diff --git a/source/components/rojo-chat-panel/rojo-chat-panel.css b/source/components/rojo-chat-panel/rojo-chat-panel.css index 794f86c..4be3eb5 100644 --- a/source/components/rojo-chat-panel/rojo-chat-panel.css +++ b/source/components/rojo-chat-panel/rojo-chat-panel.css @@ -17,6 +17,22 @@ 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; diff --git a/source/components/rojo-chat-panel/rojo-chat-panel.ts b/source/components/rojo-chat-panel/rojo-chat-panel.ts index 7fae094..93dd59f 100644 --- a/source/components/rojo-chat-panel/rojo-chat-panel.ts +++ b/source/components/rojo-chat-panel/rojo-chat-panel.ts @@ -1,3 +1,4 @@ +import { Editor } from '../../editor/Editor.js'; import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js'; declare const markdownit: ( options?: Record ) => { render: ( md: string ) => string }; @@ -12,12 +13,22 @@ 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: string = ''; - _sending = false; + _initialized = false; + _conversationId = ''; + _sending = false; _md: { render: ( s: string ) => string } | null = null; + _rojos: RojoEntry[] = []; + _selectedPath = ''; connectedCallback(): void { @@ -31,9 +42,11 @@ class RojoChatPanel extends HTMLElement this.innerHTML = `
๐Ÿค–
- Rojo - - + + +
@@ -47,26 +60,101 @@ 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 + { + 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 = '' + + this._rojos.map( r => + `` + ).join( '' ); + + if ( prev && this._rojos.some( r => r.path === prev ) ) + { + picker.value = prev; + this._selectedPath = prev; + } + else + { + this._selectedPath = ''; + } + } + + async _createRojo(): Promise + { + 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 { if ( this._sending ) return; - const inputText = this.querySelector( '.rcp-input-text' ) as HTMLElement; - const sendBtn = this.querySelector( '.rcp-send-btn' ) as HTMLButtonElement; - const history = this.querySelector( '.rcp-history' ) as HTMLElement; + const inputText = this.querySelector( '.rcp-input-text' ) as HTMLElement; + const sendBtn = this.querySelector( '.rcp-send-btn' ) as HTMLButtonElement; + const history = this.querySelector( '.rcp-history' ) as HTMLElement; const text = extractRawText( inputText ).replaceAll( 'ย ', ' ' ).trim(); if ( !text ) return; @@ -75,8 +163,8 @@ class RojoChatPanel extends HTMLElement this._sending = true; sendBtn.disabled = true; - const userBubble = document.createElement( 'div' ); - userBubble.className = 'rcp-user-bubble'; + const userBubble = document.createElement( 'div' ); + userBubble.className = 'rcp-user-bubble'; userBubble.textContent = text; history.appendChild( userBubble ); history.scrollTop = history.scrollHeight; @@ -89,13 +177,20 @@ class RojoChatPanel extends HTMLElement try { + const body: Record = { 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( { id: this._conversationId, message: text } ), - } - ); + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( 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 } ); - console.log( "Received raw:", raw ); - for ( const line of raw.split( '\n' ) ) { const trimmed = line.trim(); @@ -120,7 +213,6 @@ class RojoChatPanel extends HTMLElement try { const msg = JSON.parse( trimmed ) as { type: string; text?: string }; - if ( msg.type === 'CHAT' && msg.text ) { markdown += msg.text; @@ -128,32 +220,11 @@ class RojoChatPanel extends HTMLElement history.scrollTop = history.scrollHeight; } } - catch - { - // partial or non-JSON chunk โ€” skip - } + catch {} } } - 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)'; - } + if ( !markdown ) assistantBubble.textContent = '(no response)'; } catch ( err ) { @@ -168,7 +239,8 @@ class RojoChatPanel extends HTMLElement 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' }` ) ); } } diff --git a/source/components/rojo-settings-panel/rojo-settings-panel.css b/source/components/rojo-settings-panel/rojo-settings-panel.css new file mode 100644 index 0000000..40c6af8 --- /dev/null +++ b/source/components/rojo-settings-panel/rojo-settings-panel.css @@ -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; +} diff --git a/source/components/rojo-settings-panel/rojo-settings-panel.ts b/source/components/rojo-settings-panel/rojo-settings-panel.ts new file mode 100644 index 0000000..1bcd7b0 --- /dev/null +++ b/source/components/rojo-settings-panel/rojo-settings-panel.ts @@ -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 +{ + 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 = ` +
+ ๐Ÿค– Rojo Settings + +
+
Open a .rojo file from the file tree
+ + `; + + 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 + { + 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 = '
Loadingโ€ฆ
'; + 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 = `
Error: ${ data?.error ?? 'Unexpected response' }
`; + return; + } + + const tunnels = data as Array<{ id: string; name: string; purpose: string; active: boolean; }>; + + if ( !tunnels.length ) + { + list.innerHTML = '
No tunnels available. Add one in the Tunnel Agent app.
'; + return; + } + + list.innerHTML = tunnels.map( t => ` +
+ + ${ t.name } + ${ t.purpose } +
+ ` ).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 = '
Could not reach tunnel server.
'; + } + } + + async _initPortrait(): Promise + { + 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 + { + 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 + { + 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 ); diff --git a/source/components/tab-container/tab-container.ts b/source/components/tab-container/tab-container.ts index 74b727a..592d9d2 100644 --- a/source/components/tab-container/tab-container.ts +++ b/source/components/tab-container/tab-container.ts @@ -74,10 +74,11 @@ class TabContainer extends HTMLElement { const addDir = new ContextMenuDirectory(root, 'Add'); const panelTypes = [ - { label: 'HTML Editor', panelType: 'html-editor', tag: 'html-editor-panel' }, - { 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: 'HTML Editor', panelType: 'html-editor', tag: 'html-editor-panel' }, + { 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, () => { diff --git a/source/editor/FileEditorRegistry.ts b/source/editor/FileEditorRegistry.ts index adde368..a55cca2 100644 --- a/source/editor/FileEditorRegistry.ts +++ b/source/editor/FileEditorRegistry.ts @@ -8,6 +8,7 @@ export class FileEditorRegistry { static readonly DefaultEntries: RegistryEntry[] = [ + { suffix: 'rojo', editor: 'RojoSettingsPanel' }, { suffix: 'html', editor: 'HTMLEditorPanel' }, { suffix: 'htm', editor: 'HTMLEditorPanel' }, { suffix: 'js', editor: 'CodePanel' }, @@ -26,6 +27,7 @@ export class FileEditorRegistry static readonly EditorTagNames: Record = { + 'RojoSettingsPanel': 'rojo-settings-panel', 'HTMLEditorPanel': 'html-editor-panel', 'CodePanel': 'code-panel', }; diff --git a/source/pages/editor.html b/source/pages/editor.html index 9cdc68f..0b3b074 100644 --- a/source/pages/editor.html +++ b/source/pages/editor.html @@ -11,6 +11,7 @@ +