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

446 lines
16 KiB
TypeScript
Raw Permalink Normal View History

import { Editor } from '../../editor/Editor.js';
import { EditorPanelDefinition } from '../../editor/editor-panel.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' | 'claude' | 'claude-code';
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
{
__interfaces__ = [ EditorPanelDefinition.type ];
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>
<option value="claude">Claude API (Anthropic)</option>
<option value="claude-code">Claude Code (local, no key)</option>
</select>
</label>
<div class="rsp-endpoint-external">
<div class="rsp-url-row">
<label class="rsp-label">Base URL
<input class="rsp-input" data-field="endpoint.url" type="text" placeholder="http://localhost:11434/v1">
</label>
</div>
<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>
<div class="rsp-endpoint-claude-code" style="display:none">
<div class="rsp-claude-code-info">Uses the local Claude Code CLI no API key needed. Inherits your existing Claude Code login.</div>
</div>
<label class="rsp-label rsp-model-row">Model
<input class="rsp-input" data-field="endpoint.model" type="text" list="rsp-model-suggestions" placeholder="gpt-4o / llama3 / claude-opus-4-8 / …">
<datalist id="rsp-model-suggestions">
<option value="claude-opus-4-8">
<option value="claude-sonnet-4-6">
<option value="claude-haiku-4-5">
</datalist>
</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;
if ( e.targetElement && e.targetElement !== this ) 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' | 'claude' | 'claude-code',
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' );
const isCC = type === 'claude-code';
( this.querySelector( '.rsp-endpoint-external' ) as HTMLElement ).style.display = ( type === 'external' || type === 'claude' ) ? '' : 'none';
( this.querySelector( '.rsp-endpoint-tunnel' ) as HTMLElement ).style.display = type === 'tunnel' ? '' : 'none';
( this.querySelector( '.rsp-endpoint-claude-code' ) as HTMLElement ).style.display = isCC ? '' : 'none';
const urlRow = this.querySelector( '.rsp-url-row' ) as HTMLElement | null;
const modelRow = this.querySelector( '.rsp-model-row' ) as HTMLElement | null;
if ( urlRow ) urlRow.style.display = type === 'claude' ? 'none' : '';
if ( modelRow ) modelRow.style.display = isCC ? '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 );