rojects/source/components/rojo-chat-panel/rojo-chat-panel.ts

412 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown> ) => { render: ( md: string ) => string };
async function typeText( text: string, onPiece: ( piece: string ) => void ): Promise<void>
{
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<void>( 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, unknown> ): 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<string, unknown>;
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<typeof setInterval> | 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 = `
<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(); }
} );
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<void>
{
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 = '<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 editor = Editor.get();
const dir = this._selectedPath
? parentDir( this._selectedPath )
: 'workspace/rojos';
let url: string;
let body: Record<string, string>;
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<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';
history.appendChild( assistantBubble );
history.scrollTop = history.scrollHeight;
this._startThinking( assistantBubble );
try
{
const body: Record<string, string> = { 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<string, unknown>;
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 =
`<rcp-tool-call-line class="rcp-tool-call-line">` +
`⚙ <span class="rcp-tool-call-name">${ msg.name }</span>` +
` ${ formatToolArgs( msg.args ?? {} ) }` +
`</rcp-tool-call-line>`;
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 );