176 lines
5.2 KiB
TypeScript
176 lines
5.2 KiB
TypeScript
|
|
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 );
|