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

176 lines
5.2 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 { 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;
}
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 = `
<div class="rcp-toolbar">
<div class="rcp-toolbar-icon">🤖</div>
<span class="rcp-toolbar-name">Rojo</span>
<button class="rcp-toolbar-btn" title="Actions">★</button>
<button class="rcp-toolbar-btn" title="Settings">☰</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;
sendBtn.addEventListener( 'click', () => this._send() );
inputText.addEventListener( 'keydown', ( e: KeyboardEvent ) =>
{
if ( e.key === 'Enter' && !e.shiftKey )
{
e.preventDefault();
this._send();
}
} );
}
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 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 );