diff --git a/source/components/rojo-chat-panel/rojo-chat-panel.css b/source/components/rojo-chat-panel/rojo-chat-panel.css index a667b1e..007f47d 100644 --- a/source/components/rojo-chat-panel/rojo-chat-panel.css +++ b/source/components/rojo-chat-panel/rojo-chat-panel.css @@ -130,6 +130,36 @@ rojo-chat-panel { } .rcp-assistant-bubble pre code { background: none; padding: 0; } +/* ── Tool activity ───────────────────────────────────────── */ + +.rcp-tool-activity { + align-self: flex-start; + display: flex; + flex-direction: column; + gap: 2px; + padding: 5px 10px; + border-left: 2px solid #2a3050; + font-family: ui-monospace, "Cascadia Code", monospace; + font-size: 0.78rem; + color: #434a68; +} + +.rcp-tool-call-line { + color: #5a6590; +} + +.rcp-tool-call-name { + color: #6e7fa8; +} + +.rcp-tool-result-line { + color: #3d6e45; +} + +.rcp-tool-result-line.error { + color: #8b3a3a; +} + /* ── Input area ──────────────────────────────────────────── */ .rcp-input-area { diff --git a/source/components/rojo-chat-panel/rojo-chat-panel.ts b/source/components/rojo-chat-panel/rojo-chat-panel.ts index 437ede0..f792dad 100644 --- a/source/components/rojo-chat-panel/rojo-chat-panel.ts +++ b/source/components/rojo-chat-panel/rojo-chat-panel.ts @@ -30,6 +30,48 @@ function extractRawText( node: Node ): string return text; } +function formatToolArgs( args: Record ): string +{ + const entries = Object.entries( args ); + + if ( 0 === entries.length ) return ''; + + return entries.map( ( [ , v ] ) => + { + if ( typeof v !== 'string' ) return JSON.stringify( v ); + const s = v.length > 60 ? v.slice( 0, 57 ) + '…' : v; + return `"${ s }"`; + } ).join( ', ' ); +} + +function tryParseResult( raw: string ): unknown +{ + try { return JSON.parse( raw ); } + catch { return raw; } +} + +function formatToolResult( parsed: unknown ): string +{ + if ( parsed === null || parsed === undefined ) return '✓'; + if ( typeof parsed !== 'object' ) return String( parsed ); + + const obj = parsed as Record; + + if ( true === obj.ok ) return '✓'; + + if ( Array.isArray( parsed ) ) return `${ parsed.length } items`; + + if ( 'content' in obj && typeof obj.content === 'string' ) + { + const preview = obj.content.slice( 0, 40 ).replace( /\n/g, ' ' ); + return `"${ preview }${ obj.content.length > 40 ? '…' : '' }"`; + } + + if ( 'type' in obj ) return `${ obj.type } (${ ( obj as any ).size ?? '' })`; + + return JSON.stringify( parsed ).slice( 0, 60 ); +} + interface RojoEntry { id: string; name: string; description: string; path: string; } function parentDir( filePath: string ): string @@ -241,6 +283,7 @@ class RojoChatPanel extends HTMLElement const reader = response.body.getReader(); const decoder = new TextDecoder(); let markdown = ''; + let pendingToolBubble: HTMLElement | null = null; while ( true ) { @@ -256,8 +299,16 @@ class RojoChatPanel extends HTMLElement try { - const msg = JSON.parse( trimmed ) as { type: string; text?: string }; - if ( msg.type === 'CHAT' && msg.text ) + const msg = JSON.parse( trimmed ) as + { + type: string; + text?: string; + name?: string; + args?: Record; + result?: string; + }; + + if ( 'CHAT' === msg.type && msg.text ) { if ( !markdown ) { @@ -271,6 +322,31 @@ class RojoChatPanel extends HTMLElement history.scrollTop = history.scrollHeight; } ); } + else if ( 'TOOL_CALL' === msg.type && msg.name ) + { + this._stopThinking(); + pendingToolBubble = document.createElement( 'div' ); + pendingToolBubble.className = 'rcp-tool-activity'; + pendingToolBubble.innerHTML = + `` + + `⚙ ${ msg.name }` + + ` ${ formatToolArgs( msg.args ?? {} ) }` + + ``; + history.insertBefore( pendingToolBubble, assistantBubble ); + history.scrollTop = history.scrollHeight; + } + else if ( 'TOOL_RESULT' === msg.type && pendingToolBubble ) + { + const parsed = tryParseResult( msg.result ?? '' ); + const isError = parsed && typeof parsed === 'object' && 'error' in parsed; + const label = isError ? `✗ ${ ( parsed as any ).error }` : formatToolResult( parsed ); + const resultLine = document.createElement( 'rcp-tool-result-line' ); + resultLine.className = `rcp-tool-result-line${ isError ? ' error' : '' }`; + resultLine.textContent = `→ ${ label }`; + pendingToolBubble.appendChild( resultLine ); + pendingToolBubble = null; + history.scrollTop = history.scrollHeight; + } } catch {} } diff --git a/source/editor/FileEditorRegistry.ts b/source/editor/FileEditorRegistry.ts index d14edd3..cb6b0d4 100644 --- a/source/editor/FileEditorRegistry.ts +++ b/source/editor/FileEditorRegistry.ts @@ -10,6 +10,7 @@ export class FileEditorRegistry [ { suffix: 'rojo', editor: 'RojoSettingsPanel' }, { suffix: 'page', editor: 'PageEditorPanel' }, + { suffix: 'html', editor: 'CodePanel' }, { suffix: 'js', editor: 'CodePanel' }, { suffix: 'ts', editor: 'CodePanel' }, { suffix: 'css', editor: 'CodePanel' }, diff --git a/source/server/rojos/RojosAgent.ts b/source/server/rojos/RojosAgent.ts index 4af4652..a75586f 100644 --- a/source/server/rojos/RojosAgent.ts +++ b/source/server/rojos/RojosAgent.ts @@ -1,68 +1,134 @@ import { ChatOpenAI } from "@langchain/openai"; -import { HumanMessage, AIMessage, SystemMessage, BaseMessage } from "@langchain/core/messages"; +import { HumanMessage, AIMessage, SystemMessage, ToolMessage, BaseMessage } from "@langchain/core/messages"; +import { RojoToolContext } from "./tools/RojoTool.js"; export interface AgentConfig { - baseURL: string; - model: string; - apiKey: string; - headers?: Record; + baseURL: string; + model: string; + apiKey: string; + headers?: Record; systemPrompt?: string; + tools?: object[]; } -type MessageRole = "user" | "assistant" | "system"; -type MessageData = { role: MessageRole; content: string }; -type SessionData = { id: string; messages: MessageData[] }; +export type AgentEvent = + | { type: "text"; text: string } + | { type: "tool_call"; name: string; args: Record } + | { type: "tool_result"; name: string; result: string }; + +type ToolExecutor = ( name: string, args: Record, ctx: RojoToolContext ) => Promise; + +type SessionData = { id: string; messages: BaseMessage[] }; const sessions = new Map(); -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 ); - } ); -} - -export async function getAgentStream( id: string, userMessage: string, config: AgentConfig ) +export async function* getAgentStream( + id: string, + userMessage: string, + config: AgentConfig, + toolContext?: RojoToolContext, + toolExecutor?: ToolExecutor, +): AsyncGenerator { const model = new ChatOpenAI( { - model: config.model, + model: config.model, apiKey: config.apiKey, configuration: { - baseURL: config.baseURL, + baseURL: config.baseURL, defaultHeaders: config.headers ?? {}, }, } ); if ( !sessions.has( id ) ) { - const messages: MessageData[] = []; + const messages: BaseMessage[] = []; if ( config.systemPrompt?.trim() ) { - messages.push( { role: "system", content: config.systemPrompt } ); + messages.push( new SystemMessage( config.systemPrompt ) ); } sessions.set( id, { id, messages } ); } - const session = sessions.get( id )!; - session.messages.push( { role: "user", content: userMessage } ); + const session = sessions.get( id )!; + const tools = config.tools ?? []; + const boundModel = tools.length > 0 ? model.bindTools( tools ) : model; - return model.stream( toBaseMessages( session.messages ) ); -} + session.messages.push( new HumanMessage( userMessage ) ); -export function updateAgentConversation( id: string, assistantMessage: string ): void -{ - const session = sessions.get( id ); - - if ( session ) + while ( true ) { - session.messages.push( { role: "assistant", content: assistantMessage } ); + const stream = await boundModel.stream( session.messages ); + let accumulated: any = null; + + for await ( const chunk of stream ) + { + const text = extractText( chunk.content ); + + if ( text ) yield { type: "text", text }; + + accumulated = accumulated ? accumulated.concat( chunk ) : chunk; + } + + const toolCalls: any[] = accumulated?.tool_calls ?? []; + + console.log( "[RojosAgent] loop end — tool_calls:", JSON.stringify( toolCalls ), + "| text length:", extractText( accumulated?.content ).length, + "| accumulated null:", accumulated === null || accumulated === undefined, + "| content type:", typeof accumulated?.content, + "| content:", JSON.stringify( accumulated?.content )?.slice( 0, 200 ) ); + + if ( toolCalls.length > 0 && toolContext && toolExecutor ) + { + session.messages.push( new AIMessage( { content: extractText( accumulated?.content ), tool_calls: toolCalls } ) ); + + for ( const tc of toolCalls ) + { + yield { type: "tool_call", name: tc.name, args: tc.args }; + + let result: unknown; + + try + { + result = await toolExecutor( tc.name, tc.args, toolContext ); + } + catch ( err ) + { + result = { error: String( err ) }; + } + + const resultStr = typeof result === "string" ? result : JSON.stringify( result ); + + yield { type: "tool_result", name: tc.name, result: resultStr }; + + session.messages.push( new ToolMessage( { content: resultStr, tool_call_id: tc.id } ) ); + } + } + else + { + const text = extractText( accumulated?.content ); + + if ( text ) session.messages.push( new AIMessage( text ) ); + + break; + } } } + +function extractText( content: unknown ): string +{ + if ( typeof content === "string" ) return content; + + if ( Array.isArray( content ) ) + { + return content + .map( c => typeof c === "string" ? c : ( ( c as any )?.text ?? "" ) ) + .join( "" ); + } + + return ""; +} diff --git a/source/server/rojos/tools/CreateDirectoryTool.ts b/source/server/rojos/tools/CreateDirectoryTool.ts new file mode 100644 index 0000000..db32581 --- /dev/null +++ b/source/server/rojos/tools/CreateDirectoryTool.ts @@ -0,0 +1,36 @@ +import fs from "fs"; +import { RojoTool, RojoToolContext } from "./RojoTool.js"; +import { resolveProjectPath } from "../../storage.js"; + +export class CreateDirectoryTool extends RojoTool +{ + readonly Definition = + { + type: "function", + function: + { + name: "create_directory", + description: "Create a directory and any missing parent directories at the given path.", + parameters: + { + type: "object", + properties: + { + path: { type: "string", description: "Path relative to the project root." }, + }, + required: [ "path" ], + }, + }, + }; + + async execute( args: Record, ctx: RojoToolContext ): Promise + { + const full = resolveProjectPath( ctx.projectId, args.path as string ); + + if ( !full ) return { error: "Invalid path" }; + + fs.mkdirSync( full, { recursive: true } ); + + return { ok: true }; + } +} diff --git a/source/server/rojos/tools/GetTextContentTool.ts b/source/server/rojos/tools/GetTextContentTool.ts new file mode 100644 index 0000000..c0cc80e --- /dev/null +++ b/source/server/rojos/tools/GetTextContentTool.ts @@ -0,0 +1,33 @@ +import { RojoTool, RojoToolContext } from "./RojoTool.js"; +import { readProjectFile } from "../../storage.js"; + +export class GetTextContentTool extends RojoTool +{ + readonly Definition = + { + type: "function", + function: + { + name: "get_text_content", + description: "Read and return the text content of a file.", + parameters: + { + type: "object", + properties: + { + path: { type: "string", description: "Path relative to the project root." }, + }, + required: [ "path" ], + }, + }, + }; + + async execute( args: Record, ctx: RojoToolContext ): Promise + { + const content = readProjectFile( ctx.projectId, args.path as string ); + + if ( null === content ) return { error: "File not found or unreadable" }; + + return content; + } +} diff --git a/source/server/rojos/tools/ListFilesTool.ts b/source/server/rojos/tools/ListFilesTool.ts new file mode 100644 index 0000000..46592b1 --- /dev/null +++ b/source/server/rojos/tools/ListFilesTool.ts @@ -0,0 +1,37 @@ +import fs from "fs"; +import { RojoTool, RojoToolContext } from "./RojoTool.js"; +import { resolveProjectPath } from "../../storage.js"; + +export class ListFilesTool extends RojoTool +{ + readonly Definition = + { + type: "function", + function: + { + name: "list_files", + description: "List the files and subdirectories inside a directory.", + parameters: + { + type: "object", + properties: + { + path: { type: "string", description: "Path relative to the project root." }, + }, + required: [ "path" ], + }, + }, + }; + + async execute( args: Record, ctx: RojoToolContext ): Promise + { + const full = resolveProjectPath( ctx.projectId, args.path as string ); + + if ( !full ) return { error: "Invalid path" }; + if ( !fs.existsSync( full ) ) return { error: "Directory does not exist" }; + + const entries = fs.readdirSync( full, { withFileTypes: true } ); + + return entries.map( e => ( { name: e.name, type: e.isDirectory() ? "directory" : "file" } ) ); + } +} diff --git a/source/server/rojos/tools/ReadPathMetaTool.ts b/source/server/rojos/tools/ReadPathMetaTool.ts new file mode 100644 index 0000000..c88108e --- /dev/null +++ b/source/server/rojos/tools/ReadPathMetaTool.ts @@ -0,0 +1,41 @@ +import fs from "fs"; +import { RojoTool, RojoToolContext } from "./RojoTool.js"; +import { resolveProjectPath } from "../../storage.js"; + +export class ReadPathMetaTool extends RojoTool +{ + readonly Definition = + { + type: "function", + function: + { + name: "read_path_meta", + description: "Get metadata for a file or directory: type (file/directory), size in bytes, and last-modified time.", + parameters: + { + type: "object", + properties: + { + path: { type: "string", description: "Path relative to the project root." }, + }, + required: [ "path" ], + }, + }, + }; + + async execute( args: Record, ctx: RojoToolContext ): Promise + { + const full = resolveProjectPath( ctx.projectId, args.path as string ); + + if ( !full ) return { error: "Invalid path" }; + if ( !fs.existsSync( full ) ) return { error: "Path does not exist" }; + + const stat = fs.statSync( full ); + + return { + type: stat.isDirectory() ? "directory" : "file", + size: stat.size, + modifiedAt: stat.mtime.toISOString(), + }; + } +} diff --git a/source/server/rojos/tools/RojoTool.ts b/source/server/rojos/tools/RojoTool.ts new file mode 100644 index 0000000..7e4e9ae --- /dev/null +++ b/source/server/rojos/tools/RojoTool.ts @@ -0,0 +1,10 @@ +export interface RojoToolContext +{ + projectId: string; +} + +export abstract class RojoTool +{ + abstract readonly Definition: object; + abstract execute( args: Record, ctx: RojoToolContext ): Promise; +} diff --git a/source/server/rojos/tools/RojoToolRegistry.ts b/source/server/rojos/tools/RojoToolRegistry.ts new file mode 100644 index 0000000..5fd69fd --- /dev/null +++ b/source/server/rojos/tools/RojoToolRegistry.ts @@ -0,0 +1,34 @@ +import { RojoTool, RojoToolContext } from "./RojoTool.js"; +import { ReadPathMetaTool } from "./ReadPathMetaTool.js"; +import { GetTextContentTool } from "./GetTextContentTool.js"; +import { WriteTextContentTool } from "./WriteTextContentTool.js"; +import { ListFilesTool } from "./ListFilesTool.js"; +import { CreateDirectoryTool } from "./CreateDirectoryTool.js"; + +export class RojoToolRegistry +{ + static readonly All: RojoTool[] = + [ + new ReadPathMetaTool(), + new GetTextContentTool(), + new WriteTextContentTool(), + new ListFilesTool(), + new CreateDirectoryTool(), + ]; + + static definitions(): object[] + { + return RojoToolRegistry.All.map( t => t.Definition ); + } + + static async execute( name: string, args: Record, ctx: RojoToolContext ): Promise + { + const tool = RojoToolRegistry.All.find( + t => ( t.Definition as any ).function?.name === name + ); + + if ( !tool ) return { error: `Unknown tool: ${ name }` }; + + return tool.execute( args, ctx ); + } +} diff --git a/source/server/rojos/tools/WriteTextContentTool.ts b/source/server/rojos/tools/WriteTextContentTool.ts new file mode 100644 index 0000000..f2bea5b --- /dev/null +++ b/source/server/rojos/tools/WriteTextContentTool.ts @@ -0,0 +1,34 @@ +import { RojoTool, RojoToolContext } from "./RojoTool.js"; +import { writeProjectFile } from "../../storage.js"; + +export class WriteTextContentTool extends RojoTool +{ + readonly Definition = + { + type: "function", + function: + { + name: "write_text_content", + description: "Write text content to a file, creating it if it does not exist or overwriting it if it does.", + parameters: + { + type: "object", + properties: + { + path: { type: "string", description: "Path relative to the project root." }, + text: { type: "string", description: "The text content to write." }, + }, + required: [ "path", "text" ], + }, + }, + }; + + async execute( args: Record, ctx: RojoToolContext ): Promise + { + const ok = writeProjectFile( ctx.projectId, args.path as string, args.text as string ); + + if ( !ok ) return { error: "Could not write file" }; + + return { ok: true }; + } +} diff --git a/source/server/routes/rojos.ts b/source/server/routes/rojos.ts index a3b5c97..eec9868 100644 --- a/source/server/routes/rojos.ts +++ b/source/server/routes/rojos.ts @@ -3,7 +3,8 @@ import fs from "fs"; import path from "path"; import crypto from "crypto"; import { requireAuth } from "../../auth-connector/source/server/auth"; -import { getAgentStream, updateAgentConversation, AgentConfig } from "../rojos/RojosAgent"; +import { getAgentStream, AgentConfig } from "../rojos/RojosAgent"; +import { RojoToolRegistry } from "../rojos/tools/RojoToolRegistry"; import { readProjectFile, writeProjectFile, createProjectDirectory } from "../storage"; import { checkAccess } from "../projectAccess"; import { RojosConfig } from "../rojos/RojosConfig"; @@ -210,6 +211,7 @@ router.post( "/chat", async ( req, res ) => model: RojosConfig.model, apiKey: RojosConfig.apiKey, systemPrompt: "", + tools: projectId ? RojoToolRegistry.definitions() : [], }; if ( projectId && rojoPath ) @@ -250,47 +252,39 @@ router.post( "/chat", async ( req, res ) => } } + const toolContext = projectId ? { projectId } : undefined; + try { - const agentStream = await getAgentStream( id, message, config ); + const agentStream = getAgentStream( + id, + message, + config, + toolContext, + toolContext ? RojoToolRegistry.execute.bind( RojoToolRegistry ) : undefined, + ); res.setHeader( "Content-Type", "text/event-stream" ); res.setHeader( "Cache-Control", "no-cache" ); res.setHeader( "Connection", "keep-alive" ); res.flushHeaders(); - const collected: string[] = []; - - for await ( const chunk of agentStream ) + for await ( const event of agentStream ) { - const content = chunk.content; - let text = ""; - - if ( typeof content === "string" ) + if ( "text" === event.type ) { - text = content; + res.write( JSON.stringify( { type: "CHAT", text: event.text } ) + "\n" ); } - else if ( Array.isArray( content ) ) + else if ( "tool_call" === event.type ) { - 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; - } + res.write( JSON.stringify( { type: "TOOL_CALL", name: event.name, args: event.args } ) + "\n" ); } - - if ( text.length > 0 ) + else if ( "tool_result" === event.type ) { - collected.push( text ); - res.write( JSON.stringify( { type: "CHAT", text } ) + "\n" ); - } - else - { - res.write( JSON.stringify( { type: "THINKING" } ) + "\n" ); + res.write( JSON.stringify( { type: "TOOL_RESULT", name: event.name, result: event.result } ) + "\n" ); } } - updateAgentConversation( id, collected.join( "" ) ); res.write( JSON.stringify( { type: "DONE" } ) + "\n" ); res.end(); } diff --git a/source/server/storage.ts b/source/server/storage.ts index ef838f6..3b027ff 100644 --- a/source/server/storage.ts +++ b/source/server/storage.ts @@ -77,6 +77,11 @@ export function createProjectDirectory(projectId: string, dirPath: string): bool return true; } +export function resolveProjectPath( projectId: string, filePath: string ): string | null +{ + return safeResolve( projectId, filePath ); +} + export function projectPathExists(projectId: string, filePath: string): boolean { const full = safeResolve(projectId, filePath); return !!full && fs.existsSync(full); diff --git a/workspace/_assets_/nav-data.js b/workspace/_assets_/nav-data.js index 64688c4..e1936a6 100644 --- a/workspace/_assets_/nav-data.js +++ b/workspace/_assets_/nav-data.js @@ -18,6 +18,7 @@ var NAV_DATA = { { title: 'rokojori-auth Restructure', path: 'outline/auth-restructure.html' }, { title: 'Auth Connector Rewrite', path: 'outline/auth-connector-rewrite.html' }, { title: 'User-Based Local Tunneling', path: 'outline/tunneling.html' }, + { title: 'Rojo Chat — LLM Tools', path: 'outline/rojo-chat-tools.html' }, ] }, { diff --git a/workspace/boards/tasks.html b/workspace/boards/tasks.html index 655ce73..da04048 100644 --- a/workspace/boards/tasks.html +++ b/workspace/boards/tasks.html @@ -23,11 +23,22 @@
To Do
- Tab-container: split function broken, panel border update unreliable + Rojo Chat: LLM Tools - The tab-container split function does not work correctly in its current state. - Additionally, the update and moving of panel borders is not always applied — - borders can appear stuck or misaligned after panel resize or split operations. + Allow users to define and use tools in rojo-chat-panel. Tools extend the LLM + with callable functions (e.g. file read, web search, custom actions). The chat + panel should support declaring a tool set, passing tool definitions to the model, + handling tool-call responses, and feeding results back into the conversation. + + + + + Rojo Chat: Claude API + + Integrate the Claude API (Anthropic) as a provider option in rojo-chat-panel. + Allows testing and using Claude models alongside the existing OpenAI-compatible + provider. Covers API key configuration, model selection, and verifying streaming + and tool-use work end-to-end with the Anthropic SDK. @@ -227,6 +238,16 @@
Done
+ + Tab-container: split function broken, panel border update unreliable + + Split rebuilt as a Split > submenu with ↔ Horizontally and ↕ Vertically entries. + Section resize observer switched from watching the workspace element to watching + each .es-sections element directly — redistribution now fires correctly when a + panel is dragged, fixing stuck or misaligned borders. + + + Mobile: nav bar z-index too low on projects / index view diff --git a/workspace/history/2026/07-July/22-07-Wednesday/html-panel-update.txt b/workspace/history/2026/07-July/22-07-Wednesday/html-panel-update.txt new file mode 100644 index 0000000..a1ed3d0 --- /dev/null +++ b/workspace/history/2026/07-July/22-07-Wednesday/html-panel-update.txt @@ -0,0 +1,56 @@ +[ HTML Editor Panel Update ] +This update gives the panel a clearer direction: + +Provide a custom subset of html for the main documentation format inside Roject. It should be readable as normal html file on computers and +can be part of a webpage directory, which can be defined in css/js/headers. This will be a later part when needed but should be +planned with. Since the js part can be critical, only special js will be used when displaying the pages in the editor, effectively stripping +away any code. + +[ Naming Changes ] +- HTML Editor Panel should be renamed to PageEditorPanel. This change should be done for all involved code. +- This editor is no longer responsible for html suffixes. +- This editor is now responsible for page suffixes. + +[ Editing Changes ] +The editor will use a template for the html document with a standardized layout: + +The will have this: + + + + + + + + + + + + + + + + + + +Whenever a .page file is opened that does not follow this format, the editor needs to reject the editing and fallback to normal text editing. + +[ Blocks and Areas ] +Blocks () are full width html element templates with a specific structural layout. +They could be something like 3 rectangles arranged in two halfs, 2 rectangles vertically aligned for images +and one rectangle on the right for text. Blocks can replaced and areas inside can be changed, but the +structure of a block is not changable. + +Areas () are containers for text, images and other inline elements. +The allow rich text editing, from asigning font style attributes (color, font-family, font-weight, italic etc) + + +[ Headers and footers ] +Headers and Footers will be there, but no features will be added yet. + + + +[ UI ] +The ui should have two icons on the left which switch between blocks and areas. +Blocks will feature a horizontal scrollable list of elements (canvas/html previews), while areas have buttons +for formating the selected text. diff --git a/workspace/history/2026/07-July/25-Friday/tab-container-updates.txt b/workspace/history/2026/07-July/25-Friday/tab-container-updates.txt new file mode 100644 index 0000000..58c184d --- /dev/null +++ b/workspace/history/2026/07-July/25-Friday/tab-container-updates.txt @@ -0,0 +1,21 @@ +[ Tab Container Update ] +The menu item "Split" will be a menu instead of an option and allows now to split horizontally (the implementated function) and to split vertically. + +So the new menu points would be + +Split > + ↔ Horizontally + ↕ Vertically + + +The vertical split was planned from the beginning but somehow never implemented properly + +[ Tab Container Closing ] +Currently there's no way to close a split tab container. That's why it needs under "Split" the option "Close Container", which will be available unless the container is the last in either the left, center or right slot - so that there's always at least one container in each +of them. + +Additionally to the menu point, another interaction should be added: Middle mouse closing. When an editor tab is clicked with middle mouse +it should close. + + + diff --git a/workspace/outline/rojo-chat-tools.html b/workspace/outline/rojo-chat-tools.html new file mode 100644 index 0000000..1e8bfee --- /dev/null +++ b/workspace/outline/rojo-chat-tools.html @@ -0,0 +1,389 @@ + + + + + + Rojo Chat — LLM Tools — Roject + + + + +
+ +
+

Plan — To Do

+

Rojo Chat — LLM Tools

+

+ Extend the Rojo Chat agent with a built-in tool set — file system read/write operations + the model can call during a conversation. Generic across any OpenAI-compatible provider + (Claude, Gemini, Mistral, local models). +

+
+ +
+

What it is

+ +
+

+ LLM tool use (also called function calling) lets a model request that the server + run a named function and return the result, then continue generating text using + that result as context. The model never executes code directly — it emits a + structured tool call, the server runs it, and the result is fed back as a message. +

+

+ For Rojo Chat this unlocks agentic workflows: a user can ask the Rojo to + "create a new page at docs/intro and add a heading and some intro text" and the + model will call create_directory, then write_text_content, + then reply with a summary — all in a single conversation turn. +

+

+ The tool format used is the OpenAI function-calling standard, which is supported + by all major providers at their OpenAI-compatible endpoints: + Claude (api.anthropic.com/v1), + Gemini (generativelanguage.googleapis.com/v1beta/openai/), + Mistral (api.mistral.ai/v1), and local models via Ollama or LM Studio. + No provider-specific branching is needed in the implementation. +

+
+
+ +
+

Built-in Tools — First Set

+ +
+

+ All tools operate on the current project's file system, scoped to + storage/<projectId>/root/. Paths are always relative to + the project root. Access checks already in the chat route ensure only authorised + users can trigger tool execution. +

+
+ +
+

read_path_meta

+

Get metadata for a path — whether it is a file or directory, size in bytes, and last-modified time.

+
{
+  "name": "read_path_meta",
+  "description": "Get metadata for a file or directory: type (file/directory), size in bytes, and last-modified time.",
+  "parameters": {
+    "type": "object",
+    "properties": {
+      "path": { "type": "string", "description": "Path relative to the project root." }
+    },
+    "required": ["path"]
+  }
+}
+

Returns: { type: "file"|"directory", size: number, modifiedAt: string }

+

Server implementation: fs.stat()

+
+ +
+

get_text_content

+

Read and return the text content of a file.

+
{
+  "name": "get_text_content",
+  "description": "Read and return the text content of a file.",
+  "parameters": {
+    "type": "object",
+    "properties": {
+      "path": { "type": "string", "description": "Path relative to the project root." }
+    },
+    "required": ["path"]
+  }
+}
+

Returns: file content as a string.

+

Server implementation: readProjectFile() — already in the storage layer.

+
+ +
+

write_text_content

+

Write text to a file, creating it if it does not exist or overwriting it if it does.

+
{
+  "name": "write_text_content",
+  "description": "Write text content to a file, creating or overwriting it.",
+  "parameters": {
+    "type": "object",
+    "properties": {
+      "path": { "type": "string", "description": "Path relative to the project root." },
+      "text": { "type": "string", "description": "The text content to write." }
+    },
+    "required": ["path", "text"]
+  }
+}
+

Returns: { ok: true } on success.

+

Server implementation: writeProjectFile() — already in the storage layer.

+
+ +
+

list_files

+

List the entries (files and subdirectories) inside a directory.

+
{
+  "name": "list_files",
+  "description": "List the files and subdirectories inside a directory.",
+  "parameters": {
+    "type": "object",
+    "properties": {
+      "path": { "type": "string", "description": "Path relative to the project root." }
+    },
+    "required": ["path"]
+  }
+}
+

Returns: { name: string, type: "file"|"directory" }[]

+

Server implementation: fs.readdir(dir, { withFileTypes: true })

+
+ +
+

create_directory

+

Create a directory (and any missing parent directories) at the given path.

+
{
+  "name": "create_directory",
+  "description": "Create a directory and any missing parent directories at the given path.",
+  "parameters": {
+    "type": "object",
+    "properties": {
+      "path": { "type": "string", "description": "Path relative to the project root." }
+    },
+    "required": ["path"]
+  }
+}
+

Returns: { ok: true } on success.

+

Server implementation: fs.mkdir(path, { recursive: true })

+
+ +
+ +
+

The Tool Execution Loop

+ +
+

+ The current RojosAgent.ts calls model.stream(messages) + once and streams text back. With tools, the agent must loop until the model + produces a final text reply: +

+
loop:
+  stream from model.bindTools(tools)
+  collect full response
+
+  if response contains tool calls:
+    for each tool call:
+      → execute tool on server
+      → emit TOOL_CALL event to client
+      → emit TOOL_RESULT event to client
+    add AIMessage (tool calls) + ToolMessages (results) to history
+    continue loop
+
+  if response is text:
+    stream text chunks to client as CHAT events
+    add AIMessage to history
+    break
+

+ getAgentStream in RojosAgent.ts becomes an + async function* generator that yields typed events instead of + raw LangChain chunks. The route handler in rojos.ts iterates + the generator and serialises each event to the wire protocol. +

+
+ +
+

Event types emitted by the generator

+
{ type: "text",        text: string }
+{ type: "tool_call",  name: string, args: Record<string, unknown> }
+{ type: "tool_result", name: string, result: string }
+

+ These map to the existing NDJSON wire protocol in rojos.ts: +

+
{ "type": "CHAT",        "text": "..." }       // unchanged
+{ "type": "TOOL_CALL",  "name": "...", "args": {...} }  // new
+{ "type": "TOOL_RESULT","name": "...", "result": "..." } // new
+{ "type": "DONE" }                              // unchanged
+
+ +
+

Tool context

+

+ Every tool execution receives a RojoToolContext carrying the + projectId and the resolved projectRoot path + (storage/<projectId>/root/). Tools use this to scope + all file system operations — no tool ever receives an absolute path from + the model. +

+
+ +
+ +
+

File Structure

+ +
+

+ Tools live in their own directory. Each tool is a self-contained class so + it is easy to review, test, and extend independently. A registry class owns + the full list and dispatches execution by name. +

+
source/server/rojos/
+  RojosAgent.ts           — modified: generator loop, tool binding
+  RojosConfig.ts          — unchanged
+  tools/
+    RojoTool.ts           — abstract base class + RojoToolContext interface
+    RojoToolRegistry.ts   — holds all tool instances, dispatches execute()
+    ReadPathMetaTool.ts
+    GetTextContentTool.ts
+    WriteTextContentTool.ts
+    ListFilesTool.ts
+    CreateDirectoryTool.ts
+
+ +
+

RojoTool base class

+
export interface RojoToolContext
+{
+  projectId:   string;
+  projectRoot: string;
+}
+
+export abstract class RojoTool
+{
+  abstract Definition: object;
+  abstract execute( args: Record<string, unknown>, ctx: RojoToolContext ): Promise<unknown>;
+}
+
+ +
+

RojoToolRegistry

+
export class RojoToolRegistry
+{
+  static readonly All: RojoTool[] = [
+    new ReadPathMetaTool(),
+    new GetTextContentTool(),
+    new WriteTextContentTool(),
+    new ListFilesTool(),
+    new CreateDirectoryTool(),
+  ];
+
+  static definitions(): object[]
+  {
+    return RojoToolRegistry.All.map( t => t.Definition );
+  }
+
+  static execute( name: string, args: Record<string, unknown>, ctx: RojoToolContext )
+  {
+    const tool = RojoToolRegistry.All.find( t => ( t.Definition as any ).function?.name === name );
+    if ( !tool ) return Promise.resolve( { error: `Unknown tool: ${ name }` } );
+    return tool.execute( args, ctx );
+  }
+}
+
+ +
+ +
+

Changes to Existing Files

+ +
+

source/server/rojos/RojosAgent.ts

+
    +
  • AgentConfig gets an optional tools: object[] field.
  • +
  • getAgentStream becomes an async function* generator + yielding typed events instead of raw LangChain chunks.
  • +
  • When config.tools is non-empty, the model is created with + model.bindTools(config.tools) and the loop handles tool call chunks.
  • +
  • updateAgentConversation receives the full collected + AIMessage (including tool calls) and the subsequent + ToolMessage array so history stays consistent.
  • +
+
+ +
+

source/server/routes/rojos.ts — POST /chat

+
    +
  • Passes RojoToolRegistry.definitions() into AgentConfig.tools + when a projectId is present (so the tool context can be resolved).
  • +
  • Passes a RojoToolContext into the generator so tools can be executed.
  • +
  • Serialises the new tool_call and tool_result event types + to the existing NDJSON wire format.
  • +
+
+ +
+

source/components/rojo-chat-panel/rojo-chat-panel.ts

+
    +
  • Handles TOOL_CALL messages: renders a small dim activity line + showing the tool name and arguments (collapsed by default).
  • +
  • Handles TOOL_RESULT messages: appends the result status (ok / error) + to the same activity line.
  • +
  • Visual style: compact, clearly distinct from user and assistant bubbles — + not the focus, just informational.
  • +
+
+ +
+ +
+

Verification Test

+ +
+

+ After implementation, the following single-message conversation should work + end-to-end with any capable model via an external or tunnel endpoint: +

+
User: "Create a directory at docs/intro and write an index.page
+       file there with a page header and a short intro paragraph."
+
+Expected sequence:
+  ⚙ create_directory("docs/intro")        → ok
+  ⚙ write_text_content("docs/intro/index.page", "...") → ok
+  Assistant: "Done. I created docs/intro and wrote index.page with ..."
+

+ This test exercises two sequential tool calls in one agent loop iteration, + confirming that history is correctly updated between calls and that the model + can compose multiple operations from a single natural-language instruction. +

+
+
+ +
+

Phased Implementation

+ +
+

Phase 1 — Backend tools + loop

+
    +
  • Create source/server/rojos/tools/ directory
  • +
  • Write RojoTool.ts (abstract base + context interface)
  • +
  • Write one class per tool: ReadPathMetaTool, GetTextContentTool, + WriteTextContentTool, ListFilesTool, CreateDirectoryTool
  • +
  • Write RojoToolRegistry.ts
  • +
  • Rewrite getAgentStream as an async generator with the tool execution loop
  • +
  • Wire tools into the POST /chat route
  • +
+
+ +
+

Phase 2 — Frontend display

+
    +
  • Handle TOOL_CALL and TOOL_RESULT message types in + rojo-chat-panel.ts
  • +
  • Render compact activity lines for each tool call / result
  • +
+
+ +
+

Phase 3 — Future extensions

+
    +
  • Per-rojo tool configuration: enable/disable specific tools per rojo
  • +
  • Additional tools: rename, delete, move files
  • +
  • User-defined tools: name + description + server-side handler registration
  • +
+
+
+ +
+ Roject — Rojo Chat LLM tools plan +
+ +
+ + + + +