import { Editor } from '../../editor/Editor.js'; import { EditorPanelDefinition } from '../../editor/editor-panel.js'; import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js'; declare const markdownit: ( options?: Record ) => { render: ( md: string ) => string }; async function typeText( text: string, onPiece: ( piece: string ) => void ): Promise { const THRESHOLD = 6; const STEP = 3; const DELAY_MS = 18; if ( text.length <= THRESHOLD ) { onPiece( text ); return; } for ( let i = 0; i < text.length; i += STEP ) { onPiece( text.slice( i, i + STEP ) ); if ( i + STEP < text.length ) await new Promise( r => setTimeout( r, DELAY_MS ) ); } } 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; } function formatToolArgs( args: Record ): string { const entries = Object.entries( args ); if ( 0 === entries.length ) return ''; return entries.map( ( [ , v ] ) => { if ( typeof v !== 'string' ) return JSON.stringify( v ); const s = v.length > 60 ? v.slice( 0, 57 ) + 'โ€ฆ' : v; return `"${ s }"`; } ).join( ', ' ); } function tryParseResult( raw: string ): unknown { try { return JSON.parse( raw ); } catch { return raw; } } function formatToolResult( parsed: unknown ): string { if ( parsed === null || parsed === undefined ) return 'โœ“'; if ( typeof parsed !== 'object' ) return String( parsed ); const obj = parsed as Record; if ( true === obj.ok ) return 'โœ“'; if ( Array.isArray( parsed ) ) return `${ parsed.length } items`; if ( 'content' in obj && typeof obj.content === 'string' ) { const preview = obj.content.slice( 0, 40 ).replace( /\n/g, ' ' ); return `"${ preview }${ obj.content.length > 40 ? 'โ€ฆ' : '' }"`; } if ( 'type' in obj ) return `${ obj.type } (${ ( obj as any ).size ?? '' })`; return JSON.stringify( parsed ).slice( 0, 60 ); } 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 { __interfaces__ = [ EditorPanelDefinition.type ]; _initialized = false; _conversationId = ''; _sending = false; _md: { render: ( s: string ) => string } | null = null; _rojos: RojoEntry[] = []; _selectedPath = ''; _thinkingInterval: ReturnType | null = null; 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 = `
๐Ÿค–
`; 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(); } } ); inputText.addEventListener( 'focus', () => { setTimeout( () => inputText.scrollIntoView( { behavior: 'smooth', block: 'nearest' } ), 300 ); } ); 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 { const editor = Editor.get(); let url: string; if ( editor.localRoot ) { url = `/api/local/rojos?root=${ encodeURIComponent( editor.localRoot ) }`; } else if ( editor.remoteProject ) { url = `/api/remote/rojos/${ editor.remoteProject }/list`; } else if ( editor.projectId ) { url = `/api/rojos/${ editor.projectId }/list`; } else { return; } try { const res = await fetch( url ); 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 = '' + this._rojos.map( r => `` ).join( '' ); if ( prev && this._rojos.some( r => r.path === prev ) ) { picker.value = prev; this._selectedPath = prev; } else { this._selectedPath = ''; } } async _createRojo(): Promise { const editor = Editor.get(); const dir = this._selectedPath ? parentDir( this._selectedPath ) : 'workspace/rojos'; let url: string; let body: Record; if ( editor.localRoot ) { url = '/api/local/rojos/create'; body = { root: editor.localRoot, parentDir: dir }; } else if ( editor.remoteProject ) { url = `/api/remote/rojos/${ editor.remoteProject }/create`; body = { parentDir: dir }; } else if ( editor.projectId ) { url = `/api/rojos/${ editor.projectId }/create`; body = { parentDir: dir }; } else { return; } try { const res = await fetch( url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( body ), } ); 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.openDocument( newPath ); } catch {} } _startThinking( bubble: HTMLElement ): void { const frames = [ '.', '..', '...', '....', '---', '--', "/","|",":","ยท" ]; let i = 0; bubble.textContent = frames[ 0 ]; this._thinkingInterval = setInterval( () => { i = ( i + 1 ) % frames.length; bubble.textContent = frames[ i ]; }, 250 ); } _stopThinking(): void { if ( this._thinkingInterval === null ) return; clearInterval( this._thinkingInterval ); this._thinkingInterval = null; } async _send(): Promise { 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'; history.appendChild( assistantBubble ); history.scrollTop = history.scrollHeight; this._startThinking( assistantBubble ); try { const body: Record = { id: this._conversationId, message: text }; if ( this._selectedPath ) { const editor = Editor.get(); body.rojoPath = this._selectedPath; if ( editor.localRoot ) { body.localRoot = editor.localRoot; } else if ( editor.remoteProject ) { body.remoteProject = editor.remoteProject; } else { body.projectId = editor.projectId; } } 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 = ''; let pendingToolBubble: HTMLElement | null = null; 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; name?: string; args?: Record; result?: string; }; if ( 'CHAT' === msg.type && msg.text ) { if ( !markdown ) { this._stopThinking(); assistantBubble.innerHTML = ''; } await typeText( msg.text, piece => { markdown += piece; assistantBubble.innerHTML = this._md!.render( markdown ); history.scrollTop = history.scrollHeight; } ); } else if ( 'TOOL_CALL' === msg.type && msg.name ) { this._stopThinking(); pendingToolBubble = document.createElement( 'div' ); pendingToolBubble.className = 'rcp-tool-activity'; pendingToolBubble.innerHTML = `` + `โš™ ${ msg.name }` + ` ${ formatToolArgs( msg.args ?? {} ) }` + ``; history.insertBefore( pendingToolBubble, assistantBubble ); history.scrollTop = history.scrollHeight; } else if ( 'TOOL_RESULT' === msg.type && pendingToolBubble ) { const parsed = tryParseResult( msg.result ?? '' ); const isError = parsed && typeof parsed === 'object' && 'error' in parsed; const label = isError ? `โœ— ${ ( parsed as any ).error }` : formatToolResult( parsed ); const resultLine = document.createElement( 'rcp-tool-result-line' ); resultLine.className = `rcp-tool-result-line${ isError ? ' error' : '' }`; resultLine.textContent = `โ†’ ${ label }`; pendingToolBubble.appendChild( resultLine ); pendingToolBubble = null; history.scrollTop = history.scrollHeight; } } catch {} } } if ( !markdown ) { this._stopThinking(); assistantBubble.textContent = '(no response)'; } } catch ( err ) { this._stopThinking(); 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 );