248 lines
7.5 KiB
TypeScript
248 lines
7.5 KiB
TypeScript
import { Editor } from '../../editor/Editor.js';
|
||
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
|
||
|
||
declare const markdownit: ( options?: Record<string, unknown> ) => { render: ( md: string ) => string };
|
||
|
||
function extractRawText( node: Node ): string
|
||
{
|
||
if ( node.nodeType === Node.TEXT_NODE ) return node.nodeValue ?? '';
|
||
if ( node.nodeName === 'BR' ) return '\n';
|
||
if ( node.nodeType !== Node.ELEMENT_NODE ) return '';
|
||
let text = '';
|
||
node.childNodes.forEach( child => text += extractRawText( child ) );
|
||
return text;
|
||
}
|
||
|
||
interface RojoEntry { id: string; name: string; description: string; path: string; }
|
||
|
||
function parentDir( filePath: string ): string
|
||
{
|
||
const slash = filePath.lastIndexOf( '/' );
|
||
return slash >= 0 ? filePath.slice( 0, slash ) : 'workspace/rojos';
|
||
}
|
||
|
||
class RojoChatPanel extends HTMLElement
|
||
{
|
||
_initialized = false;
|
||
_conversationId = '';
|
||
_sending = false;
|
||
_md: { render: ( s: string ) => string } | null = null;
|
||
_rojos: RojoEntry[] = [];
|
||
_selectedPath = '';
|
||
|
||
connectedCallback(): void
|
||
{
|
||
if ( this._initialized ) return;
|
||
this._initialized = true;
|
||
|
||
this._conversationId = crypto.randomUUID();
|
||
this._md = markdownit( { html: false, linkify: true, breaks: true } );
|
||
|
||
this.className = 'rojo-chat-panel';
|
||
this.innerHTML = `
|
||
<div class="rcp-toolbar">
|
||
<div class="rcp-toolbar-icon">🤖</div>
|
||
<select class="rcp-picker" title="Select Rojo">
|
||
<option value="">— no rojo selected —</option>
|
||
</select>
|
||
<button class="rcp-toolbar-btn rcp-new-btn" title="New Rojo">+</button>
|
||
<button class="rcp-toolbar-btn rcp-refresh-btn" title="Refresh rojo list">↻</button>
|
||
</div>
|
||
<div class="rcp-history"></div>
|
||
<div class="rcp-input-area">
|
||
<div class="rcp-input-text" contenteditable="true"></div>
|
||
<div class="rcp-input-buttons">
|
||
<button class="rcp-input-btn rcp-plus-btn" title="Attach">+</button>
|
||
<button class="rcp-input-btn rcp-send-btn" title="Send">▲</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const sendBtn = this.querySelector( '.rcp-send-btn' ) as HTMLButtonElement;
|
||
const inputText = this.querySelector( '.rcp-input-text' ) as HTMLElement;
|
||
const picker = this.querySelector( '.rcp-picker' ) as HTMLSelectElement;
|
||
|
||
sendBtn.addEventListener( 'click', () => this._send() );
|
||
|
||
inputText.addEventListener( 'keydown', ( e: KeyboardEvent ) =>
|
||
{
|
||
if ( e.key === 'Enter' && !e.shiftKey ) { e.preventDefault(); this._send(); }
|
||
} );
|
||
|
||
picker.addEventListener( 'change', () =>
|
||
{
|
||
this._selectedPath = picker.value;
|
||
} );
|
||
|
||
this.querySelector( '.rcp-new-btn' )!.addEventListener( 'click', () => this._createRojo() );
|
||
this.querySelector( '.rcp-refresh-btn' )!.addEventListener( 'click', () => this._loadRojos() );
|
||
|
||
this._loadRojos();
|
||
}
|
||
|
||
async _loadRojos(): Promise<void>
|
||
{
|
||
const projectId = Editor.get().projectId;
|
||
if ( !projectId ) return;
|
||
|
||
try
|
||
{
|
||
const res = await fetch( `/api/rojos/${ projectId }/list` );
|
||
if ( !res.ok ) return;
|
||
this._rojos = await res.json() as RojoEntry[];
|
||
this._renderPicker();
|
||
}
|
||
catch {}
|
||
}
|
||
|
||
_renderPicker(): void
|
||
{
|
||
const picker = this.querySelector( '.rcp-picker' ) as HTMLSelectElement;
|
||
const prev = this._selectedPath;
|
||
|
||
picker.innerHTML = '<option value="">— no rojo selected —</option>' +
|
||
this._rojos.map( r =>
|
||
`<option value="${ r.path }">${ r.name || r.path.slice( r.path.lastIndexOf( '/' ) + 1 ) }</option>`
|
||
).join( '' );
|
||
|
||
if ( prev && this._rojos.some( r => r.path === prev ) )
|
||
{
|
||
picker.value = prev;
|
||
this._selectedPath = prev;
|
||
}
|
||
else
|
||
{
|
||
this._selectedPath = '';
|
||
}
|
||
}
|
||
|
||
async _createRojo(): Promise<void>
|
||
{
|
||
const projectId = Editor.get().projectId;
|
||
if ( !projectId ) return;
|
||
|
||
// Place new rojo in the same directory as the currently selected one,
|
||
// or directly in workspace/rojos if nothing is selected.
|
||
const dir = this._selectedPath
|
||
? parentDir( this._selectedPath )
|
||
: 'workspace/rojos';
|
||
|
||
try
|
||
{
|
||
const res = await fetch( `/api/rojos/${ projectId }/create`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify( { parentDir: dir } ),
|
||
} );
|
||
|
||
if ( !res.ok ) return;
|
||
const { path: newPath } = await res.json() as { id: string; path: string; name: string };
|
||
|
||
await this._loadRojos();
|
||
this._selectedPath = newPath;
|
||
( this.querySelector( '.rcp-picker' ) as HTMLSelectElement ).value = newPath;
|
||
|
||
await Editor.get().openDocument( newPath );
|
||
}
|
||
catch {}
|
||
}
|
||
|
||
async _send(): Promise<void>
|
||
{
|
||
if ( this._sending ) return;
|
||
|
||
const inputText = this.querySelector( '.rcp-input-text' ) as HTMLElement;
|
||
const sendBtn = this.querySelector( '.rcp-send-btn' ) as HTMLButtonElement;
|
||
const history = this.querySelector( '.rcp-history' ) as HTMLElement;
|
||
|
||
const text = extractRawText( inputText ).replaceAll( ' ', ' ' ).trim();
|
||
if ( !text ) return;
|
||
|
||
inputText.innerHTML = '';
|
||
this._sending = true;
|
||
sendBtn.disabled = true;
|
||
|
||
const userBubble = document.createElement( 'div' );
|
||
userBubble.className = 'rcp-user-bubble';
|
||
userBubble.textContent = text;
|
||
history.appendChild( userBubble );
|
||
history.scrollTop = history.scrollHeight;
|
||
|
||
const assistantBubble = document.createElement( 'div' );
|
||
assistantBubble.className = 'rcp-assistant-bubble';
|
||
assistantBubble.textContent = '…';
|
||
history.appendChild( assistantBubble );
|
||
history.scrollTop = history.scrollHeight;
|
||
|
||
try
|
||
{
|
||
const body: Record<string, string> = { id: this._conversationId, message: text };
|
||
|
||
if ( this._selectedPath )
|
||
{
|
||
body.projectId = Editor.get().projectId;
|
||
body.rojoPath = this._selectedPath;
|
||
}
|
||
|
||
const response = await fetch( '/api/rojos/chat',
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify( body ),
|
||
} );
|
||
|
||
if ( !response.body ) throw new Error( 'No response body' );
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let markdown = '';
|
||
|
||
while ( true )
|
||
{
|
||
const { done, value } = await reader.read();
|
||
if ( done ) break;
|
||
|
||
const raw = decoder.decode( value, { stream: true } );
|
||
|
||
for ( const line of raw.split( '\n' ) )
|
||
{
|
||
const trimmed = line.trim();
|
||
if ( !trimmed ) continue;
|
||
|
||
try
|
||
{
|
||
const msg = JSON.parse( trimmed ) as { type: string; text?: string };
|
||
if ( msg.type === 'CHAT' && msg.text )
|
||
{
|
||
markdown += msg.text;
|
||
assistantBubble.innerHTML = this._md!.render( markdown );
|
||
history.scrollTop = history.scrollHeight;
|
||
}
|
||
}
|
||
catch {}
|
||
}
|
||
}
|
||
|
||
if ( !markdown ) assistantBubble.textContent = '(no response)';
|
||
}
|
||
catch ( err )
|
||
{
|
||
assistantBubble.textContent = '(error: could not reach Rojo)';
|
||
console.error( err );
|
||
}
|
||
|
||
this._sending = false;
|
||
sendBtn.disabled = false;
|
||
history.scrollTop = history.scrollHeight;
|
||
}
|
||
|
||
addContextMenuEntries( dir: ContextMenuDirectory ): void
|
||
{
|
||
const rojo = this._rojos.find( r => r.path === this._selectedPath );
|
||
dir.add( new ContextMenuReadOnlyEntry( dir, `Rojo: ${ rojo?.name ?? 'none' }` ) );
|
||
}
|
||
}
|
||
|
||
customElements.define( 'rojo-chat-panel', RojoChatPanel );
|