Compare commits

...

2 Commits

12 changed files with 1258 additions and 110 deletions

View File

@ -17,6 +17,22 @@ rojo-chat-panel {
flex-shrink: 0; flex-shrink: 0;
} }
.rcp-picker {
flex: 1;
background: #1a1d2a;
color: #dde0f0;
border: 1px solid #2a2d3a;
border-radius: 6px;
padding: 4px 8px;
font-size: 0.85rem;
font-family: inherit;
outline: none;
cursor: pointer;
min-width: 0;
}
.rcp-picker:focus { border-color: #3a4a8a; }
.rcp-toolbar-icon { .rcp-toolbar-icon {
width: 32px; width: 32px;
height: 32px; height: 32px;

View File

@ -1,3 +1,4 @@
import { Editor } from '../../editor/Editor.js';
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js'; import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
declare const markdownit: ( options?: Record<string, unknown> ) => { render: ( md: string ) => string }; declare const markdownit: ( options?: Record<string, unknown> ) => { render: ( md: string ) => string };
@ -12,12 +13,22 @@ function extractRawText( node: Node ): string
return text; 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 class RojoChatPanel extends HTMLElement
{ {
_initialized = false; _initialized = false;
_conversationId: string = ''; _conversationId = '';
_sending = false; _sending = false;
_md: { render: ( s: string ) => string } | null = null; _md: { render: ( s: string ) => string } | null = null;
_rojos: RojoEntry[] = [];
_selectedPath = '';
connectedCallback(): void connectedCallback(): void
{ {
@ -31,9 +42,11 @@ class RojoChatPanel extends HTMLElement
this.innerHTML = ` this.innerHTML = `
<div class="rcp-toolbar"> <div class="rcp-toolbar">
<div class="rcp-toolbar-icon">🤖</div> <div class="rcp-toolbar-icon">🤖</div>
<span class="rcp-toolbar-name">Rojo</span> <select class="rcp-picker" title="Select Rojo">
<button class="rcp-toolbar-btn" title="Actions"></button> <option value=""> no rojo selected </option>
<button class="rcp-toolbar-btn" title="Settings"></button> </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>
<div class="rcp-history"></div> <div class="rcp-history"></div>
<div class="rcp-input-area"> <div class="rcp-input-area">
@ -47,17 +60,92 @@ class RojoChatPanel extends HTMLElement
const sendBtn = this.querySelector( '.rcp-send-btn' ) as HTMLButtonElement; const sendBtn = this.querySelector( '.rcp-send-btn' ) as HTMLButtonElement;
const inputText = this.querySelector( '.rcp-input-text' ) as HTMLElement; const inputText = this.querySelector( '.rcp-input-text' ) as HTMLElement;
const picker = this.querySelector( '.rcp-picker' ) as HTMLSelectElement;
sendBtn.addEventListener( 'click', () => this._send() ); sendBtn.addEventListener( 'click', () => this._send() );
inputText.addEventListener( 'keydown', ( e: KeyboardEvent ) => inputText.addEventListener( 'keydown', ( e: KeyboardEvent ) =>
{ {
if ( e.key === 'Enter' && !e.shiftKey ) if ( e.key === 'Enter' && !e.shiftKey ) { e.preventDefault(); this._send(); }
{
e.preventDefault();
this._send();
}
} ); } );
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 {}
} }
async _send(): Promise<void> async _send(): Promise<void>
@ -89,13 +177,20 @@ class RojoChatPanel extends HTMLElement
try 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', const response = await fetch( '/api/rojos/chat',
{ {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify( { id: this._conversationId, message: text } ), body: JSON.stringify( body ),
} } );
);
if ( !response.body ) throw new Error( 'No response body' ); if ( !response.body ) throw new Error( 'No response body' );
@ -110,8 +205,6 @@ class RojoChatPanel extends HTMLElement
const raw = decoder.decode( value, { stream: true } ); const raw = decoder.decode( value, { stream: true } );
console.log( "Received raw:", raw );
for ( const line of raw.split( '\n' ) ) for ( const line of raw.split( '\n' ) )
{ {
const trimmed = line.trim(); const trimmed = line.trim();
@ -120,7 +213,6 @@ class RojoChatPanel extends HTMLElement
try try
{ {
const msg = JSON.parse( trimmed ) as { type: string; text?: string }; const msg = JSON.parse( trimmed ) as { type: string; text?: string };
if ( msg.type === 'CHAT' && msg.text ) if ( msg.type === 'CHAT' && msg.text )
{ {
markdown += msg.text; markdown += msg.text;
@ -128,32 +220,11 @@ class RojoChatPanel extends HTMLElement
history.scrollTop = history.scrollHeight; 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 {} catch {}
} }
if ( !markdown )
{
assistantBubble.textContent = '(no response)';
} }
if ( !markdown ) assistantBubble.textContent = '(no response)';
} }
catch ( err ) catch ( err )
{ {
@ -168,7 +239,8 @@ class RojoChatPanel extends HTMLElement
addContextMenuEntries( dir: ContextMenuDirectory ): void addContextMenuEntries( dir: ContextMenuDirectory ): void
{ {
dir.add( new ContextMenuReadOnlyEntry( dir, `Rojo Chat — ${this._conversationId.slice( 0, 8 )}` ) ); const rojo = this._rojos.find( r => r.path === this._selectedPath );
dir.add( new ContextMenuReadOnlyEntry( dir, `Rojo: ${ rojo?.name ?? 'none' }` ) );
} }
} }

View File

@ -0,0 +1,294 @@
rojo-settings-panel {
display: flex;
flex-direction: column;
height: 100%;
background: #0f1117;
overflow: hidden;
}
/* ── Toolbar ─────────────────────────────────────────────── */
.rsp-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: #13151f;
border-bottom: 1px solid #2a2d3a;
flex-shrink: 0;
}
.rsp-toolbar-title {
flex: 1;
color: #e2e4ed;
font-size: 0.92rem;
font-weight: 600;
}
.rsp-save {
padding: 4px 14px;
background: #1a5fd4;
border: none;
border-radius: 5px;
color: #fff;
cursor: pointer;
font-size: 0.85rem;
}
.rsp-save:hover:not(:disabled) { background: #1450b0; }
.rsp-save:disabled {
opacity: 0.3;
cursor: default;
}
/* ── Empty state ─────────────────────────────────────────── */
.rsp-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #555;
font-size: 0.9rem;
}
/* ── Portrait ────────────────────────────────────────────── */
.rsp-portrait-wrap {
display: flex;
justify-content: center;
padding: 8px 0 4px;
}
.rsp-portrait {
width: 300px;
height: 300px;
flex-shrink: 0;
border-radius: 12px;
overflow: hidden;
background: #0a0c12;
}
.rsp-portrait svg {
display: block;
width: 100%;
height: 100%;
}
/* ── Form ────────────────────────────────────────────────── */
.rsp-form {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 20px;
}
.rsp-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.rsp-section-title {
color: #7c8cff;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
padding-bottom: 4px;
border-bottom: 1px solid #1e2235;
}
.rsp-name-row {
display: flex;
align-items: flex-end;
gap: 8px;
}
.rsp-name-label {
flex: 1;
min-width: 0;
}
.rsp-sync-btn {
flex-shrink: 0;
padding: 6px 10px;
background: #1e2235;
border: 1px solid #2a2d3a;
border-radius: 6px;
color: #8890a8;
font-size: 0.78rem;
cursor: pointer;
white-space: nowrap;
margin-bottom: 1px;
}
.rsp-sync-btn:hover { background: #252a40; color: #c0c6de; }
.rsp-id-row {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 0 4px;
}
.rsp-id-label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #555;
flex-shrink: 0;
}
.rsp-id-value {
font-size: 0.75rem;
font-family: ui-monospace, "Cascadia Code", monospace;
color: #555;
word-break: break-all;
user-select: all;
}
.rsp-label {
display: flex;
flex-direction: column;
gap: 5px;
color: #8890a8;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.rsp-input {
background: #13151f;
color: #dde0f0;
border: 1px solid #2a2d3a;
border-radius: 6px;
padding: 7px 10px;
font-size: 0.88rem;
font-family: inherit;
outline: none;
width: 100%;
box-sizing: border-box;
}
.rsp-input:focus {
border-color: #3a4a8a;
background: #141620;
}
.rsp-textarea {
resize: vertical;
line-height: 1.5;
}
.rsp-system-prompt {
min-height: 120px;
font-family: ui-monospace, "Cascadia Code", monospace;
font-size: 0.83rem;
}
select.rsp-input {
appearance: none;
cursor: pointer;
}
.rsp-endpoint-external,
.rsp-endpoint-tunnel {
display: flex;
flex-direction: column;
gap: 12px;
}
/* ── Tunnel picker ───────────────────────────────────────────────────────── */
.rsp-tunnel-row {
display: flex;
align-items: flex-end;
gap: 8px;
}
.rsp-tunnel-id-label { flex: 1; min-width: 0; }
.rsp-browse-tunnels-btn {
flex-shrink: 0;
padding: 6px 10px;
background: #1e2235;
border: 1px solid #2a2d3a;
border-radius: 6px;
color: #8890a8;
font-size: 0.78rem;
cursor: pointer;
white-space: nowrap;
margin-bottom: 1px;
}
.rsp-browse-tunnels-btn:hover { background: #252a40; color: #c0c6de; }
.rsp-tunnel-picker {
border: 1px solid #1e2235;
border-radius: 7px;
overflow: hidden;
background: #0d0f18;
}
.rsp-tunnel-list {
display: flex;
flex-direction: column;
max-height: 180px;
overflow-y: auto;
}
.rsp-tunnel-entry {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
cursor: pointer;
border-bottom: 1px solid #1a1d2a;
transition: background 0.12s;
}
.rsp-tunnel-entry:last-child { border-bottom: none; }
.rsp-tunnel-entry:hover { background: #141826; }
.rsp-tunnel-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
background: #2a2d3a;
}
.rsp-tunnel-active .rsp-tunnel-dot {
background: #22c55e;
box-shadow: 0 0 5px #22c55e88;
}
.rsp-tunnel-name {
flex: 1;
color: #c0c6de;
font-size: 0.83rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rsp-tunnel-purpose {
font-size: 0.72rem;
color: #555;
flex-shrink: 0;
}
.rsp-tunnel-loading,
.rsp-tunnel-empty {
padding: 10px;
font-size: 0.8rem;
color: #555;
text-align: center;
}

View File

@ -0,0 +1,424 @@
import { Editor } from '../../editor/Editor.js';
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
const NS_INKSCAPE = 'http://www.inkscape.org/namespaces/inkscape';
function inkscapeLabel( el: Element ): string
{
return el.getAttributeNS( NS_INKSCAPE, 'label' ) ?? el.getAttribute( 'inkscape:label' ) ?? '';
}
// ── Types ────────────────────────────────────────────────────────────────────
interface RojoEndpoint
{
type: 'external' | 'tunnel';
url: string;
model: string;
apiKey: string;
tunnelId: string;
}
interface RojoColor
{
name: string;
value: string;
target: 'fill' | 'stroke' | 'both';
}
interface RojoLayer
{
name: string;
selected: string;
}
interface RojoAppearance
{
colors: RojoColor[];
layers: RojoLayer[];
}
export interface RojoSettings
{
id: string;
name: string;
description: string;
systemPrompt: string;
endpoint: RojoEndpoint;
appearance: RojoAppearance;
}
function defaultSettings(): RojoSettings
{
return {
id: '',
name: '',
description: '',
systemPrompt: '',
endpoint: { type: 'external', url: '', model: '', apiKey: '', tunnelId: '' },
appearance: { colors: [], layers: [] },
};
}
// ── Name normalization ────────────────────────────────────────────────────────
function normalizeName( name: string ): string
{
return name
.toLowerCase()
.normalize( 'NFD' )
.replace( /[̀-ͯ]/g, '' )
.replace( /[^a-z0-9\s-]/g, '' )
.trim()
.replace( /\s+/g, '-' )
.replace( /-+/g, '-' );
}
// ── SVG processing ────────────────────────────────────────────────────────────
const CHAT_MASK_VIEWBOX = '1.5883274 0.60429424 25.973324 23.865683';
async function loadPortraitSvg(): Promise<string>
{
const res = await fetch( '/rojos/rojo-base.svg' );
const text = await res.text();
const parser = new DOMParser();
const doc = parser.parseFromString( text, 'image/svg+xml' );
const svg = doc.documentElement;
doc.querySelector( 'sodipodi\\:namedview, [id="namedview7"]' )?.remove();
const toRemove: Element[] = [];
svg.querySelectorAll( '*' ).forEach( el =>
{
const label = inkscapeLabel( el );
if ( label.endsWith( '-pivot' ) || label === 'chat-cutout' || label === 'chat-mask' )
{
toRemove.push( el );
}
} );
toRemove.forEach( el => el.remove() );
svg.setAttribute( 'viewBox', CHAT_MASK_VIEWBOX );
svg.setAttribute( 'width', '100%' );
svg.setAttribute( 'height', '100%' );
return new XMLSerializer().serializeToString( svg );
}
// ── Component ────────────────────────────────────────────────────────────────
class RojoSettingsPanel extends HTMLElement
{
currentPath: string | null = null;
_initialized = false;
_ignoreChange = false;
_appearance: RojoAppearance = { colors: [], layers: [] };
_rojoId = '';
connectedCallback(): void
{
if ( this._initialized ) return;
this._initialized = true;
this.className = 'rojo-settings-panel';
this.innerHTML = `
<div class="rsp-toolbar">
<span class="rsp-toolbar-title">🤖 Rojo Settings</span>
<button class="rsp-save" disabled>Save</button>
</div>
<div class="rsp-empty">Open a .rojo file from the file tree</div>
<div class="rsp-form" style="display:none">
<div class="rsp-portrait-wrap">
<div class="rsp-portrait"></div>
</div>
<div class="rsp-section">
<div class="rsp-name-row">
<label class="rsp-label rsp-name-label">Name
<input class="rsp-input" data-field="name" type="text" placeholder="My Rojo">
</label>
<button class="rsp-sync-btn" title="Rename file to match this name">Sync filename</button>
</div>
<div class="rsp-id-row">
<span class="rsp-id-label">ID</span>
<span class="rsp-id-value"></span>
</div>
<label class="rsp-label">Description
<textarea class="rsp-input rsp-textarea" data-field="description" placeholder="What this rojo does…" rows="2"></textarea>
</label>
<label class="rsp-label">System Prompt
<textarea class="rsp-input rsp-textarea rsp-system-prompt" data-field="systemPrompt" placeholder="You are a helpful assistant…" rows="6"></textarea>
</label>
</div>
<div class="rsp-section">
<div class="rsp-section-title">Endpoint</div>
<label class="rsp-label">Type
<select class="rsp-input" data-field="endpoint.type">
<option value="external">External / Local URL</option>
<option value="tunnel">Tunnel (tunnel.rokojori.com)</option>
</select>
</label>
<div class="rsp-endpoint-external">
<label class="rsp-label">Base URL
<input class="rsp-input" data-field="endpoint.url" type="text" placeholder="http://localhost:11434/v1">
</label>
<label class="rsp-label">API Key
<input class="rsp-input" data-field="endpoint.apiKey" type="password" placeholder="sk-… (or leave blank for local)">
</label>
</div>
<div class="rsp-endpoint-tunnel" style="display:none">
<div class="rsp-tunnel-row">
<label class="rsp-label rsp-tunnel-id-label">Tunnel ID
<input class="rsp-input" data-field="endpoint.tunnelId" type="text" placeholder="uuid of the tunnel">
</label>
<button class="rsp-browse-tunnels-btn" title="Browse available tunnels">Browse</button>
</div>
<div class="rsp-tunnel-picker" style="display:none">
<div class="rsp-tunnel-list"></div>
</div>
</div>
<label class="rsp-label">Model
<input class="rsp-input" data-field="endpoint.model" type="text" placeholder="gpt-4o / llama3 / gemma3 / …">
</label>
</div>
</div>
`;
this.querySelector( '.rsp-save' )!.addEventListener( 'click', () => this._save() );
this.querySelector( '.rsp-sync-btn' )!.addEventListener( 'click', () => this._syncFilename() );
this.querySelector( '.rsp-browse-tunnels-btn' )!.addEventListener( 'click', () => this._browseTunnels() );
this.querySelectorAll( '[data-field]' ).forEach( el =>
{
el.addEventListener( 'input', () => this._onFieldChange() );
} );
( this.querySelector( '[data-field="endpoint.type"]' ) as HTMLSelectElement )
.addEventListener( 'change', () => this._updateEndpointVisibility() );
Editor.get().onDocumentOpened.addListener( e =>
{
if ( 'rojo-settings-panel' !== e.editorTag ) return;
this._loadDocument( e.path, e.content );
} );
document.addEventListener( 'keydown', ( e: KeyboardEvent ) =>
{
if ( !this.currentPath ) return;
if ( e.ctrlKey && e.key === 's' ) { e.preventDefault(); this._save(); }
} );
this._initPortrait();
}
async _browseTunnels(): Promise<void>
{
const picker = this.querySelector( '.rsp-tunnel-picker' ) as HTMLElement;
const list = this.querySelector( '.rsp-tunnel-list' ) as HTMLElement;
if ( picker.style.display !== 'none' ) { picker.style.display = 'none'; return; }
list.innerHTML = '<div class="rsp-tunnel-loading">Loading…</div>';
picker.style.display = '';
try
{
const res = await fetch( '/api/rojos/tunnels/browse' );
const data = await res.json();
if ( !res.ok || !Array.isArray( data ) )
{
list.innerHTML = `<div class="rsp-tunnel-empty">Error: ${ data?.error ?? 'Unexpected response' }</div>`;
return;
}
const tunnels = data as Array<{ id: string; name: string; purpose: string; active: boolean; }>;
if ( !tunnels.length )
{
list.innerHTML = '<div class="rsp-tunnel-empty">No tunnels available. Add one in the Tunnel Agent app.</div>';
return;
}
list.innerHTML = tunnels.map( t => `
<div class="rsp-tunnel-entry ${ t.active ? 'rsp-tunnel-active' : '' }" data-tunnel-id="${ t.id }">
<span class="rsp-tunnel-dot"></span>
<span class="rsp-tunnel-name">${ t.name }</span>
<span class="rsp-tunnel-purpose">${ t.purpose }</span>
</div>
` ).join( '' );
list.querySelectorAll( '.rsp-tunnel-entry' ).forEach( entry =>
{
entry.addEventListener( 'click', () =>
{
const id = ( entry as HTMLElement ).dataset.tunnelId ?? '';
this._setField( 'endpoint.tunnelId', id );
this._onFieldChange();
picker.style.display = 'none';
} );
} );
}
catch
{
list.innerHTML = '<div class="rsp-tunnel-empty">Could not reach tunnel server.</div>';
}
}
async _initPortrait(): Promise<void>
{
try
{
const svgHtml = await loadPortraitSvg();
( this.querySelector( '.rsp-portrait' ) as HTMLElement ).innerHTML = svgHtml;
}
catch {}
}
_loadDocument( path: string, content: string ): void
{
this.currentPath = path;
this._updateTabLabel( path );
let settings: RojoSettings = defaultSettings();
try { settings = JSON.parse( content ); }
catch {}
this._rojoId = settings.id ?? '';
this._appearance = settings.appearance ?? { colors: [], layers: [] };
this._ignoreChange = true;
this._populateForm( settings );
this._ignoreChange = false;
( this.querySelector( '.rsp-empty' ) as HTMLElement ).style.display = 'none';
( this.querySelector( '.rsp-form' ) as HTMLElement ).style.display = '';
this._updateSaveButton( false );
}
_populateForm( s: RojoSettings ): void
{
this._setField( 'name', s.name ?? '' );
this._setField( 'description', s.description ?? '' );
this._setField( 'systemPrompt', s.systemPrompt ?? '' );
this._setField( 'endpoint.type', s.endpoint?.type ?? 'external' );
this._setField( 'endpoint.url', s.endpoint?.url ?? '' );
this._setField( 'endpoint.apiKey', s.endpoint?.apiKey ?? '' );
this._setField( 'endpoint.tunnelId', s.endpoint?.tunnelId ?? '' );
this._setField( 'endpoint.model', s.endpoint?.model ?? '' );
( this.querySelector( '.rsp-id-value' ) as HTMLElement ).textContent =
s.id ? s.id : '—';
this._updateEndpointVisibility();
}
_setField( field: string, value: string ): void
{
const el = this.querySelector( `[data-field="${ field }"]` ) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
if ( el ) el.value = value;
}
_getField( field: string ): string
{
const el = this.querySelector( `[data-field="${ field }"]` ) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
return el ? el.value : '';
}
_readSettings(): RojoSettings
{
return {
id: this._rojoId,
name: this._getField( 'name' ),
description: this._getField( 'description' ),
systemPrompt: this._getField( 'systemPrompt' ),
endpoint: {
type: this._getField( 'endpoint.type' ) as 'external' | 'tunnel',
url: this._getField( 'endpoint.url' ),
apiKey: this._getField( 'endpoint.apiKey' ),
tunnelId: this._getField( 'endpoint.tunnelId' ),
model: this._getField( 'endpoint.model' ),
},
appearance: this._appearance,
};
}
_onFieldChange(): void
{
if ( this._ignoreChange || !this.currentPath ) return;
Editor.get().markDirty( this.currentPath, JSON.stringify( this._readSettings(), null, 2 ) );
this._updateSaveButton( true );
}
_updateEndpointVisibility(): void
{
const type = this._getField( 'endpoint.type' );
( this.querySelector( '.rsp-endpoint-external' ) as HTMLElement ).style.display = type === 'external' ? '' : 'none';
( this.querySelector( '.rsp-endpoint-tunnel' ) as HTMLElement ).style.display = type === 'tunnel' ? '' : 'none';
}
async _syncFilename(): Promise<void>
{
if ( !this.currentPath ) return;
const name = this._getField( 'name' );
const slug = normalizeName( name );
if ( !slug ) return;
const newName = slug + '.rojo';
const oldPath = this.currentPath;
const res = await fetch( `/api/files/${ Editor.get().projectId }/rename`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify( { path: oldPath, newName } ),
} );
if ( !res.ok ) return;
// Derive new path: same directory, new filename
const dir = oldPath.lastIndexOf( '/' ) >= 0
? oldPath.slice( 0, oldPath.lastIndexOf( '/' ) )
: '';
const newPath = dir ? `${ dir }/${ newName }` : newName;
// Migrate openDocs entry to the new path
const editor = Editor.get();
const doc = editor.openDocs.get( oldPath );
if ( doc )
{
editor.openDocs.set( newPath, doc );
editor.openDocs.delete( oldPath );
}
this.currentPath = newPath;
this._updateTabLabel( newPath );
}
async _save(): Promise<void>
{
if ( !this.currentPath ) return;
await Editor.get().save( this.currentPath );
this._updateSaveButton( false );
}
_updateTabLabel( path: string ): void
{
const name = path ? path.slice( path.lastIndexOf( '/' ) + 1 ) : '';
this.dispatchEvent( new CustomEvent( 'panel:label-change', { bubbles: true, detail: { label: '🤖 ' + name } } ) );
}
_updateSaveButton( dirty: boolean ): void
{
( this.querySelector( '.rsp-save' ) as HTMLButtonElement ).disabled = !dirty;
}
addContextMenuEntries( dir: ContextMenuDirectory ): void
{
dir.add( new ContextMenuReadOnlyEntry( dir, this.currentPath ? `Rojo: ${ this.currentPath }` : 'No rojo open' ) );
}
}
customElements.define( 'rojo-settings-panel', RojoSettingsPanel );

View File

@ -78,6 +78,7 @@ class TabContainer extends HTMLElement {
{ label: 'Code Editor', panelType: 'code-panel', tag: 'code-panel' }, { label: 'Code Editor', panelType: 'code-panel', tag: 'code-panel' },
{ label: 'File Tree', panelType: 'file-tree', tag: 'file-tree-panel' }, { label: 'File Tree', panelType: 'file-tree', tag: 'file-tree-panel' },
{ label: 'Rojo Chat', panelType: 'rojo-chat', tag: 'rojo-chat-panel' }, { label: 'Rojo Chat', panelType: 'rojo-chat', tag: 'rojo-chat-panel' },
{ label: 'Rojo Settings', panelType: 'rojo-settings', tag: 'rojo-settings-panel' },
]; ];
for (const pt of panelTypes) { for (const pt of panelTypes) {
addDir.add(new ContextMenuEntry(addDir, pt.label, () => { addDir.add(new ContextMenuEntry(addDir, pt.label, () => {

View File

@ -8,6 +8,7 @@ export class FileEditorRegistry
{ {
static readonly DefaultEntries: RegistryEntry[] = static readonly DefaultEntries: RegistryEntry[] =
[ [
{ suffix: 'rojo', editor: 'RojoSettingsPanel' },
{ suffix: 'html', editor: 'HTMLEditorPanel' }, { suffix: 'html', editor: 'HTMLEditorPanel' },
{ suffix: 'htm', editor: 'HTMLEditorPanel' }, { suffix: 'htm', editor: 'HTMLEditorPanel' },
{ suffix: 'js', editor: 'CodePanel' }, { suffix: 'js', editor: 'CodePanel' },
@ -26,6 +27,7 @@ export class FileEditorRegistry
static readonly EditorTagNames: Record<string, string> = static readonly EditorTagNames: Record<string, string> =
{ {
'RojoSettingsPanel': 'rojo-settings-panel',
'HTMLEditorPanel': 'html-editor-panel', 'HTMLEditorPanel': 'html-editor-panel',
'CodePanel': 'code-panel', 'CodePanel': 'code-panel',
}; };

View File

@ -11,6 +11,7 @@
<link rel="stylesheet" href="/components/html-editor-panel/html-editor-panel.css"> <link rel="stylesheet" href="/components/html-editor-panel/html-editor-panel.css">
<link rel="stylesheet" href="/components/confirm-dialog/confirm-dialog.css"> <link rel="stylesheet" href="/components/confirm-dialog/confirm-dialog.css">
<link rel="stylesheet" href="/components/rojo-chat-panel/rojo-chat-panel.css"> <link rel="stylesheet" href="/components/rojo-chat-panel/rojo-chat-panel.css">
<link rel="stylesheet" href="/components/rojo-settings-panel/rojo-settings-panel.css">
<link rel="stylesheet" href="/vendor/codemirror.min.css"> <link rel="stylesheet" href="/vendor/codemirror.min.css">
<link rel="stylesheet" href="/components/code-panel/code-panel.css"> <link rel="stylesheet" href="/components/code-panel/code-panel.css">
<style> <style>
@ -28,6 +29,7 @@
<script type="module" src="/components/confirm-dialog/confirm-dialog.js"></script> <script type="module" src="/components/confirm-dialog/confirm-dialog.js"></script>
<script src="/vendor/markdown-it.min.js"></script> <script src="/vendor/markdown-it.min.js"></script>
<script type="module" src="/components/rojo-chat-panel/rojo-chat-panel.js"></script> <script type="module" src="/components/rojo-chat-panel/rojo-chat-panel.js"></script>
<script type="module" src="/components/rojo-settings-panel/rojo-settings-panel.js"></script>
<script src="/vendor/codemirror.min.js"></script> <script src="/vendor/codemirror.min.js"></script>
<script src="/vendor/cm-mode-xml.min.js"></script> <script src="/vendor/cm-mode-xml.min.js"></script>
<script src="/vendor/cm-mode-javascript.min.js"></script> <script src="/vendor/cm-mode-javascript.min.js"></script>

View File

@ -24,15 +24,15 @@
inkscape:deskcolor="#333333" inkscape:deskcolor="#333333"
inkscape:document-units="px" inkscape:document-units="px"
showgrid="false" showgrid="false"
inkscape:zoom="3.3424804" inkscape:zoom="18.907924"
inkscape:cx="-46.821516" inkscape:cx="11.397338"
inkscape:cy="45.923979" inkscape:cy="9.6520377"
inkscape:window-width="1920" inkscape:window-width="1920"
inkscape:window-height="1017" inkscape:window-height="1017"
inkscape:window-x="-8" inkscape:window-x="-8"
inkscape:window-y="-8" inkscape:window-y="-8"
inkscape:window-maximized="1" inkscape:window-maximized="1"
inkscape:current-layer="g26" /><defs inkscape:current-layer="g23" /><defs
id="defs2"><clipPath id="defs2"><clipPath
clipPathUnits="userSpaceOnUse" clipPathUnits="userSpaceOnUse"
id="clipPath7940"><rect id="clipPath7940"><rect
@ -41,7 +41,15 @@
width="1440" width="1440"
height="810" height="810"
x="0" x="0"
y="0" /></clipPath></defs><g y="0" /></clipPath></defs><rect
style="fill:#9cdbf8;fill-opacity:1;stroke:none;stroke-width:0.5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
id="rect40"
width="31.443003"
height="30.467443"
x="-0.75042945"
y="-1.3507732"
ry="4.2382751"
inkscape:label="background" /><g
id="g26" id="g26"
inkscape:label="root-transform" inkscape:label="root-transform"
transform="translate(-794.40483,-571.077)"><g transform="translate(-794.40483,-571.077)"><g
@ -124,7 +132,7 @@
r="0.37521484" r="0.37521484"
inkscape:label="arm-r-pivot" inkscape:label="arm-r-pivot"
transform="matrix(-0.78898412,0.61441359,0.61441359,0.78898412,438.93525,-341.91227)" /></g><rect transform="matrix(-0.78898412,0.61441359,0.61441359,0.78898412,438.93525,-341.91227)" /></g><rect
style="opacity:1;fill:#949494;fill-opacity:1;stroke:#390000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers" style="opacity:1;fill:#1d52f5;fill-opacity:1;stroke:#390000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
id="rect1" id="rect1"
width="9.695344" width="9.695344"
height="8.6811571" height="8.6811571"
@ -174,7 +182,7 @@
style="display:inline"><g style="display:inline"><g
id="g31" id="g31"
inkscape:label="hair-graphics"><path inkscape:label="hair-graphics"><path
style="opacity:1;fill:#da7300;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke" style="opacity:1;fill:#110e16;fill-opacity:1;stroke:#005983;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
d="m 802.71715,590.06034 1.85722,-0.95514 c 0,0 -1.03344,-5.71606 3.3704,-5.65003 1.75987,0.0264 2.43899,1.16705 2.43899,1.16705 0,0 2.58007,-1.3539 3.74746,0.52985 1.1674,1.88375 3.01917,1.06117 3.01917,1.06117 0,0 2.22866,-5.57165 -1.0082,-8.22482 -3.23687,-2.65317 -7.69419,-2.97155 -7.69419,-2.97155 l -3.68538,2.05127 c 0,0 0.81995,-2.84722 -0.93114,-2.82069 -1.75109,0.0265 -1.88375,0.66329 -1.88375,0.66329 l 0.19026,1.39279 -1.83522,0.22564 c 0,0 -4.4308,1.1674 -1.6715,1.85722 2.75929,0.68983 3.17494,0.28828 3.17494,0.28828 l -2.19632,2.92203 c 0,0 1.56842,6.63292 2.57663,8.17176 1.0082,1.53884 0.53063,0.29185 0.53063,0.29185 z" d="m 802.71715,590.06034 1.85722,-0.95514 c 0,0 -1.03344,-5.71606 3.3704,-5.65003 1.75987,0.0264 2.43899,1.16705 2.43899,1.16705 0,0 2.58007,-1.3539 3.74746,0.52985 1.1674,1.88375 3.01917,1.06117 3.01917,1.06117 0,0 2.22866,-5.57165 -1.0082,-8.22482 -3.23687,-2.65317 -7.69419,-2.97155 -7.69419,-2.97155 l -3.68538,2.05127 c 0,0 0.81995,-2.84722 -0.93114,-2.82069 -1.75109,0.0265 -1.88375,0.66329 -1.88375,0.66329 l 0.19026,1.39279 -1.83522,0.22564 c 0,0 -4.4308,1.1674 -1.6715,1.85722 2.75929,0.68983 3.17494,0.28828 3.17494,0.28828 l -2.19632,2.92203 c 0,0 1.56842,6.63292 2.57663,8.17176 1.0082,1.53884 0.53063,0.29185 0.53063,0.29185 z"
id="path20" id="path20"
sodipodi:nodetypes="ccscscsccscccsccscc" sodipodi:nodetypes="ccscscsccscccsccscc"
@ -283,7 +291,7 @@
cy="590.43231" cy="590.43231"
r="0.37521484" r="0.37521484"
inkscape:label="head-pivot" /></g><circle inkscape:label="head-pivot" /></g><circle
style="opacity:1;fill:#ff01e9;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" style="display:inline;opacity:1;fill:#ff01e9;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill"
id="circle28" id="circle28"
cx="809.2298" cx="809.2298"
cy="600.00842" cy="600.00842"
@ -295,4 +303,42 @@
cx="809.2298" cx="809.2298"
cy="604.21301" cy="604.21301"
r="0.37521484" r="0.37521484"
inkscape:label="root-pivot" /></g></svg> inkscape:label="root-pivot" /></g><g
id="g40"
transform="translate(0.712107,1.2458453)"
inkscape:label="hand-right"
style="display:inline"><path
id="path33"
style="fill:#f6e9c3;fill-opacity:1;stroke:#390000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
d="m 23.49461,18.354689 c -0.03984,-0.02239 -0.08255,-0.04008 -0.127809,-0.05469 -0.362105,-0.116791 -0.747713,0.08069 -0.864507,0.442792 l -0.426279,1.324715 a 2.3800299,2.2971435 14.956584 0 0 -0.111317,-0.04405 c 0.0048,-0.02587 0.0077,-0.05163 0.0096,-0.07851 l 0.112746,-1.641825 c 0.02639,-0.379557 -0.257745,-0.707525 -0.637303,-0.733913 -0.379556,-0.02639 -0.705788,0.258638 -0.732175,0.638196 l -0.114484,1.640932 c -0.0021,0.03027 -0.0019,0.06051 -1.32e-4,0.08998 a 2.3800299,2.2971435 14.956584 0 0 -0.103366,0.02592 c -0.0044,-0.06313 -0.01778,-0.127466 -0.04067,-0.19001 l -0.56532,-1.544604 c -0.130784,-0.35729 -0.523872,-0.539861 -0.881161,-0.409079 -0.357289,0.130783 -0.53986,0.523872 -0.409079,0.881161 l 0.565321,1.544605 c 0.053,0.144797 0.149432,0.262056 0.268724,0.34019 a 2.3800299,2.2971435 14.956584 0 0 -0.559577,0.981676 2.3800299,2.2971435 14.956584 0 0 1.707398,2.834494 2.3800299,2.2971435 14.956584 0 0 2.356166,-0.648815 c 0.169295,0.0374 0.353537,0.01177 0.512882,-0.08774 l 1.395186,-0.872728 c 0.322712,-0.201537 0.420038,-0.623482 0.218505,-0.946192 -0.201536,-0.322711 -0.623483,-0.420038 -0.946192,-0.218506 l -0.582348,0.363844 a 2.3800299,2.2971435 14.956584 0 0 -0.36674,-1.025255 c 0.0578,-0.06739 0.103765,-0.146921 0.132842,-0.236966 l 0.504106,-1.565809 c 0.102191,-0.316843 -0.03621,-0.652801 -0.314998,-0.809789 z" /><path
style="fill:none;fill-opacity:1;stroke:#390000;stroke-width:0.5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
d="m 20.131882,20.281213 c 0,0 1.60243,-0.671587 2.534559,0.533693"
id="path35" /><path
style="fill:none;fill-opacity:1;stroke:#390000;stroke-width:0.5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
d="m 23.363323,21.223444 c 0,0 0.769932,1.557567 -0.375052,2.562841"
id="path39" /></g><path
id="rect10"
style="fill:#333333;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:stroke fill markers"
d="m 1.206142,-3.0986883 c -2.3480044,0 -4.2382813,1.8902768 -4.2382813,4.2382812 V 38.493108 c 0,2.348005 1.8902769,4.238281 4.2382813,4.238281 h 26.939454 c 2.348004,0 4.238281,-1.890276 4.238281,-4.238281 V 1.1395929 c 0,-2.3480044 -1.890277,-4.2382812 -4.238281,-4.2382812 z M 6.0245014,0.92084289 H 23.559658 c 2.348005,0 4.238281,1.89027691 4.238281,4.23828131 V 20.174749 c 0,2.348004 -1.890276,4.238281 -4.238281,4.238281 H 6.0245014 c -2.3480044,0 -4.2382813,-1.890277 -4.2382813,-4.238281 V 5.1591242 c 0,-2.3480044 1.8902769,-4.23828131 4.2382813,-4.23828131 z"
inkscape:label="chat-cutout" /><rect
style="fill:none;stroke:#04a2ff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:stroke fill markers;stroke-opacity:1"
id="rect2"
width="25.973324"
height="23.865683"
x="1.5883274"
y="0.60429424"
ry="4.2382751"
inkscape:label="chat-mask" /><g
id="g41"
inkscape:label="hand-left"
style="display:inline"><path
id="rect28"
style="fill:#f6e9c3;fill-opacity:1;stroke:#390000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
d="m 8.0469818,17.768132 c -0.044378,0.01092 -0.087629,0.02724 -0.1306365,0.04754 -0.3440199,0.162512 -0.4902027,0.570337 -0.3276935,0.914359 l 0.595253,1.257879 a 2.2971435,2.3800299 61.794592 0 0 -0.1113302,0.04402 c -0.014229,-0.02212 -0.029662,-0.04296 -0.046688,-0.06385 l -1.040872,-1.274708 c -0.240394,-0.294907 -0.6719981,-0.33977 -0.9669058,-0.09938 -0.2949086,0.240393 -0.3378934,0.671461 -0.097499,0.966369 l 1.0389951,1.275247 c 0.019172,0.02352 0.040026,0.04542 0.061442,0.06572 a 2.2971435,2.3800299 61.794592 0 0 -0.057669,0.08961 c -0.046429,-0.043 -0.1001622,-0.08081 -0.1596457,-0.110766 L 5.3347667,20.140218 c -0.3398055,-0.17115 -0.7514233,-0.03542 -0.9225746,0.304379 -0.1711507,0.339804 -0.035424,0.751423 0.3043786,0.922575 l 1.4689654,0.739954 c 0.1377124,0.06936 0.2882622,0.08893 0.4287252,0.06432 a 2.2971435,2.3800299 61.794592 0 0 0.26336,1.098842 2.2971435,2.3800299 61.794592 0 0 3.1843887,0.899575 2.2971435,2.3800299 61.794592 0 0 1.27481,-2.085027 c 0.149078,-0.08853 0.265933,-0.233259 0.31409,-0.414848 l 0.420679,-1.590982 c 0.09753,-0.367761 -0.120116,-0.742115 -0.487872,-0.839646 -0.367761,-0.09753 -0.742115,0.120117 -0.839646,0.487873 l -0.175886,0.663759 A 2.2971435,2.3800299 61.794592 0 0 9.5993372,19.894019 c -0.00394,-0.08869 -0.024815,-0.178148 -0.065202,-0.26372 L 8.830728,18.143323 C 8.6885278,17.842305 8.357757,17.691926 8.0470145,17.768123 Z" /><path
style="fill:none;fill-opacity:1;stroke:#390000;stroke-width:0.5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
d="m 9.5310841,20.788387 c 0,0 -1.0649928,1.372807 -0.1434041,2.586165"
id="path34" /><path
style="fill:none;fill-opacity:1;stroke:#390000;stroke-width:0.5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
d="m 9.0022053,21.846144 c 0,0 -0.126233,0.09028 -0.7780586,0.351652"
id="path40"
sodipodi:nodetypes="cc" /></g></svg>

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -1,16 +1,17 @@
import { ChatOpenAI } from "@langchain/openai"; import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage, AIMessage, BaseMessage } from "@langchain/core/messages"; import { HumanMessage, AIMessage, SystemMessage, BaseMessage } from "@langchain/core/messages";
import { RojosConfig } from "./RojosConfig";
const model = new ChatOpenAI({ export interface AgentConfig
model: RojosConfig.model, {
apiKey: RojosConfig.apiKey, baseURL: string;
configuration: { model: string;
baseURL: RojosConfig.baseURL, apiKey: string;
}, headers?: Record<string, string>;
}); systemPrompt?: string;
}
type MessageData = { role: "user" | "assistant"; content: string }; type MessageRole = "user" | "assistant" | "system";
type MessageData = { role: MessageRole; content: string };
type SessionData = { id: string; messages: MessageData[] }; type SessionData = { id: string; messages: MessageData[] };
const sessions = new Map<string, SessionData>(); const sessions = new Map<string, SessionData>();
@ -18,15 +19,36 @@ const sessions = new Map<string, SessionData>();
function toBaseMessages( messages: MessageData[] ): BaseMessage[] function toBaseMessages( messages: MessageData[] ): BaseMessage[]
{ {
return messages.map( m => return messages.map( m =>
m.role === "user" ? new HumanMessage( m.content ) : new AIMessage( m.content ) {
); if ( m.role === "user" ) return new HumanMessage( m.content );
if ( m.role === "assistant" ) return new AIMessage( m.content );
return new SystemMessage( m.content );
} );
} }
export async function getAgentStream( id: string, userMessage: string ) export async function getAgentStream( id: string, userMessage: string, config: AgentConfig )
{ {
if ( ! sessions.has( id ) ) const model = new ChatOpenAI(
{ {
sessions.set( id, { id, messages: [] } ); model: config.model,
apiKey: config.apiKey,
configuration:
{
baseURL: config.baseURL,
defaultHeaders: config.headers ?? {},
},
} );
if ( !sessions.has( id ) )
{
const messages: MessageData[] = [];
if ( config.systemPrompt?.trim() )
{
messages.push( { role: "system", content: config.systemPrompt } );
}
sessions.set( id, { id, messages } );
} }
const session = sessions.get( id )!; const session = sessions.get( id )!;
@ -35,7 +57,7 @@ export async function getAgentStream( id: string, userMessage: string )
return model.stream( toBaseMessages( session.messages ) ); return model.stream( toBaseMessages( session.messages ) );
} }
export function updateAgentConversation( id: string, assistantMessage: string ) export function updateAgentConversation( id: string, assistantMessage: string ): void
{ {
const session = sessions.get( id ); const session = sessions.get( id );

View File

@ -1,16 +1,202 @@
import { Router } from "express"; import { Router } from "express";
import fs from "fs";
import path from "path";
import crypto from "crypto";
import { requireAuth } from "../middleware/auth"; import { requireAuth } from "../middleware/auth";
import { getAgentStream, updateAgentConversation } from "../rojos/RojosAgent"; import { getAgentStream, updateAgentConversation, AgentConfig } from "../rojos/RojosAgent";
import { RJLog } from "../../library-ts/node/log/RJLog"; import { readProjectFile, writeProjectFile, createProjectDirectory } from "../storage";
import { checkAccess } from "../projectAccess";
import { RojosConfig } from "../rojos/RojosConfig";
import { ROOT } from "../rootDir";
const router = Router(); const router = Router();
const STORAGE = path.join( ROOT, "build", "data", "storage" );
router.use( requireAuth ); router.use( requireAuth );
// ── Helpers ──────────────────────────────────────────────────────────────────
function normalizeUrl( url: string ): string
{
if ( !url.startsWith( "http://" ) && !url.startsWith( "https://" ) )
{
return "http://" + url;
}
return url;
}
function pick<T>( arr: T[] ): T
{
return arr[ Math.floor( Math.random() * arr.length ) ];
}
function cap( s: string ): string { return s[ 0 ].toUpperCase() + s.slice( 1 ); }
const ADJECTIVES = [
"funky", "cool", "groovy", "wild", "sharp", "slick", "swift", "bold",
"bright", "calm", "daring", "eager", "fierce", "jolly", "lively",
"quirky", "smart", "vivid", "zesty", "sleek", "crisp", "nifty", "rad",
"sunny", "sassy", "snappy", "peppy", "zippy", "breezy", "mellow",
];
const ROLES = [
"boss", "doctor", "player", "teacher", "hunter", "builder", "maker",
"dancer", "singer", "painter", "writer", "coder", "pilot", "chef",
"sailor", "rider", "climber", "dreamer", "wanderer", "scout", "keeper",
"helper", "guide", "mentor", "runner", "seeker", "ranger", "scholar",
];
// 50% European, 50% rest of world — modern names, male/female mixed
const NAMES = [
"Sofia", "Elena", "Lucas", "Mia", "Noah", "Emma", "Leon", "Lena",
"Felix", "Anna", "Max", "Clara", "Julian", "Sara", "Lars", "Nina",
"Tom", "Lara", "Erik", "Ida", "Hugo", "Vera", "Otto", "Maja",
"Kai", "Maya", "Arjun", "Zara", "Aisha", "Omar", "Nala", "Ravi",
"Yuki", "Kenji", "Amara", "Diego", "Mateo", "Jae", "Sora", "Kira",
"Rio", "Zion", "Nova", "Leila", "Cyrus", "Noa", "Bao", "Mila",
];
function generateRojoName(): { display: string; slug: string }
{
const adj = pick( ADJECTIVES );
const role = pick( ROLES );
const name = pick( NAMES );
return {
display: `${ cap( adj ) } ${ cap( role ) } ${ name }`,
slug: `${ adj }-${ role }-${ name.toLowerCase() }`,
};
}
// ── Recursive *.rojo scanner ─────────────────────────────────────────────────
function scanRojoFiles( dir: string, projectRoot: string, out: string[] = [] ): string[]
{
if ( !fs.existsSync( dir ) ) return out;
for ( const entry of fs.readdirSync( dir, { withFileTypes: true } ) )
{
const full = path.join( dir, entry.name );
if ( entry.isDirectory() )
{
scanRojoFiles( full, projectRoot, out );
}
else if ( entry.name.endsWith( ".rojo" ) )
{
out.push( path.relative( projectRoot, full ).replace( /\\/g, "/" ) );
}
}
return out;
}
// ── Tunnel browser (proxies to tunnel.rokojori.com) ─────────────────────────
router.get( "/tunnels/browse", async ( req, res ) =>
{
const purpose = typeof req.query.purpose === "string" ? req.query.purpose : "";
const token = req.cookies?.accessToken
|| ( typeof req.headers.authorization === "string" && req.headers.authorization.startsWith( "Bearer " )
? req.headers.authorization.slice( 7 )
: "" );
if ( !token ) { res.status( 401 ).json( { error: "No auth token" } ); return; }
const tunnelServer = process.env.TUNNEL_SERVER_URL || "https://tunnel.rokojori.com";
const url = purpose
? `${ tunnelServer }/api/tunnels/available?purpose=${ encodeURIComponent( purpose ) }`
: `${ tunnelServer }/api/tunnels/available`;
try
{
const upstream = await fetch( url, { headers: { Authorization: `Bearer ${ token }` } } );
const data = await upstream.json();
res.json( data );
}
catch
{
res.status( 502 ).json( { error: "Could not reach tunnel server" } );
}
} );
// ── List rojos ───────────────────────────────────────────────────────────────
router.get( "/:projectId/list", ( req, res ) =>
{
const denied = checkAccess( req.params.projectId, req.user!, "view" );
if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; }
const projectRoot = path.join( STORAGE, req.params.projectId, "root" );
const rojoDir = path.join( projectRoot, "workspace", "rojos" );
const files = scanRojoFiles( rojoDir, projectRoot );
const rojos = files.map( filePath =>
{
let id = "", name = filePath, description = "";
const content = readProjectFile( req.params.projectId, filePath );
if ( content )
{
try
{
const s = JSON.parse( content );
id = s.id || "";
name = s.name || name;
description = s.description || "";
}
catch {}
}
return { id, name, description, path: filePath };
} );
res.json( rojos );
} );
// ── Create rojo ──────────────────────────────────────────────────────────────
router.post( "/:projectId/create", ( req, res ) =>
{
const denied = checkAccess( req.params.projectId, req.user!, "edit" );
if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; }
const { parentDir } = req.body as { parentDir?: string };
const dir = parentDir || "workspace/rojos";
const { display, slug } = generateRojoName();
const id = crypto.randomUUID();
const filePath = `${ dir }/${ slug }.rojo`;
const defaults = JSON.stringify(
{
id,
name: display,
description: "",
systemPrompt: "",
endpoint: { type: "external", url: "", model: "", apiKey: "", tunnelId: "" },
appearance: { colors: [], layers: [] },
}, null, 2 );
const ok = writeProjectFile( req.params.projectId, filePath, defaults );
if ( !ok ) { res.status( 500 ).json( { error: "Could not create rojo file" } ); return; }
res.json( { id, path: filePath, name: display } );
} );
// ── Chat ─────────────────────────────────────────────────────────────────────
router.post( "/chat", async ( req, res ) => router.post( "/chat", async ( req, res ) =>
{ {
const { id, message } = req.body as { id: string; message: string }; const { id, message, projectId, rojoPath } = req.body as
{
// RJLog.log( "Chat", { id, message } ); id: string;
message: string;
projectId?: string;
rojoPath?: string;
};
if ( !id || !message ) if ( !id || !message )
{ {
@ -18,9 +204,53 @@ router.post( "/chat", async ( req, res ) =>
return; return;
} }
const config: AgentConfig = {
baseURL: RojosConfig.baseURL,
model: RojosConfig.model,
apiKey: RojosConfig.apiKey,
systemPrompt: "",
};
if ( projectId && rojoPath )
{
const content = readProjectFile( projectId, rojoPath );
if ( content )
{
try try
{ {
const agentStream = await getAgentStream( id, message ); const settings = JSON.parse( content );
const ep = settings.endpoint ?? {};
config.systemPrompt = settings.systemPrompt ?? "";
if ( ep.type === "tunnel" && ep.tunnelId )
{
const tunnelServer = process.env.TUNNEL_SERVER_URL || "https://tunnel.rokojori.com";
config.baseURL = `${ tunnelServer }/t/${ ep.tunnelId }/v1`;
config.model = ep.model || RojosConfig.model;
config.apiKey = "not-needed";
const token = req.cookies?.accessToken
|| ( typeof req.headers.authorization === "string" && req.headers.authorization.startsWith( "Bearer " )
? req.headers.authorization.slice( 7 )
: "" );
if ( token ) config.headers = { Authorization: `Bearer ${ token }` };
}
else if ( ep.url )
{
config.baseURL = normalizeUrl( ep.url );
config.model = ep.model || RojosConfig.model;
config.apiKey = ep.apiKey || "not-needed";
}
}
catch {}
}
}
try
{
const agentStream = await getAgentStream( id, message, config );
res.setHeader( "Content-Type", "text/event-stream" ); res.setHeader( "Content-Type", "text/event-stream" );
res.setHeader( "Cache-Control", "no-cache" ); res.setHeader( "Cache-Control", "no-cache" );
@ -34,8 +264,6 @@ router.post( "/chat", async ( req, res ) =>
const content = chunk.content; const content = chunk.content;
let text = ""; let text = "";
// RJLog.log( "Chunk", chunk );
if ( typeof content === "string" ) if ( typeof content === "string" )
{ {
text = content; text = content;
@ -44,14 +272,8 @@ router.post( "/chat", async ( req, res ) =>
{ {
for ( const c of content ) for ( const c of content )
{ {
if ( typeof c === "string" ) if ( typeof c === "string" ) text += c;
{ else if ( c && typeof c === "object" && "text" in c ) text += ( c as any ).text;
text += c;
}
else if ( c && typeof c === "object" && "text" in c )
{
text += ( c as any ).text;
}
} }
} }
@ -67,18 +289,13 @@ router.post( "/chat", async ( req, res ) =>
} }
updateAgentConversation( id, collected.join( "" ) ); updateAgentConversation( id, collected.join( "" ) );
res.write( JSON.stringify( { type: "DONE" } ) + "\n" ); res.write( JSON.stringify( { type: "DONE" } ) + "\n" );
res.end(); res.end();
} }
catch ( err ) catch ( err )
{ {
console.error( err ); console.error( err );
if ( !res.headersSent ) res.status( 500 ).json( { error: "Failed to get agent response" } );
if ( !res.headersSent )
{
res.status( 500 ).json( { error: "Failed to get agent response" } );
}
} }
} ); } );

View File

@ -25,15 +25,15 @@
inkscape:deskcolor="#333333" inkscape:deskcolor="#333333"
inkscape:document-units="px" inkscape:document-units="px"
showgrid="false" showgrid="false"
inkscape:zoom="0.41781006" inkscape:zoom="0.59087265"
inkscape:cx="-1493.5016" inkscape:cx="563.57321"
inkscape:cy="3112.6584" inkscape:cy="733.66063"
inkscape:window-width="1920" inkscape:window-width="1920"
inkscape:window-height="1017" inkscape:window-height="1017"
inkscape:window-x="-8" inkscape:window-x="-8"
inkscape:window-y="-8" inkscape:window-y="-8"
inkscape:window-maximized="1" inkscape:window-maximized="1"
inkscape:current-layer="g101" /><defs inkscape:current-layer="layer1" /><defs
id="defs2"><linearGradient id="defs2"><linearGradient
id="linearGradient99" id="linearGradient99"
inkscape:collect="never"><stop inkscape:collect="never"><stop

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -22,6 +22,58 @@
<div class="lane"> <div class="lane">
<div class="lane-header">To Do</div> <div class="lane-header">To Do</div>
<task-item class="blue hide-content">
<task-title>Rojo Character Editor</task-title>
<task-content>
Allow changing colors and selecting layers of a Rojo character from within
the rojo-settings-panel. Colors map to fill/stroke/both targets on SVG elements;
layers toggle visibility of named groups or swap between variants.
The appearance field (colors[], layers[]) is already in the settings.rojo schema.
</task-content>
</task-item>
<task-item class="blue hide-content">
<task-title>Rojo Character Animation Box</task-title>
<task-content>
Animation system for emotional feedback during conversations.
Animations can be scripted (predefined sequences) or driven dynamically by
an LLM that emits emotion tags alongside its response. The animation box
plays character animations (idle, happy, thinking, surprised, etc.) in the
portrait area of the rojo-settings-panel and rojo-chat-panel.
</task-content>
</task-item>
<task-item class="blue hide-content">
<task-title>File tree: drag-and-drop move for files and directories</task-title>
<task-content>
Allow files and directories to be moved by dragging them within the file tree.
Dragging a file onto a directory moves it inside; dragging a directory onto another
directory moves the whole subtree. Use the existing file rename API
(POST /api/files/:projectId/rename) — moving is a rename to a new parent path.
</task-content>
</task-item>
<task-item class="blue hide-content">
<task-title>Tab context menu on right-click (tabs and empty tab bar area)</task-title>
<task-content>
The tab ⋮ menu already works but should also open on right-click anywhere on
the tab bar — both on individual tabs and on the empty space to the right of the tabs.
Right-clicking a specific tab should also offer a "Close this tab" action directly.
</task-content>
</task-item>
<task-item class="blue hide-content">
<task-title>File tree double-click: auto-open or focus existing editor</task-title>
<task-content>
When a file is double-clicked in the file tree:
— If an editor panel that can handle the file type is already open and not pinned,
focus that panel's tab and load the file into it.
— If no suitable unpinned editor exists, open a new panel of the correct type
in the active section before loading the file.
Single-click keeps current behaviour (selection only, no open).
</task-content>
</task-item>
<task-item class="blue hide-content"> <task-item class="blue hide-content">
<task-title>Remote Projects in Electron</task-title> <task-title>Remote Projects in Electron</task-title>
<task-content> <task-content>