import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js'; declare const markdownit: ( options?: Record ) => { 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; } class RojoChatPanel extends HTMLElement { _initialized = false; _conversationId: string = ''; _sending = false; _md: { render: ( s: string ) => string } | 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 = `
🤖
Rojo
`; const sendBtn = this.querySelector( '.rcp-send-btn' ) as HTMLButtonElement; const inputText = this.querySelector( '.rcp-input-text' ) as HTMLElement; sendBtn.addEventListener( 'click', () => this._send() ); inputText.addEventListener( 'keydown', ( e: KeyboardEvent ) => { if ( e.key === 'Enter' && !e.shiftKey ) { e.preventDefault(); this._send(); } } ); } 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'; assistantBubble.textContent = '…'; history.appendChild( assistantBubble ); history.scrollTop = history.scrollHeight; try { const response = await fetch( '/api/rojos/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( { id: this._conversationId, message: text } ), } ); 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 } ); console.log( "Received raw:", raw ); 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 { // partial or non-JSON chunk — skip } } } const remaining = decoder.decode(); if ( remaining.trim() ) { try { const msg = JSON.parse( remaining.trim() ) as { type: string; text?: string }; if ( msg.type === 'CHAT' && msg.text ) { markdown += msg.text; assistantBubble.innerHTML = this._md!.render( markdown ); } } 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 { dir.add( new ContextMenuReadOnlyEntry( dir, `Rojo Chat — ${this._conversationId.slice( 0, 8 )}` ) ); } } customElements.define( 'rojo-chat-panel', RojoChatPanel );