Compare commits

..

No commits in common. "614176d2a9c06e622c48167aff3ad9060732ace5" and "93f8c7bb33577e93824fbf04241ab09e388cc4df" have entirely different histories.

12 changed files with 110 additions and 1258 deletions

View File

@ -17,22 +17,6 @@ rojo-chat-panel {
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 {
width: 32px;
height: 32px;

View File

@ -1,4 +1,3 @@
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 };
@ -13,22 +12,12 @@ function extractRawText( node: Node ): string
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 = '';
_conversationId: string = '';
_sending = false;
_md: { render: ( s: string ) => string } | null = null;
_rojos: RojoEntry[] = [];
_selectedPath = '';
connectedCallback(): void
{
@ -42,11 +31,9 @@ class RojoChatPanel extends HTMLElement
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>
<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">
@ -60,92 +47,17 @@ class RojoChatPanel extends HTMLElement
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(); }
if ( e.key === 'Enter' && !e.shiftKey )
{
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>
@ -177,20 +89,13 @@ class RojoChatPanel extends HTMLElement
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 ),
} );
body: JSON.stringify( { id: this._conversationId, message: text } ),
}
);
if ( !response.body ) throw new Error( 'No response body' );
@ -205,6 +110,8 @@ class RojoChatPanel extends HTMLElement
const raw = decoder.decode( value, { stream: true } );
console.log( "Received raw:", raw );
for ( const line of raw.split( '\n' ) )
{
const trimmed = line.trim();
@ -213,6 +120,7 @@ class RojoChatPanel extends HTMLElement
try
{
const msg = JSON.parse( trimmed ) as { type: string; text?: string };
if ( msg.type === 'CHAT' && msg.text )
{
markdown += msg.text;
@ -220,11 +128,32 @@ class RojoChatPanel extends HTMLElement
history.scrollTop = history.scrollHeight;
}
}
catch {}
catch
{
// partial or non-JSON chunk — skip
}
}
}
if ( !markdown ) assistantBubble.textContent = '(no response)';
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 )
{
@ -239,8 +168,7 @@ class RojoChatPanel extends HTMLElement
addContextMenuEntries( dir: ContextMenuDirectory ): void
{
const rojo = this._rojos.find( r => r.path === this._selectedPath );
dir.add( new ContextMenuReadOnlyEntry( dir, `Rojo: ${ rojo?.name ?? 'none' }` ) );
dir.add( new ContextMenuReadOnlyEntry( dir, `Rojo Chat — ${this._conversationId.slice( 0, 8 )}` ) );
}
}

View File

@ -1,294 +0,0 @@
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

@ -1,424 +0,0 @@
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,7 +78,6 @@ class TabContainer extends HTMLElement {
{ label: 'Code Editor', panelType: 'code-panel', tag: 'code-panel' },
{ label: 'File Tree', panelType: 'file-tree', tag: 'file-tree-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) {
addDir.add(new ContextMenuEntry(addDir, pt.label, () => {

View File

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

View File

@ -11,7 +11,6 @@
<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/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="/components/code-panel/code-panel.css">
<style>
@ -29,7 +28,6 @@
<script type="module" src="/components/confirm-dialog/confirm-dialog.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-settings-panel/rojo-settings-panel.js"></script>
<script src="/vendor/codemirror.min.js"></script>
<script src="/vendor/cm-mode-xml.min.js"></script>
<script src="/vendor/cm-mode-javascript.min.js"></script>

View File

@ -24,15 +24,15 @@
inkscape:deskcolor="#333333"
inkscape:document-units="px"
showgrid="false"
inkscape:zoom="18.907924"
inkscape:cx="11.397338"
inkscape:cy="9.6520377"
inkscape:zoom="3.3424804"
inkscape:cx="-46.821516"
inkscape:cy="45.923979"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="g23" /><defs
inkscape:current-layer="g26" /><defs
id="defs2"><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath7940"><rect
@ -41,15 +41,7 @@
width="1440"
height="810"
x="0"
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
y="0" /></clipPath></defs><g
id="g26"
inkscape:label="root-transform"
transform="translate(-794.40483,-571.077)"><g
@ -132,7 +124,7 @@
r="0.37521484"
inkscape:label="arm-r-pivot"
transform="matrix(-0.78898412,0.61441359,0.61441359,0.78898412,438.93525,-341.91227)" /></g><rect
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"
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"
id="rect1"
width="9.695344"
height="8.6811571"
@ -182,7 +174,7 @@
style="display:inline"><g
id="g31"
inkscape:label="hair-graphics"><path
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"
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"
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"
sodipodi:nodetypes="ccscscsccscccsccscc"
@ -291,7 +283,7 @@
cy="590.43231"
r="0.37521484"
inkscape:label="head-pivot" /></g><circle
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"
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"
id="circle28"
cx="809.2298"
cy="600.00842"
@ -303,42 +295,4 @@
cx="809.2298"
cy="604.21301"
r="0.37521484"
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>
inkscape:label="root-pivot" /></g></svg>

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

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

View File

@ -1,202 +1,16 @@
import { Router } from "express";
import fs from "fs";
import path from "path";
import crypto from "crypto";
import { requireAuth } from "../middleware/auth";
import { getAgentStream, updateAgentConversation, AgentConfig } from "../rojos/RojosAgent";
import { readProjectFile, writeProjectFile, createProjectDirectory } from "../storage";
import { checkAccess } from "../projectAccess";
import { RojosConfig } from "../rojos/RojosConfig";
import { ROOT } from "../rootDir";
import { getAgentStream, updateAgentConversation } from "../rojos/RojosAgent";
import { RJLog } from "../../library-ts/node/log/RJLog";
const router = Router();
const STORAGE = path.join( ROOT, "build", "data", "storage" );
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 ) =>
{
const { id, message, projectId, rojoPath } = req.body as
{
id: string;
message: string;
projectId?: string;
rojoPath?: string;
};
const { id, message } = req.body as { id: string; message: string };
// RJLog.log( "Chat", { id, message } );
if ( !id || !message )
{
@ -204,53 +18,9 @@ router.post( "/chat", async ( req, res ) =>
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
{
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 );
const agentStream = await getAgentStream( id, message );
res.setHeader( "Content-Type", "text/event-stream" );
res.setHeader( "Cache-Control", "no-cache" );
@ -264,6 +34,8 @@ router.post( "/chat", async ( req, res ) =>
const content = chunk.content;
let text = "";
// RJLog.log( "Chunk", chunk );
if ( typeof content === "string" )
{
text = content;
@ -272,8 +44,14 @@ router.post( "/chat", async ( req, res ) =>
{
for ( const c of content )
{
if ( typeof c === "string" ) text += c;
else if ( c && typeof c === "object" && "text" in c ) text += ( c as any ).text;
if ( typeof c === "string" )
{
text += c;
}
else if ( c && typeof c === "object" && "text" in c )
{
text += ( c as any ).text;
}
}
}
@ -289,13 +67,18 @@ router.post( "/chat", async ( req, res ) =>
}
updateAgentConversation( id, collected.join( "" ) );
res.write( JSON.stringify( { type: "DONE" } ) + "\n" );
res.end();
}
catch ( 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:document-units="px"
showgrid="false"
inkscape:zoom="0.59087265"
inkscape:cx="563.57321"
inkscape:cy="733.66063"
inkscape:zoom="0.41781006"
inkscape:cx="-1493.5016"
inkscape:cy="3112.6584"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" /><defs
inkscape:current-layer="g101" /><defs
id="defs2"><linearGradient
id="linearGradient99"
inkscape:collect="never"><stop

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -22,58 +22,6 @@
<div class="lane">
<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-title>Remote Projects in Electron</task-title>
<task-content>