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

299 lines
9.0 KiB
TypeScript
Raw 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 { 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;
}
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 = '';
_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 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 {}
}
_startThinking( bubble: HTMLElement ): void
{
const frames = [ '.', '..', '...', 'thinking', '.', '..', '...', 'imagining' ];
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 )
{
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 )
{
if ( !markdown )
{
this._stopThinking();
assistantBubble.innerHTML = '';
}
await typeText( msg.text, piece =>
{
markdown += piece;
assistantBubble.innerHTML = this._md!.render( markdown );
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 );