diff --git a/package-lock.json b/package-lock.json index 43463b1..9b9fe63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,12 @@ "name": "roject", "version": "1.0.0", "dependencies": { + "@langchain/core": "^1.2.2", + "@langchain/openai": "^1.5.4", "bcryptjs": "^2.4.3", "express": "^4.18.2", - "express-session": "^1.17.3" + "express-session": "^1.17.3", + "markdown-it": "^14.3.0" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", @@ -21,6 +24,12 @@ "typescript": "^5.3.3" } }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "license": "MIT" + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -62,6 +71,48 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@langchain/core": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.2.tgz", + "integrity": "sha512-KfjEOT6sCg0vvItagfEtGpmrGoLMGfma4Affb5BGEqPmS2YR3AxW54pABSkhQlzCehTB+0BnLquAe1lGF4J9zQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@cfworker/json-schema": "^4.0.2", + "@standard-schema/spec": "^1.1.0", + "js-tiktoken": "^1.0.12", + "langsmith": ">=0.5.0 <1.0.0", + "mustache": "^4.2.0", + "p-queue": "^6.6.2", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@langchain/openai": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.5.4.tgz", + "integrity": "sha512-KeWzWukSDmuR40umDHcJG9OqZ4PxRhsZdSlMS+hVKYawvu/fcB+zHs1QEu8e+5Y1SR3EN9yYB+51lo1f1j6MdA==", + "license": "MIT", + "dependencies": { + "js-tiktoken": "^1.0.12", + "openai": "^6.41.0", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@langchain/core": "^1.2.2" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -272,12 +323,38 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", @@ -456,6 +533,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -501,6 +590,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", @@ -735,6 +830,67 @@ "node": ">= 0.10" } }, + "node_modules/js-tiktoken": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", + "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1" + } + }, + "node_modules/langsmith": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.8.0.tgz", + "integrity": "sha512-edm/zZ1jMQRt0GzavmJ0YPIsqKkXetffIpmR29li8P0SoZ0MI/xKk/tIAkPnxHCybmzJesIQf5CT7eLQCqv3kA==", + "license": "MIT", + "dependencies": { + "p-queue": "6.6.2" + }, + "peerDependencies": { + "@opentelemetry/api": "*", + "@opentelemetry/exporter-trace-otlp-proto": "*", + "@opentelemetry/sdk-trace-base": "*", + "openai": "*", + "ws": ">=7" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-proto": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "openai": { + "optional": true + }, + "ws": { + "optional": true + } + } + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -742,6 +898,33 @@ "dev": true, "license": "ISC" }, + "node_modules/markdown-it": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -751,6 +934,12 @@ "node": ">= 0.4" } }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -817,6 +1006,15 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -859,6 +1057,74 @@ "node": ">= 0.8" } }, + "node_modules/openai": { + "version": "6.45.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz", + "integrity": "sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==", + "license": "Apache-2.0", + "peer": true, + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -887,6 +1153,15 @@ "node": ">= 0.10" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -1175,6 +1450,12 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/uid-safe": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", @@ -1237,6 +1518,15 @@ "engines": { "node": ">=6" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 91531ef..fc794d1 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,12 @@ "build": "tsc --build tsconfig.client.json" }, "dependencies": { + "@langchain/core": "^1.2.2", + "@langchain/openai": "^1.5.4", "bcryptjs": "^2.4.3", "express": "^4.18.2", - "express-session": "^1.17.3" + "express-session": "^1.17.3", + "markdown-it": "^14.3.0" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", diff --git a/server/index.ts b/server/index.ts index e7c9eb9..68d6f2c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -8,6 +8,7 @@ import projectsRouter from './routes/projects'; import filesRouter from './routes/files'; import localesRouter from './routes/locales'; import layoutRouter from './routes/layout'; +import rojosRouter from './routes/rojos'; import { generateLocales } from './localeGenerator'; generateLocales(); @@ -33,6 +34,7 @@ app.use( '/api/projects', projectsRouter ); app.use( '/api/files', filesRouter ); app.use( '/api/locales', localesRouter ); app.use( '/api/layout', layoutRouter ); +app.use( '/api/rojos', rojosRouter ); app.get( '/', ( req, res ) => { diff --git a/server/rojos/RojosAgent.ts b/server/rojos/RojosAgent.ts new file mode 100644 index 0000000..6bde589 --- /dev/null +++ b/server/rojos/RojosAgent.ts @@ -0,0 +1,46 @@ +import { ChatOpenAI } from "@langchain/openai"; +import { HumanMessage, AIMessage, BaseMessage } from "@langchain/core/messages"; +import { RojosConfig } from "./RojosConfig"; + +const model = new ChatOpenAI({ + model: RojosConfig.model, + apiKey: RojosConfig.apiKey, + configuration: { + baseURL: RojosConfig.baseURL, + }, +}); + +type MessageData = { role: "user" | "assistant"; content: string }; +type SessionData = { id: string; messages: MessageData[] }; + +const sessions = new Map(); + +function toBaseMessages( messages: MessageData[] ): BaseMessage[] +{ + return messages.map( m => + m.role === "user" ? new HumanMessage( m.content ) : new AIMessage( m.content ) + ); +} + +export async function getAgentStream( id: string, userMessage: string ) +{ + if ( ! sessions.has( id ) ) + { + sessions.set( id, { id, messages: [] } ); + } + + const session = sessions.get( id )!; + session.messages.push( { role: "user", content: userMessage } ); + + return model.stream( toBaseMessages( session.messages ) ); +} + +export function updateAgentConversation( id: string, assistantMessage: string ) +{ + const session = sessions.get( id ); + + if ( session ) + { + session.messages.push( { role: "assistant", content: assistantMessage } ); + } +} diff --git a/server/rojos/RojosConfig.ts b/server/rojos/RojosConfig.ts new file mode 100644 index 0000000..3e87815 --- /dev/null +++ b/server/rojos/RojosConfig.ts @@ -0,0 +1,5 @@ +export const RojosConfig = { + baseURL: "http://0.0.0.0:8900/v1", + model: "LLAMA", + apiKey: "not-needed", +}; diff --git a/server/routes/rojos.ts b/server/routes/rojos.ts new file mode 100644 index 0000000..e038356 --- /dev/null +++ b/server/routes/rojos.ts @@ -0,0 +1,85 @@ +import { Router } from "express"; +import { requireAuth } from "../middleware/auth"; +import { getAgentStream, updateAgentConversation } from "../rojos/RojosAgent"; +import { RJLog } from "../../src/library-ts/node/log/RJLog"; + +const router = Router(); +router.use( requireAuth ); + +router.post( "/chat", async ( req, res ) => +{ + const { id, message } = req.body as { id: string; message: string }; + + // RJLog.log( "Chat", { id, message } ); + + if ( !id || !message ) + { + res.status( 400 ).json( { error: "id and message are required" } ); + return; + } + + try + { + const agentStream = await getAgentStream( id, message ); + + 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 ) + { + const content = chunk.content; + let text = ""; + + // RJLog.log( "Chunk", chunk ); + + if ( typeof content === "string" ) + { + text = content; + } + else if ( Array.isArray( content ) ) + { + for ( const c of content ) + { + if ( typeof c === "string" ) + { + text += c; + } + else if ( c && typeof c === "object" && "text" in c ) + { + text += ( c as any ).text; + } + } + } + + if ( text.length > 0 ) + { + collected.push( text ); + res.write( JSON.stringify( { type: "CHAT", text } ) + "\n" ); + } + else + { + res.write( JSON.stringify( { type: "THINKING" } ) + "\n" ); + } + } + + updateAgentConversation( id, collected.join( "" ) ); + + res.write( JSON.stringify( { type: "DONE" } ) + "\n" ); + res.end(); + } + catch ( err ) + { + console.error( err ); + + if ( !res.headersSent ) + { + res.status( 500 ).json( { error: "Failed to get agent response" } ); + } + } +} ); + +export default router; diff --git a/src/commands/Command.ts b/src/commands/Command.ts index 549a09b..5793275 100644 --- a/src/commands/Command.ts +++ b/src/commands/Command.ts @@ -20,12 +20,12 @@ export abstract class Command getLocalizedTitle():Promise { - return LocaleManager.$().get( this._titleLocalePath ); + return LocaleManager.$.get( this._titleLocalePath ); } getLocalizedInfo():Promise { - return LocaleManager.$().get( this._infoLocalePath ); + return LocaleManager.$.get( this._infoLocalePath ); } abstract execute( context: CommandContext ): Promise diff --git a/src/commands/file-tree-commands/AddFile.ts b/src/commands/file-tree-commands/AddFile.ts index bbe41ed..efc5be7 100644 --- a/src/commands/file-tree-commands/AddFile.ts +++ b/src/commands/file-tree-commands/AddFile.ts @@ -5,12 +5,7 @@ export class AddFileCommand extends Command { constructor(){ super( "", "" ); } - async _doAction( context: CommandContext ): Promise - { - - } - - async _undoAction( context: CommandContext ): Promise + async execute( context: CommandContext ): Promise { } diff --git a/src/components/rojo-chat-panel/rojo-chat-panel.ts b/src/components/rojo-chat-panel/rojo-chat-panel.ts new file mode 100644 index 0000000..7fae094 --- /dev/null +++ b/src/components/rojo-chat-panel/rojo-chat-panel.ts @@ -0,0 +1,175 @@ +import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js'; + +declare const markdownit: ( options?: Record ) => { render: ( md: string ) => string }; + +function extractRawText( node: Node ): string +{ + if ( node.nodeType === Node.TEXT_NODE ) return node.nodeValue ?? ''; + if ( node.nodeName === 'BR' ) return '\n'; + if ( node.nodeType !== Node.ELEMENT_NODE ) return ''; + let text = ''; + node.childNodes.forEach( child => text += extractRawText( child ) ); + return text; +} + +class RojoChatPanel extends HTMLElement +{ + _initialized = false; + _conversationId: string = ''; + _sending = false; + _md: { render: ( s: string ) => string } | null = null; + + connectedCallback(): void + { + if ( this._initialized ) return; + this._initialized = true; + + this._conversationId = crypto.randomUUID(); + this._md = markdownit( { html: false, linkify: true, breaks: true } ); + + this.className = 'rojo-chat-panel'; + this.innerHTML = ` +
+
🤖
+ Rojo + + +
+
+
+
+
+ + +
+
+ `; + + const sendBtn = this.querySelector( '.rcp-send-btn' ) as HTMLButtonElement; + const inputText = this.querySelector( '.rcp-input-text' ) as HTMLElement; + + sendBtn.addEventListener( 'click', () => this._send() ); + + inputText.addEventListener( 'keydown', ( e: KeyboardEvent ) => + { + if ( e.key === 'Enter' && !e.shiftKey ) + { + e.preventDefault(); + this._send(); + } + } ); + } + + 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 text = extractRawText( inputText ).replaceAll( ' ', ' ' ).trim(); + if ( !text ) return; + + inputText.innerHTML = ''; + this._sending = true; + sendBtn.disabled = true; + + const userBubble = document.createElement( 'div' ); + userBubble.className = 'rcp-user-bubble'; + userBubble.textContent = text; + history.appendChild( userBubble ); + history.scrollTop = history.scrollHeight; + + const assistantBubble = document.createElement( 'div' ); + assistantBubble.className = 'rcp-assistant-bubble'; + assistantBubble.textContent = '…'; + history.appendChild( assistantBubble ); + history.scrollTop = history.scrollHeight; + + try + { + const response = await fetch( '/api/rojos/chat', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( { id: this._conversationId, message: text } ), + } + ); + + if ( !response.body ) throw new Error( 'No response body' ); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let markdown = ''; + + while ( true ) + { + const { done, value } = await reader.read(); + if ( done ) break; + + const raw = decoder.decode( value, { stream: true } ); + + console.log( "Received raw:", raw ); + + for ( const line of raw.split( '\n' ) ) + { + const trimmed = line.trim(); + if ( !trimmed ) continue; + + try + { + const msg = JSON.parse( trimmed ) as { type: string; text?: string }; + + if ( msg.type === 'CHAT' && msg.text ) + { + markdown += msg.text; + assistantBubble.innerHTML = this._md!.render( markdown ); + history.scrollTop = history.scrollHeight; + } + } + catch + { + // partial or non-JSON chunk — skip + } + } + } + + const remaining = decoder.decode(); + if ( remaining.trim() ) + { + try + { + const msg = JSON.parse( remaining.trim() ) as { type: string; text?: string }; + if ( msg.type === 'CHAT' && msg.text ) + { + markdown += msg.text; + assistantBubble.innerHTML = this._md!.render( markdown ); + } + } + catch {} + } + + if ( !markdown ) + { + assistantBubble.textContent = '(no response)'; + } + } + catch ( err ) + { + assistantBubble.textContent = '(error: could not reach Rojo)'; + console.error( err ); + } + + this._sending = false; + sendBtn.disabled = false; + history.scrollTop = history.scrollHeight; + } + + addContextMenuEntries( dir: ContextMenuDirectory ): void + { + dir.add( new ContextMenuReadOnlyEntry( dir, `Rojo Chat — ${this._conversationId.slice( 0, 8 )}` ) ); + } +} + +customElements.define( 'rojo-chat-panel', RojoChatPanel ); diff --git a/src/components/tab-container/tab-container.ts b/src/components/tab-container/tab-container.ts index 864fe3d..a0c8f9c 100644 --- a/src/components/tab-container/tab-container.ts +++ b/src/components/tab-container/tab-container.ts @@ -76,6 +76,7 @@ class TabContainer extends HTMLElement { const panelTypes = [ { label: 'HTML Editor', panelType: 'html-editor', tag: 'html-editor-panel' }, { label: 'File Tree', panelType: 'file-tree', tag: 'file-tree-panel' }, + { label: 'Rojo Chat', panelType: 'rojo-chat', tag: 'rojo-chat-panel' }, ]; for (const pt of panelTypes) { addDir.add(new ContextMenuEntry(addDir, pt.label, () => { diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 4bb74ca..eb9585a 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -1,5 +1,5 @@ import { EventSlot } from '../library-ts/browser/events/EventSlot.js'; -import UndoManager from '../library-ts/browser/undo/UndoManager.js'; + export interface DocumentOpenedEvent { @@ -30,7 +30,6 @@ export class Editor projectName: string = ''; openDocs: Map = new Map(); activeDoc: string | null = null; - undoManager: UndoManager readonly onDocumentOpened: EventSlot = new EventSlot(); diff --git a/src/locales/generated/Locales.ts b/src/locales/generated/Locales.ts new file mode 100644 index 0000000..040afd9 --- /dev/null +++ b/src/locales/generated/Locales.ts @@ -0,0 +1,9 @@ +// Auto-generated by localeGenerator — do not edit manually. + +import { Commands } from './commands/Commands.js'; + +export class Locales +{ + static readonly greeting_txt = 'greeting.txt'; + static readonly Commands = Commands; +} diff --git a/src/locales/generated/commands/Commands.ts b/src/locales/generated/commands/Commands.ts new file mode 100644 index 0000000..899628e --- /dev/null +++ b/src/locales/generated/commands/Commands.ts @@ -0,0 +1,8 @@ +// Auto-generated by localeGenerator — do not edit manually. + +import { FileTreeCommands } from './file-tree-commands/FileTreeCommands.js'; + +export class Commands +{ + static readonly FileTreeCommands = FileTreeCommands; +} diff --git a/src/locales/generated/commands/file-tree-commands/FileTreeCommands.ts b/src/locales/generated/commands/file-tree-commands/FileTreeCommands.ts new file mode 100644 index 0000000..c7c7b5d --- /dev/null +++ b/src/locales/generated/commands/file-tree-commands/FileTreeCommands.ts @@ -0,0 +1,6 @@ +// Auto-generated by localeGenerator — do not edit manually. + +export class FileTreeCommands +{ + static readonly addFile_txt = 'commands/file-tree-commands/add-file.txt'; +} diff --git a/workspace/_assets_/nav-data.js b/workspace/_assets_/nav-data.js index 3fcfbff..cd0bc97 100644 --- a/workspace/_assets_/nav-data.js +++ b/workspace/_assets_/nav-data.js @@ -26,6 +26,7 @@ var NAV_DATA = { title: 'History', path: 'history/index.html', children: [ + { title: 'Thursday, 9 July 2026', path: 'history/2026/07-July/09-Thursday/index.html' }, { title: 'Monday, 6 July 2026', path: 'history/2026/07-July/06-Monday/index.html' }, { title: 'Sunday, 5 July 2026', path: 'history/2026/07-July/05-Sunday/index.html' }, { title: 'Saturday, 4 July 2026', path: 'history/2026/07-July/04-Saturday/index.html' }, diff --git a/workspace/actions/index.html b/workspace/actions/index.html index 83c0367..91aab49 100644 --- a/workspace/actions/index.html +++ b/workspace/actions/index.html @@ -12,7 +12,10 @@

Actions

-

Repeatable procedures for maintaining the project. Run these at the appropriate moment in a working session.

+

Repeatable procedures for maintaining the project. These actions are for agents and are requested by the users + to run at the appropriate moment in a working session. + +

diff --git a/workspace/history/2026/07-July/09-Thursday/RojoChatPanel.txt b/workspace/history/2026/07-July/09-Thursday/RojoChatPanel.txt new file mode 100644 index 0000000..e2be8eb --- /dev/null +++ b/workspace/history/2026/07-July/09-Thursday/RojoChatPanel.txt @@ -0,0 +1,31 @@ +[ RojoChatPanel Feature] +This new EditorPanel is a chat session, where the user can have an conversion with an agent/assistant. + +It has a chat-history, where all the posts of the user are right-aligned, and all posts of the assistant are left-algined. +The assistant writes markdown, which is processed by "markdown-it". +When the user writes something into the chat box, the raw text of the element is extracted and put to the history. +After that the responses of the agent is fetched/streamed from the server. + + +[ RojoChatPanel UI ] +There's a mock to demonstrate the UI Element placings here: "workspace/history/2026/07-July/09-Thursday/rojo-chat-panel.jpg" +The UI has three components, a toolbar like the others which has an icon for the Rojo assistant, a name for the conversation +and two buttons on the right (actions and settings). Make them all placeholder in the first iteration. +The second one is the chat history having the alternate aligned posts from user and agent. +The third one the chat box, which has an area to input text (should be 3 lines high) and 2 buttons on the right, one with a plus +which is placeholder now, and another with an arrow up for posting. + + +[ RojoChatPanel Implementation ] + +I already have some example code in "workspace/history/2026/07-July/09-Thursday/llama-app-source". Please try to exract +what you need to built it yourself. The backend can be almost the same, just put into the server so that it works. +Maybe the route could be "rojos-chat". There will be other settings for "rojos", so keep that open. + +The frontend uses my library which you don't use in the project yet, so you can change it to fit the style of the RojoChatPanel the match the +other panels in the Roject project. + + + + + diff --git a/workspace/history/2026/07-July/09-Thursday/index.html b/workspace/history/2026/07-July/09-Thursday/index.html new file mode 100644 index 0000000..de51161 --- /dev/null +++ b/workspace/history/2026/07-July/09-Thursday/index.html @@ -0,0 +1,203 @@ + + + + + + Session Summary — 9 July 2026 + + + + +
+ +
+

Thursday, 9 July 2026

+

Roject — Session Summary

+

+ RojoChatPanel — a streaming AI chat panel for the editor, with LangChain backend, + markdown rendering, and tab container integration. +

+
+ +
+

What we built

+ +
+

RojoChatPanel — Backend

+

+ A new /api/rojos/chat route streams AI responses from an + OpenAI-compatible model (e.g. a local LLAMA). The backend is split into + three files under server/rojos/: +

+
    +
  • RojosConfig.ts — hardcoded config wrapper (baseURL, + model, apiKey). Designed to grow as more Rojo settings + are added.
  • +
  • RojosAgent.ts — in-memory session store (Map of + conversation ID → message history). Uses ChatOpenAI.stream() from + @langchain/openai with messages converted to + HumanMessage / AIMessage via @langchain/core.
  • +
  • server/routes/rojos.ts — Express router, POST /chat, + requires auth. Iterates the agent stream with for await, + writes newline-delimited JSON chunks ({ type:"CHAT", text }, + { type:"THINKING" }, { type:"DONE" }) directly + via res.write(), then calls res.end(). + After streaming, calls updateAgentConversation so the assistant + reply is appended to the session history.
  • +
+

+ The route is mounted at /api/rojos in server/index.ts, + keeping the namespace open for future Rojo settings endpoints. +

+
+ server/rojos/RojosConfig.ts + server/rojos/RojosAgent.ts + server/routes/rojos.ts + @langchain/openai + @langchain/core + SSE / res.write +
+
+ +
+

RojoChatPanel — Frontend Component

+

+ A new rojo-chat-panel custom element following the same pattern + as html-editor-panel. The element has three zones: +

+
    +
  • Toolbar — robot emoji icon, conversation name label, two + placeholder buttons (actions ★, settings ☰).
  • +
  • Chat history — scrolling div; user messages + are right-aligned bubble, assistant messages are left-aligned and rendered + with markdown-it (live as chunks arrive).
  • +
  • Input area — contenteditable div (3-line height), + a placeholder + button, and a â–² send button. + Enter sends; Shift+Enter inserts a newline.
  • +
+

+ Each panel instance generates a fresh crypto.randomUUID() + conversation ID on connect. Raw text is extracted from the + contenteditable with an inlined recursive walker (same logic + as DOMEditor.extractRawText). + markdown-it is loaded as a UMD global from + /vendor/markdown-it.min.js (built file copied from + node_modules, no bundler required). +

+
+ src/components/rojo-chat-panel/rojo-chat-panel.ts + public/components/rojo-chat-panel/rojo-chat-panel.css + public/vendor/markdown-it.min.js + contenteditable + ReadableStream +
+
+ +
+

Tab Container Integration

+

+ Rojo Chat was added to the panelTypes array in + TabContainer.openMenu(), so it appears under + Add → Rojo Chat in every tab container's ⋮ menu. + Duplicate already worked for free — it calls the stored + factory, creating a second panel with its own UUID and empty history. +

+

+ RojoChatPanel also implements addContextMenuEntries + (the EditorPanel interface), displaying a read-only label with + the first 8 characters of the conversation ID. +

+
+ src/components/tab-container/tab-container.ts + addContextMenuEntries + panelTypes +
+
+ +
+ +
+

Key Decisions

+ +
+ Split LangChain packages, not the monolithic langchain +

+ The example code used the monolithic langchain package with + createAgent. We switched to @langchain/core + + @langchain/openai and call model.stream() directly. + With no tools in scope yet, this is equivalent and avoids a large dependency. + Migrating to createReactAgent from @langchain/langgraph + when tools are added will be a small, localised change in RojosAgent.ts. +

+
+ +
+ RojosConfig.ts as a hardcoded TypeScript wrapper +

+ A JSON config file in data/ would allow runtime editing but + adds complexity (read-on-start, hot-reload). Since the model endpoint is not + expected to change frequently, a TS constant is simpler and caught by the + type checker. The file is the single place to change when adding providers. +

+
+ +
+ markdown-it UMD copied to public/vendor/ +

+ The project has no bundler and all frontend modules are bare ES modules. + A CDN script would require a network call. Copying the UMD build once into + public/vendor/ keeps it self-hosted, available offline, and + version-locked with the rest of node_modules. +

+
+ +
+ In-memory conversation sessions, UUID per panel instance +

+ Persisting conversations to data/ adds file I/O and a + naming/indexing scheme. For a first iteration, in-memory is sufficient and + keeps the surface area small. Each panel gets its own UUID on + connectedCallback; named/saved conversations are deferred. +

+
+ +
+ for await + res.write() instead of Readable.pipe() +

+ The initial implementation used Readable.from(async function*(){}) + piped to the response. This was simplified to a direct for await + loop calling res.write() then res.end(), which is + easier to read and avoids the intermediate Readable wrapper. +

+
+ +
+ +
+

Structural Changes

+
+

+ package.json — added @langchain/core, @langchain/openai, markdown-it
+ server/rojos/RojosConfig.ts — new: model/provider config wrapper
+ server/rojos/RojosAgent.ts — new: in-memory session store, LangChain stream
+ server/routes/rojos.ts — new: POST /chat streaming endpoint
+ server/index.ts — added import rojosRouter, mounted at /api/rojos
+ src/components/rojo-chat-panel/rojo-chat-panel.ts — new: custom element
+ public/components/rojo-chat-panel/rojo-chat-panel.css — new: panel styles
+ public/vendor/markdown-it.min.js — new: UMD build (copied from node_modules)
+ public/editor.html — added rojo-chat-panel CSS, vendor script, module script
+ src/components/tab-container/tab-container.ts — added Rojo Chat to panelTypes +

+
+
+ +
+ Roject — session log — 9 July 2026 +
+ +
+ + + + + diff --git a/workspace/history/2026/07-July/09-Thursday/llama-app-source/backend/Agent.ts b/workspace/history/2026/07-July/09-Thursday/llama-app-source/backend/Agent.ts new file mode 100644 index 0000000..bdca67b --- /dev/null +++ b/workspace/history/2026/07-July/09-Thursday/llama-app-source/backend/Agent.ts @@ -0,0 +1,90 @@ +import { ChatOpenAI } from "@langchain/openai"; +import { ContentBlock, createAgent } from "langchain"; +import { RJLog } from "../library-ts/node/log/RJLog"; +import { IterableReadableStream } from "@langchain/core/utils/stream"; + +const model = new ChatOpenAI( + { + model: "LLAMA", + apiKey: "not-needed", + configuration: + { + baseURL: "http://0.0.0.0:8900/v1", + }, + } +); + +const agent = createAgent({ + model, + tools: [], +}); + +type MessageData = +{ + role:"user"|"assistant"; + content:string +} + +type SessionData = +{ + id:string; + messages:MessageData[]; +} + +const sessions = new Map(); + +export async function updateAgentConversation( id:string, agentMessage: string ) +{ + let sessionData = sessions.get( id ); + + let userMessageData:MessageData = + { + role: "assistant", + content: agentMessage + }; + + sessionData.messages.push( userMessageData ); +} + +export async function getAgentStream( id:string, userMessage: string ):Promise< IterableReadableStream>> +{ + let sessionData:SessionData = null; + + if ( ! sessions.has( id ) ) + { + let sd = { id, messages:[] }; + sessions.set( id, sd ); + } + + sessionData = sessions.get( id ); + + let userMessageData:MessageData = + { + role: "user", + content: userMessage + }; + + sessionData.messages.push( userMessageData ); + + RJLog.log( "Asking:", userMessage ); + + let stream = await agent.stream( + { + messages: sessionData.messages + // messages: + // [ + // { + // role:"user", + // content:userMessage + // } + // ] + + }, + + { + streamMode: "messages" + } + ); + + return stream; +} diff --git a/workspace/history/2026/07-July/09-Thursday/llama-app-source/backend/main.ts b/workspace/history/2026/07-July/09-Thursday/llama-app-source/backend/main.ts new file mode 100644 index 0000000..24284ad --- /dev/null +++ b/workspace/history/2026/07-July/09-Thursday/llama-app-source/backend/main.ts @@ -0,0 +1,111 @@ +import fastify from 'fastify'; +import { getAgentStream, updateAgentConversation } from './Agent'; +import { RJLog } from "../library-ts/node/log/RJLog"; +import cors from "@fastify/cors"; +import { Readable } from 'stream'; +import { AIMessageChunk } from 'langchain'; +import { sleep } from "../library-ts/browser/tools/sleep"; + +async function main() +{ + const server = fastify(); + + await server.register( cors, { + origin: "http://localhost:1712" + }); + + server.post( "/chat", + async ( request, reply ) => + { + RJLog.log( "on chat:", request.body ); + + try + { + let reb = request.body as { message:string, id:string }; + let message = reb.message; + let id = reb.id; + + + RJLog.log( "Parsing Message", reb, message ); + // const response = await getAgentResponse( message ); + // reply.send( { response } ); + + let agentStream = await getAgentStream( id, message ); + reply + .header("Content-Type", "text/event-stream") + .header("Cache-Control", "no-cache") + .header("Connection", "keep-alive"); + + let agentMessages:string[] = []; + + const readable = Readable.from( + ( + async function* () + { + for await ( const chunk of agentStream ) + { + let data:[AIMessageChunk,any] = chunk as any; + RJLog.log( chunk ); + + let content = data[ 0 ].content; + + if ( content.length > 0 ) + { + let text:string = ""; + + if ( typeof content === "string" ) + { + text += content; + } + else if ( Array.isArray( content ) ) + { + content.forEach( + ( c )=> + { + text += c.text; + } + ); + } + + agentMessages.push( text ); + yield JSON.stringify( { type:"CHAT", text } ); + + } + else + { + yield JSON.stringify( { type:"THINKING" } ); + } + } + + updateAgentConversation( id, agentMessages.join( "" ) ); + + await sleep( 100 ); + + yield JSON.stringify( { type:"DONE" } ); + } + )() + ); + + return reply.send( readable ); + } + catch( err ) + { + console.error(err); + reply.status(500).send( { error: 'Failed to get agent response' } ); + } + + } + ); + + server.listen( + { port: 9100 }, + () => + { + RJLog.log( "Agent server listening at http://localhost:9100" ); + } + ); + + +} + +main(); \ No newline at end of file diff --git a/workspace/history/2026/07-July/09-Thursday/llama-app-source/frontend/DOMEditor.ts b/workspace/history/2026/07-July/09-Thursday/llama-app-source/frontend/DOMEditor.ts new file mode 100644 index 0000000..62a5ce2 --- /dev/null +++ b/workspace/history/2026/07-July/09-Thursday/llama-app-source/frontend/DOMEditor.ts @@ -0,0 +1,233 @@ +import { HTMLNodeTreeWalker } from "../graphs/HTMLNodeTreeWalker"; +import { TreeWalker } from "../graphs/TreeWalker"; + +export class DOMEditor +{ + static nodeListToArray( list:NodeList ) + { + return Array.prototype.slice.call( list ); + } + + static prefixData( source:string ) + { + let singleReplaceRegex = /\[\s*-/g; + source = source.replace(singleReplaceRegex, "[data-"); + let autoDataExtensionRegex = /\[(?!data-|([a-zA-Z0-9]+s*(\=|\~|\||\^|\$|\*|\])))/g; + return source.replace( autoDataExtensionRegex, "[data-" ); + } + + static copyAttribute( destination:Element, source:Element, sourceAttribute:string) + { + sourceAttribute = DOMEditor.prefixData( sourceAttribute ); + let value = source.getAttribute (sourceAttribute ); + destination.setAttribute( sourceAttribute, value ); + } + + static copyAllAttributesThroughRawHTML( source:Element, destination:Element ) + { + let attributes:[string,string][] = []; + + for ( let i = 0; i < destination.attributes.length; i++ ) + { + let attribute = destination.attributes[ i ]; + let attributeName = attribute.nodeName; + let attributeValue = attribute.nodeValue; + + attributes.push( [ attributeName, attributeValue ] ); + } + + for ( let i = 0; i < source.attributes.length; i++ ) + { + let attribute = source.attributes[ i ]; + let attributeName = attribute.nodeName; + let attributeValue = attribute.nodeValue; + + let index = attributes.findIndex( a => a[ 0 ] === attributeName ); + + if ( index === -1 ) + { + attributes.push( [ attributeName, attributeValue ] ); + } + else + { + attributes[ index ][ 1 ] = attributeValue; + } + } + + let innerHTML = destination.innerHTML; + let attributesString = ""; + + for ( let i = 0; i < attributes.length; i++ ) + { + if ( i !== 0 ){ attributesString += " "; } + + let attribute = attributes[ i ]; + attributesString += `${attribute[ 0 ]}="${attribute[ 1 ]}"`; + } + + let tag = destination.nodeName.toLowerCase(); + + let html = `<${tag} ${attributesString}>${innerHTML}`; + + console.log( "Copied html:", html ); + + destination.outerHTML = html; + + return destination; + + } + + static copyAllAttributes( source:Element, destination:Element ) + { + for ( let i = 0; i < source.attributes.length; i++ ) + { + let attribute = source.attributes[ i ]; + let attributeName = attribute.nodeName; + let attributeValue = attribute.nodeValue; + destination.setAttribute( attributeName, attributeValue ); + } + } + + static cloneChildren( element:Element ):Node[] + { + let clones:Node[] = []; + + for ( let i = 0; i < element.childNodes.length; i++ ) + { + + clones.push( element.childNodes[ i ].cloneNode( true ) ); + } + + + return clones; + } + + static reverseChildren( element:Element ) + { + Array.from( element.childNodes ).reverse().forEach( node => { + element.appendChild( node ); + }); + } + + static getChildrenWidth( parent:Element, callback:( e:Element )=>boolean ) + { + HTMLNodeTreeWalker.$.forAll + } + + static swapNodes( parent:Element, node1:Node, node2:Node ) + { + if ( node1 === node2 ) + { + return; + } + + let next1 = node1.nextSibling; + let next2 = node2.nextSibling; + + if ( next1 === node2 ) + { + parent.insertBefore( node2, node1 ); + } + else if ( next2 === node1 ) + { + parent.insertBefore( node1, node2 ); + } + else + { + parent.insertBefore( node1, next2 ); + parent.insertBefore( node2, next1 ); + } + } + + static _cssCreationRegex:RegExp; + + static get cssCreationRegex() + { + if ( DOMEditor._cssCreationRegex) + { + return DOMEditor._cssCreationRegex; + } + + let cssCreationRules = [ + //"Attribute", + /\[\s*(?:\w|-)+\s*(?:(?:\~|\||\^|\$|\*)?=)\s*(?:(?:"(?:\\"|.)*?")|(?:'(?:\\'|.)*?')|(?:(?:\w|-)+))\s*\]/, + //"ID", + /#(?:\w|-)+/, + //"Class", + /\.(?:\w|-)+/, + //"Empty Attribute", + /\[\s*(?:\w|-)+\s*]/, + //"Element", + /(?:\w|-)+/ + ]; + + let regexString = ""; + + for (let i = 0; i < cssCreationRules.length; i++) + { + if (i !== 0) + { + regexString += "|"; + } + + regexString += "(" + cssCreationRules[i].source + ")"; + } + + DOMEditor._cssCreationRegex = new RegExp(regexString, "g"); + + return DOMEditor._cssCreationRegex; + } + + static extractRawText( node:Node ) + { + if ( node.nodeType === Node.TEXT_NODE ) + { + return node.nodeValue; + } + + if ( node.nodeType !== Node.ELEMENT_NODE ) + { + return ""; + } + + if ( node.nodeType === Node.ELEMENT_NODE && node.nodeName.toUpperCase() == "BR" ) + { + return "\n"; + } + + let element = node as Element; + + let text = ""; + + element.childNodes.forEach( child => + { + text += DOMEditor.extractRawText( child ); + } + ); + + + return text; + } + + static stringToDocument( html:string ):Document + { + let parser = new DOMParser(); + let doc = parser.parseFromString( html, "text/html" ); + return doc; + } + + static remove( node:Node ) + { + if ( ! node ) + { + return; + } + + if ( ! node.parentNode ) + { + return; + } + + node.parentNode.removeChild( node ); + } +} \ No newline at end of file diff --git a/workspace/history/2026/07-July/09-Thursday/llama-app-source/frontend/LLAMAChat.ts b/workspace/history/2026/07-July/09-Thursday/llama-app-source/frontend/LLAMAChat.ts new file mode 100644 index 0000000..f90e566 --- /dev/null +++ b/workspace/history/2026/07-July/09-Thursday/llama-app-source/frontend/LLAMAChat.ts @@ -0,0 +1,223 @@ +import { nextFrame } from "../library-ts/browser/animation/nextFrame"; +import { DOMEditor } from "../library-ts/browser/dom/DOMEditor"; +import { ElementAttribute } from "../library-ts/browser/dom/ElementAttribute"; +import { ElementType } from "../library-ts/browser/dom/ElementType"; +import { OnClick } from "../library-ts/browser/dom/EventListeners"; +import MarkdownIt from "markdown-it"; + +export type StreamMessage = { type:string, content?:string}; + +export class LLAMAChat +{ + static readonly type = new ElementType( "llama-chat" ); + static readonly inputBox = new ElementType( "input-box" ); + static readonly inputText = new ElementType( "input-text" ); + + static readonly chatBlock = new ElementType( "chat-block" ); + static readonly userBlock = new ElementType( "user-block" ); + static readonly llmBlock = new ElementType( "llm-block" ); + + static conversionID:string = null; + + static applyOnDocument() + { + this.type.forEachInDoc( + ( e )=> + { + LLAMAChat.applyOnElement( e ); + } + ) + } + + + static setConversionID() + { + const HASH_PREFIX = "#conversation-id="; + + + const match = window.location.hash.match( + /^#conversation-id=([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i + ); + + if ( match ) + { + LLAMAChat.conversionID = match[1]; + } + else + { + LLAMAChat.conversionID = crypto.randomUUID(); + window.location.hash = `${HASH_PREFIX}${LLAMAChat.conversionID}`; + } + + window.location.hash = `${HASH_PREFIX}${LLAMAChat.conversionID}`; + + console.log( LLAMAChat.conversionID ); + } + + static applyOnElement( element:Element ) + { + LLAMAChat.setConversionID(); + + console.log( "LLMCHAT", element ); + let chatBlock = LLAMAChat.chatBlock.query( element ); + + let button = ElementType.button.query( LLAMAChat.inputBox.query( element ) ); + let inputText = LLAMAChat.inputText.query( element ); + + const md = new MarkdownIt({ + html: false, + linkify: true, + breaks: true + }); + + OnClick.add( button, + async ( ev )=> + { + let textValue = DOMEditor.extractRawText( inputText ); + textValue = textValue.replaceAll( "\u00a0", " "); + let innerHTML = inputText.innerHTML; + inputText.innerHTML = ""; + + let userBlock = LLAMAChat.userBlock.create(); + userBlock.innerHTML = innerHTML; + + chatBlock.appendChild( userBlock ); + + ElementAttribute.contentEditable.removeFrom( inputText ); + + let postURL = "http://localhost:9100/chat"; + + let message = JSON.stringify( { message: textValue, id:LLAMAChat.conversionID } ); + + let postResponse = await fetch( postURL, + { + headers: { "Content-Type": "application/json" }, + method: "post", body:message + } + ); + + if ( ! postResponse.body ) + { + throw new Error("No response body"); + } + + let llmBlock = LLAMAChat.llmBlock.create( "" ); + + chatBlock.appendChild( llmBlock ); + + + + + const reader = postResponse.body.getReader(); + const decoder = new TextDecoder(); + + let markdown = ""; + let maxThinking = 10; + let thinkingStart = new Date().getTime(); + let thinkingFPS = 2; + + let processUpdate = ( m: StreamMessage ) => + { + if ( m == null ) + { + return; + } + + if ( m.type === "THINKING" ) + { + let now = new Date().getTime(); + let elapsed = ( now - thinkingStart ) / 1000; + let thinkingCounter = ( elapsed / thinkingFPS ) % maxThinking; + + + let thinking = ""; + + for ( let i = 0; i < thinkingCounter; i++ ) + { + thinking += "."; + } + + llmBlock.innerHTML = thinking; + // llmBlock.appendChild( document.createTextNode( markdown ) ); + } + else + { + if ( m.type === "CHAT" ) + { + markdown += m.content; + + console.log( m.content ); + } + + llmBlock.innerHTML = ""; + llmBlock.innerHTML = md.render( markdown ); + + let codeElements = ElementType.code.queryAll( llmBlock ); + codeElements.forEach( + ( ce )=> + { + + } + ); + } + } + + while ( true ) + { + const { done, value } = await reader.read(); + + if ( done ) + { + break; + } + + let updateValue = decoder.decode( value, { stream: true } ); + + let updateMessage:StreamMessage = null; + + try + { + updateMessage = JSON.parse( updateValue ); + } + catch( e ) + { + console.log( "Not json:", updateValue ); + + let endingMessage:StreamMessage = { type: "DONE" }; + let endingMessageString = JSON.stringify( endingMessage ); + + if ( updateValue.endsWith( endingMessageString ) ) + { + updateMessage = endingMessage; + } + } + + await nextFrame(); + + processUpdate( updateMessage ); + + + } + + try + { + let lastUpdateMessage:StreamMessage = JSON.parse( decoder.decode() ); + + await nextFrame(); + + markdown += decoder.decode(); // flush any buffered bytes + + processUpdate( lastUpdateMessage ); + + console.log( markdown ); + } + catch( e ) + { + + } + + ElementAttribute.contentEditable.to( inputText ); + } + ) + } +} \ No newline at end of file diff --git a/workspace/history/2026/07-July/09-Thursday/rojo-chat-panel.jpg b/workspace/history/2026/07-July/09-Thursday/rojo-chat-panel.jpg new file mode 100644 index 0000000..8519cd8 Binary files /dev/null and b/workspace/history/2026/07-July/09-Thursday/rojo-chat-panel.jpg differ diff --git a/workspace/history/2026/07-July/09-Thursday/rojo-chat-panel.svg b/workspace/history/2026/07-July/09-Thursday/rojo-chat-panel.svg new file mode 100644 index 0000000..0c9277d --- /dev/null +++ b/workspace/history/2026/07-July/09-Thursday/rojo-chat-panel.svg @@ -0,0 +1,325 @@ + + + +Type message...HELLO! Can you please help me?I want to know something about the UI!Could you tell me what is wrong with this?Conversation NameSure!Main Concepts- Projects- Groups- Rojos- Editor- Panels diff --git a/workspace/history/2026/07-July/10-Friday/CodePanel.txt b/workspace/history/2026/07-July/10-Friday/CodePanel.txt new file mode 100644 index 0000000..7d734da --- /dev/null +++ b/workspace/history/2026/07-July/10-Friday/CodePanel.txt @@ -0,0 +1,2 @@ +[ Code Panel ] +A new panel window will \ No newline at end of file diff --git a/workspace/history/2026/07-July/10-Friday/Panel Writing Guide.txt b/workspace/history/2026/07-July/10-Friday/Panel Writing Guide.txt new file mode 100644 index 0000000..328351d --- /dev/null +++ b/workspace/history/2026/07-July/10-Friday/Panel Writing Guide.txt @@ -0,0 +1,5 @@ +[ Editor Panel Writing Guide ] +- Every panel should be added to TabContainer.openMenu ( in "src/components/tab-container/tab-container.ts" ) +- It should also be possible to duplicate the panel. +- Panels need to have a toolbar +- Panels should work on desktop, mobile and tablets for landscape/potrait diff --git a/workspace/history/index.html b/workspace/history/index.html index 7358173..5050a34 100644 --- a/workspace/history/index.html +++ b/workspace/history/index.html @@ -19,6 +19,11 @@

2026 — July

+
+

Thursday, 9 July 2026

+

RojoChatPanel — streaming AI chat panel for the editor with LangChain backend (@langchain/core + @langchain/openai), markdown-it rendering, and tab container integration.

+
+

Monday, 6 July 2026

File tree rename/delete (right-click context menu), locale generator rewrite (per-directory output, new naming conventions), locale documentation, doc/ renamed to workspace/, fixed navigation header system, Guides/Actions workspace restructure, Update History action rewrite, Rojo chibi bone animator prototype (SVG pivot system, no graphics library).

diff --git a/workspace/outline/index.html b/workspace/outline/index.html index 4ccfa6f..5eae564 100644 --- a/workspace/outline/index.html +++ b/workspace/outline/index.html @@ -134,42 +134,6 @@
-
-

Locales

-

- All user-visible strings are stored as plain files under locales/en/ - and accessed in code via LocaleManager.$.get( Locales.X.Y.member_ext ). - Never hardcode a user-visible string directly in TypeScript. -
- Read more about the locale system -

-
- -
-

Guides

-

- Reference guides for working in specific areas of the project. - Read the relevant guide before starting a task in that area. -
- Browse all guides -

- -
- -
-

Actions

-

- Repeatable procedures for maintaining the project. -
- Browse all actions -

- -