2026-07-16 13:06:33 +00:00
|
|
|
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;
|
Editor: auth redirect, targeted file open, Godot file types
- editor-shell: redirect to / if not authenticated on load
- file-tree: open file in first clean matching panel, create new panel if all dirty
- Editor: add openDocumentIn() with targetElement for precise panel targeting
- panels: skip onDocumentOpened if targeted at a different panel
- FileEditorRegistry: add Godot extensions (gd, gdshader, gdshaderinc, gdinclude, glsl, tscn, tres, cs, gdextension)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 20:22:42 +00:00
|
|
|
if ( e.targetElement && e.targetElement !== this ) return;
|
2026-07-16 13:06:33 +00:00
|
|
|
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 );
|