history: RojoChatPanel — streaming AI chat, LangChain backend, tab integration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rokojori 2026-07-10 20:18:15 +02:00
parent 7b0c01560c
commit 55acdd3f12
28 changed files with 1869 additions and 49 deletions

292
package-lock.json generated
View File

@ -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"
}
}
}
}

View File

@ -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",

View File

@ -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 ) =>
{

View File

@ -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<string, SessionData>();
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 } );
}
}

View File

@ -0,0 +1,5 @@
export const RojosConfig = {
baseURL: "http://0.0.0.0:8900/v1",
model: "LLAMA",
apiKey: "not-needed",
};

85
server/routes/rojos.ts Normal file
View File

@ -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;

View File

@ -20,12 +20,12 @@ export abstract class Command
getLocalizedTitle():Promise<string>
{
return LocaleManager.$().get( this._titleLocalePath );
return LocaleManager.$.get( this._titleLocalePath );
}
getLocalizedInfo():Promise<string>
{
return LocaleManager.$().get( this._infoLocalePath );
return LocaleManager.$.get( this._infoLocalePath );
}
abstract execute( context: CommandContext ): Promise<void>

View File

@ -5,12 +5,7 @@ export class AddFileCommand extends Command
{
constructor(){ super( "", "" ); }
async _doAction( context: CommandContext ): Promise<void>
{
}
async _undoAction( context: CommandContext ): Promise<void>
async execute( context: CommandContext ): Promise<void>
{
}

View File

@ -0,0 +1,175 @@
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
declare const markdownit: ( options?: Record<string, unknown> ) => { 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 = `
<div class="rcp-toolbar">
<div class="rcp-toolbar-icon">🤖</div>
<span class="rcp-toolbar-name">Rojo</span>
<button class="rcp-toolbar-btn" title="Actions"></button>
<button class="rcp-toolbar-btn" title="Settings"></button>
</div>
<div class="rcp-history"></div>
<div class="rcp-input-area">
<div class="rcp-input-text" contenteditable="true"></div>
<div class="rcp-input-buttons">
<button class="rcp-input-btn rcp-plus-btn" title="Attach">+</button>
<button class="rcp-input-btn rcp-send-btn" title="Send"></button>
</div>
</div>
`;
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<void>
{
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 );

View File

@ -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, () => {

View File

@ -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<string, { content: string; dirty: boolean }> = new Map();
activeDoc: string | null = null;
undoManager: UndoManager
readonly onDocumentOpened: EventSlot<DocumentOpenedEvent> = new EventSlot();

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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';
}

View File

@ -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' },

View File

@ -12,7 +12,10 @@
<header>
<h1>Actions</h1>
<p class="subtitle">Repeatable procedures for maintaining the project. Run these at the appropriate moment in a working session.</p>
<p class="subtitle">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.
</p>
</header>
<section>

View File

@ -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.

View File

@ -0,0 +1,203 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Session Summary — 9 July 2026</title>
<link rel="stylesheet" href="../../../../_assets_/styles.css">
<link rel="stylesheet" href="../../../../_assets_/nav.css">
</head>
<body>
<div class="page">
<header>
<p class="date">Thursday, 9 July 2026</p>
<h1>Roject — Session Summary</h1>
<p class="subtitle">
RojoChatPanel — a streaming AI chat panel for the editor, with LangChain backend,
markdown rendering, and tab container integration.
</p>
</header>
<section>
<h2>What we built</h2>
<div class="card">
<h3>RojoChatPanel — Backend</h3>
<p>
A new <code>/api/rojos/chat</code> route streams AI responses from an
OpenAI-compatible model (e.g. a local LLAMA). The backend is split into
three files under <code>server/rojos/</code>:
</p>
<ul style="margin-top:0.75rem">
<li><strong>RojosConfig.ts</strong> — hardcoded config wrapper (<code>baseURL</code>,
<code>model</code>, <code>apiKey</code>). Designed to grow as more Rojo settings
are added.</li>
<li><strong>RojosAgent.ts</strong> — in-memory session store (<code>Map</code> of
conversation ID → message history). Uses <code>ChatOpenAI.stream()</code> from
<code>@langchain/openai</code> with messages converted to
<code>HumanMessage</code> / <code>AIMessage</code> via <code>@langchain/core</code>.</li>
<li><strong>server/routes/rojos.ts</strong> — Express router, <code>POST /chat</code>,
requires auth. Iterates the agent stream with <code>for await</code>,
writes newline-delimited JSON chunks (<code>{ type:"CHAT", text }</code>,
<code>{ type:"THINKING" }</code>, <code>{ type:"DONE" }</code>) directly
via <code>res.write()</code>, then calls <code>res.end()</code>.
After streaming, calls <code>updateAgentConversation</code> so the assistant
reply is appended to the session history.</li>
</ul>
<p style="margin-top:0.75rem">
The route is mounted at <code>/api/rojos</code> in <code>server/index.ts</code>,
keeping the namespace open for future Rojo settings endpoints.
</p>
<div class="tags">
<span class="tag">server/rojos/RojosConfig.ts</span>
<span class="tag">server/rojos/RojosAgent.ts</span>
<span class="tag">server/routes/rojos.ts</span>
<span class="tag">@langchain/openai</span>
<span class="tag">@langchain/core</span>
<span class="tag">SSE / res.write</span>
</div>
</div>
<div class="card">
<h3>RojoChatPanel — Frontend Component</h3>
<p>
A new <code>rojo-chat-panel</code> custom element following the same pattern
as <code>html-editor-panel</code>. The element has three zones:
</p>
<ul style="margin-top:0.75rem">
<li><strong>Toolbar</strong> — robot emoji icon, conversation name label, two
placeholder buttons (actions ★, settings ☰).</li>
<li><strong>Chat history</strong> — scrolling <code>div</code>; user messages
are right-aligned bubble, assistant messages are left-aligned and rendered
with <code>markdown-it</code> (live as chunks arrive).</li>
<li><strong>Input area</strong><code>contenteditable</code> div (3-line height),
a placeholder <em>+</em> button, and a <em></em> send button.
Enter sends; Shift+Enter inserts a newline.</li>
</ul>
<p style="margin-top:0.75rem">
Each panel instance generates a fresh <code>crypto.randomUUID()</code>
conversation ID on connect. Raw text is extracted from the
<code>contenteditable</code> with an inlined recursive walker (same logic
as <code>DOMEditor.extractRawText</code>).
<code>markdown-it</code> is loaded as a UMD global from
<code>/vendor/markdown-it.min.js</code> (built file copied from
<code>node_modules</code>, no bundler required).
</p>
<div class="tags">
<span class="tag">src/components/rojo-chat-panel/rojo-chat-panel.ts</span>
<span class="tag">public/components/rojo-chat-panel/rojo-chat-panel.css</span>
<span class="tag">public/vendor/markdown-it.min.js</span>
<span class="tag">contenteditable</span>
<span class="tag">ReadableStream</span>
</div>
</div>
<div class="card">
<h3>Tab Container Integration</h3>
<p>
<em>Rojo Chat</em> was added to the <code>panelTypes</code> array in
<code>TabContainer.openMenu()</code>, so it appears under
<strong>Add → Rojo Chat</strong> in every tab container's <code></code> menu.
<strong>Duplicate</strong> already worked for free — it calls the stored
factory, creating a second panel with its own UUID and empty history.
</p>
<p style="margin-top:0.75rem">
<code>RojoChatPanel</code> also implements <code>addContextMenuEntries</code>
(the <code>EditorPanel</code> interface), displaying a read-only label with
the first 8 characters of the conversation ID.
</p>
<div class="tags">
<span class="tag">src/components/tab-container/tab-container.ts</span>
<span class="tag">addContextMenuEntries</span>
<span class="tag">panelTypes</span>
</div>
</div>
</section>
<section>
<h2>Key Decisions</h2>
<div class="decision">
<strong>Split LangChain packages, not the monolithic <code>langchain</code></strong>
<p>
The example code used the monolithic <code>langchain</code> package with
<code>createAgent</code>. We switched to <code>@langchain/core</code> +
<code>@langchain/openai</code> and call <code>model.stream()</code> directly.
With no tools in scope yet, this is equivalent and avoids a large dependency.
Migrating to <code>createReactAgent</code> from <code>@langchain/langgraph</code>
when tools are added will be a small, localised change in <code>RojosAgent.ts</code>.
</p>
</div>
<div class="decision">
<strong><code>RojosConfig.ts</code> as a hardcoded TypeScript wrapper</strong>
<p>
A JSON config file in <code>data/</code> 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.
</p>
</div>
<div class="decision">
<strong><code>markdown-it</code> UMD copied to <code>public/vendor/</code></strong>
<p>
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
<code>public/vendor/</code> keeps it self-hosted, available offline, and
version-locked with the rest of <code>node_modules</code>.
</p>
</div>
<div class="decision">
<strong>In-memory conversation sessions, UUID per panel instance</strong>
<p>
Persisting conversations to <code>data/</code> 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
<code>connectedCallback</code>; named/saved conversations are deferred.
</p>
</div>
<div class="decision">
<strong><code>for await</code> + <code>res.write()</code> instead of <code>Readable.pipe()</code></strong>
<p>
The initial implementation used <code>Readable.from(async function*(){})</code>
piped to the response. This was simplified to a direct <code>for await</code>
loop calling <code>res.write()</code> then <code>res.end()</code>, which is
easier to read and avoids the intermediate <code>Readable</code> wrapper.
</p>
</div>
</section>
<section>
<h2>Structural Changes</h2>
<div class="card">
<p>
<code>package.json</code> — added <code>@langchain/core</code>, <code>@langchain/openai</code>, <code>markdown-it</code><br>
<code>server/rojos/RojosConfig.ts</code> — new: model/provider config wrapper<br>
<code>server/rojos/RojosAgent.ts</code> — new: in-memory session store, LangChain stream<br>
<code>server/routes/rojos.ts</code> — new: POST /chat streaming endpoint<br>
<code>server/index.ts</code> — added <code>import rojosRouter</code>, mounted at <code>/api/rojos</code><br>
<code>src/components/rojo-chat-panel/rojo-chat-panel.ts</code> — new: custom element<br>
<code>public/components/rojo-chat-panel/rojo-chat-panel.css</code> — new: panel styles<br>
<code>public/vendor/markdown-it.min.js</code> — new: UMD build (copied from node_modules)<br>
<code>public/editor.html</code> — added rojo-chat-panel CSS, vendor script, module script<br>
<code>src/components/tab-container/tab-container.ts</code> — added Rojo Chat to panelTypes
</p>
</div>
</section>
<footer>
Roject &mdash; session log &mdash; 9 July 2026
</footer>
</div>
<script>var NAV_ROOT = '../../../../';</script>
<script src="../../../../_assets_/nav-data.js"></script>
<script src="../../../../_assets_/nav.js"></script>
</body>
</html>

View File

@ -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<string,SessionData>();
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<Uint8Array<ArrayBufferLike>>>
{
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;
}

View File

@ -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();

View File

@ -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}</${tag}>`;
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 );
}
}

View File

@ -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 );
}
)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View File

@ -0,0 +1,325 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="1080"
height="2160"
viewBox="0 0 1080 2160"
version="1.1"
id="svg5"
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
sodipodi:docname="rojo-chat-panel.svg"
xml:space="preserve"
inkscape:export-filename="rojo-chat-panel.jpg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview7"
pagecolor="#333333"
bordercolor="#404040"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#333333"
inkscape:document-units="px"
showgrid="false"
inkscape:zoom="0.41781005"
inkscape:cx="-276.44141"
inkscape:cy="1626.3371"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" /><defs
id="defs2"><rect
x="180.70413"
y="26.327753"
width="573.227"
height="98.130717"
id="rect16" /><rect
x="100.52415"
y="1801.0577"
width="879.58631"
height="260.8841"
id="rect5" /><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath7940"><rect
style="fill:#333333;fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
id="rect7942"
width="1440"
height="810"
x="0"
y="0" /></clipPath></defs><g
inkscape:label="Content"
inkscape:groupmode="layer"
id="layer1"
style="fill:#ffffff;fill-opacity:1"><rect
style="fill:#000000;stroke-linecap:round;stroke-linejoin:round"
id="rect1"
width="1080"
height="1698.0284"
x="0"
y="0"
ry="3.8862312" /><rect
style="fill:#4a4a4a;fill-opacity:1;stroke-width:1.20124;stroke-linecap:round;stroke-linejoin:round"
id="rect2"
width="1080"
height="154.36333"
x="0"
y="0"
ry="5.6077323" /><rect
style="fill:#6f2525;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
id="rect3"
width="1080"
height="461.97156"
x="0"
y="1698.0284"
ry="3.8862312" /><rect
style="fill:#2d181c;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
id="rect4"
width="971.27222"
height="336.20959"
x="56.034939"
y="1759.1575"
ry="27.743992" /><text
xml:space="preserve"
id="text5"
style="font-size:40px;line-height:1.4;font-family:'Exo 2';-inkscape-font-specification:'Exo 2, ';writing-mode:lr-tb;direction:ltr;white-space:pre;shape-inside:url(#rect5);display:inline;fill:#ffffff;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
transform="translate(9.5737285,4.7868643)"><tspan
x="100.52344"
y="1842.3586"
id="tspan1">Type message...</tspan></text><g
id="g19"><rect
style="fill:#2c2c2c;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
id="rect6"
width="828.63788"
height="246.21414"
x="191.48904"
y="249.33499"
ry="20.376312" /><text
xml:space="preserve"
style="font-size:40px;line-height:1.4;font-family:'Exo 2';-inkscape-font-specification:'Exo 2, ';writing-mode:lr-tb;direction:ltr;fill:#ffffff;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
x="226.37257"
y="331.7128"
id="text6"><tspan
sodipodi:role="line"
id="tspan6"
x="226.37257"
y="331.7128">HELLO! Can you please help me?</tspan><tspan
sodipodi:role="line"
x="226.37257"
y="387.7128"
id="tspan7">I want to know something about the UI!</tspan><tspan
sodipodi:role="line"
x="226.37257"
y="443.7128"
id="tspan8">Could you tell me what is wrong with this?</tspan></text></g><rect
style="fill:#007bea;fill-opacity:1;stroke-width:0.987022;stroke-linecap:round;stroke-linejoin:round"
id="rect8"
width="118.87884"
height="108.66821"
x="20.308945"
y="19.46274"
ry="20.698675" /><rect
style="fill:#007bea;fill-opacity:1;stroke-width:0.987022;stroke-linecap:round;stroke-linejoin:round"
id="rect9"
width="118.87884"
height="108.66821"
x="801.68555"
y="19.46274"
ry="20.698675" /><rect
style="fill:#ffffff;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
id="rect10"
width="13.584198"
height="1450.1163"
x="1047.6836"
y="190.1792"
ry="7.6411281" /><path
sodipodi:type="star"
style="fill:#ffffff;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
id="path11"
inkscape:flatsided="false"
sodipodi:sides="5"
sodipodi:cx="822.17078"
sodipodi:cy="82.247093"
sodipodi:r1="38.291626"
sodipodi:r2="18.95949"
sodipodi:arg1="-1.5707963"
sodipodi:arg2="-0.9424778"
inkscape:rounded="0"
inkscape:randomized="0"
d="m 822.17078,43.955467 11.1441,22.953077 25.2734,3.505787 -18.38596,17.691567 4.47571,25.119772 -22.50725,-12.01909 -22.50726,12.01909 4.47571,-25.119772 -18.38595,-17.691569 25.27339,-3.505785 z"
inkscape:transform-center-y="-3.6565248"
transform="translate(38.215913,-7.1802964)" /><rect
style="fill:#007bea;fill-opacity:1;stroke-width:0.987022;stroke-linecap:round;stroke-linejoin:round"
id="rect11"
width="118.87884"
height="108.66821"
x="938.77094"
y="19.46274"
ry="20.698675" /><rect
style="fill:#ffffff;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
id="rect12"
width="90.051559"
height="16.20928"
x="953.34583"
y="34.835823"
ry="8.10464" /><rect
style="fill:#ffffff;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
id="rect13"
width="90.051559"
height="16.20928"
x="953.34583"
y="77.618423"
ry="8.10464" /><rect
style="fill:#ffffff;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
id="rect14"
width="90.051559"
height="16.20928"
x="953.34583"
y="98.860138"
ry="8.10464" /><rect
style="fill:#ffffff;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
id="rect15"
width="90.051559"
height="16.20928"
x="953.34583"
y="56.975071"
ry="8.10464" /><text
xml:space="preserve"
id="text15"
style="font-size:40px;line-height:1.4;font-family:'Exo 2';-inkscape-font-specification:'Exo 2, ';writing-mode:lr-tb;direction:ltr;white-space:pre;shape-inside:url(#rect16);display:inline;fill:#ffffff;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
transform="matrix(1.2619008,0,0,1.2619008,-50.359278,5.1996215)"><tspan
x="180.70508"
y="67.628125"
id="tspan2">Conversation Name</tspan></text><rect
style="fill:#ffffff;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
id="rect17"
width="42.238461"
height="31.201273"
x="25.894936"
y="49.242828"
ry="5.6077323" /><rect
style="fill:#ffffff;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
id="rect18"
width="54.336914"
height="33.748318"
x="79.170578"
y="47.120293"
ry="5.6077323" /><circle
style="fill:#3d3d3d;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
id="path18"
cx="43.095547"
cy="68.170013"
r="10.718805" /><circle
style="fill:#3d3d3d;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
id="circle18"
cx="94.07946"
cy="68.170013"
r="10.718805" /><path
style="fill:#3d3d3d;fill-opacity:1;stroke-width:1.7312;stroke-linecap:round;stroke-linejoin:round"
d="m 33.430521,85.74693 84.146719,2.572171 -0.73491,20.209909 -38.582553,-2.20472 -2.57217,-17.270284 z"
id="path19" /><g
id="g21"
transform="translate(-157.39432,324.94312)"><rect
style="fill:#2c2c2c;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
id="rect19"
width="982.74652"
height="587.48138"
x="191.48904"
y="249.33499"
ry="20.376312" /><text
xml:space="preserve"
style="font-size:40px;line-height:1.4;font-family:'Exo 2';-inkscape-font-specification:'Exo 2, ';writing-mode:lr-tb;direction:ltr;fill:#ffffff;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
x="226.37257"
y="331.7128"
id="text21"><tspan
sodipodi:role="line"
id="tspan19"
x="226.37257"
y="331.7128">Sure!</tspan><tspan
sodipodi:role="line"
x="226.37257"
y="387.7128"
id="tspan21" /><tspan
sodipodi:role="line"
x="226.37257"
y="443.7128"
id="tspan22"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-family:'Exo 2';-inkscape-font-specification:'Exo 2 Bold'">Main Concepts</tspan><tspan
sodipodi:role="line"
x="226.37257"
y="499.7128"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Exo 2';-inkscape-font-specification:'Exo 2, '"
id="tspan23">- Projects</tspan><tspan
sodipodi:role="line"
x="226.37257"
y="555.71277"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Exo 2';-inkscape-font-specification:'Exo 2, '"
id="tspan24">- Groups</tspan><tspan
sodipodi:role="line"
x="226.37257"
y="611.71277"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Exo 2';-inkscape-font-specification:'Exo 2, '"
id="tspan25">- Rojos</tspan><tspan
sodipodi:role="line"
x="226.37257"
y="667.71277"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Exo 2';-inkscape-font-specification:'Exo 2, '"
id="tspan26">- Editor</tspan><tspan
sodipodi:role="line"
x="226.37257"
y="723.71277"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Exo 2';-inkscape-font-specification:'Exo 2, '"
id="tspan27">- Panels</tspan></text><rect
style="fill:#3d3d3d;fill-opacity:1;stroke-width:1.55888;stroke-linecap:round;stroke-linejoin:round"
id="rect27"
width="113.92631"
height="95.943802"
x="1047.1316"
y="1632.9744"
ry="47.971901" /><rect
style="fill:#3d3d3d;fill-opacity:1;stroke-width:1.55888;stroke-linecap:round;stroke-linejoin:round"
id="rect28"
width="113.92631"
height="95.943802"
x="1047.1316"
y="1482.7865"
ry="47.971901" /><path
sodipodi:type="star"
style="fill:#ffffff;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
id="path28"
inkscape:flatsided="false"
sodipodi:sides="3"
sodipodi:cx="1074.7195"
sodipodi:cy="1710.8225"
sodipodi:r1="55.290321"
sodipodi:r2="27.376123"
sodipodi:arg1="-0.72393721"
sodipodi:arg2="0.32326034"
inkscape:rounded="0"
inkscape:randomized="0"
d="m 1116.1432,1674.2015 -15.4655,45.3173 -14.9554,45.4882 -31.5131,-36.0522 -31.9163,-35.6958 46.9787,-9.2651 z"
inkscape:transform-center-x="2.5850365"
inkscape:transform-center-y="-11.66389"
transform="matrix(0.39596491,-0.43947252,0.43947252,0.39596491,-75.206204,1482.3098)" /></g><rect
style="fill:#ffffff;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
id="rect29"
width="15.909109"
height="63.936607"
x="936.53625"
y="1822.3434"
ry="7.9545546" /><rect
style="fill:#ffffff;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
id="rect30"
width="15.909109"
height="63.936607"
x="1846.3572"
y="-976.45911"
ry="7.9545546"
transform="rotate(90)" /></g></svg>

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,2 @@
[ Code Panel ]
A new panel window will

View File

@ -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

View File

@ -19,6 +19,11 @@
<section>
<h2>2026 — July</h2>
<div class="card">
<h3><a href="2026/07-July/09-Thursday/index.html">Thursday, 9 July 2026</a></h3>
<p>RojoChatPanel — streaming AI chat panel for the editor with LangChain backend (@langchain/core + @langchain/openai), markdown-it rendering, and tab container integration.</p>
</div>
<div class="card">
<h3><a href="2026/07-July/06-Monday/index.html">Monday, 6 July 2026</a></h3>
<p>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).</p>

View File

@ -134,42 +134,6 @@
</div>
</div>
<div class="card">
<h3>Locales</h3>
<p>
All user-visible strings are stored as plain files under <code>locales/en/</code>
and accessed in code via <code>LocaleManager.$.get( Locales.X.Y.member_ext )</code>.
Never hardcode a user-visible string directly in TypeScript.
<br>
<a href="locales/index.html">Read more about the locale system</a>
</p>
</div>
<div class="card">
<h3>Guides</h3>
<p>
Reference guides for working in specific areas of the project.
Read the relevant guide before starting a task in that area.
<br>
<a href="../guides/index.html">Browse all guides</a>
</p>
<ul style="margin-top:0.75rem">
<li><strong>Writing code</strong><a href="../guides/writing-typescript-code/index.html">Writing TypeScript Code</a></li>
<li><strong>Adding user-visible strings</strong><a href="../guides/locales/index.html">Locales</a></li>
</ul>
</div>
<div class="card">
<h3>Actions</h3>
<p>
Repeatable procedures for maintaining the project.
<br>
<a href="../actions/index.html">Browse all actions</a>
</p>
<ul style="margin-top:0.75rem">
<li><strong>End of session</strong><a href="../actions/update-history/index.html">Update History</a>, <a href="../actions/update-outline/index.html">Update Outline</a></li>
</ul>
</div>
</section>
<footer>