From 393448f601b1fdbe6f8b039bde5886d76fec58f5 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 10 Jun 2026 23:29:36 +0000 Subject: [PATCH 1/6] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-assistant/model-in-browser/issues/15 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..4aa2095 --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-06-10T23:29:36.455Z for PR creation at branch issue-15-b719f182c4e9 for issue https://github.com/link-assistant/model-in-browser/issues/15 \ No newline at end of file From 6576c153c1f6b0e35586f45add7c8c2308be0e26 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 10 Jun 2026 23:40:26 +0000 Subject: [PATCH 2/6] feat(storage): Links Notation (.lino) serializer + conversation persistence Add a dependency-free Links Notation parser/serializer and a conversation data model that persists all chat data to localStorage as a portable .lino bundle (mirroring formal-ai's storage format), with export/import support. Includes 11 unit tests covering round-trips, escaping and forgiving imports. --- web/src/storage/conversations.ts | 192 +++++++++++++++++++++++++++++ web/src/storage/links-notation.ts | 193 ++++++++++++++++++++++++++++++ web/src/storage/storage.test.ts | 141 ++++++++++++++++++++++ 3 files changed, 526 insertions(+) create mode 100644 web/src/storage/conversations.ts create mode 100644 web/src/storage/links-notation.ts create mode 100644 web/src/storage/storage.test.ts diff --git a/web/src/storage/conversations.ts b/web/src/storage/conversations.ts new file mode 100644 index 0000000..f0977c5 --- /dev/null +++ b/web/src/storage/conversations.ts @@ -0,0 +1,192 @@ +/** + * Conversation data model and browser persistence. + * + * All chat data — every conversation and message — is stored in the browser + * (localStorage) and serialized to **Links Notation** (`.lino`, see + * `links-notation.ts`), the same portable symbolic format used by the Formal-AI + * web UI. A single `.lino` document is therefore enough to back up, export, + * import, or migrate the entire chat history. + * + * The on-disk shape is a `model_in_browser_bundle` root holding metadata plus a + * list of `conversation` nodes, each with `message` children: + * + * ``` + * model_in_browser_bundle + * version "1" + * exported_at "2026-06-10T12:00:00.000Z" + * conversation "conv_1" + * title "Getting started" + * createdAt "2026-06-10T11:59:00.000Z" + * updatedAt "2026-06-10T12:00:00.000Z" + * message "msg_1" + * role "user" + * content "Hi" + * sentAt "2026-06-10T11:59:30.000Z" + * message "msg_2" + * role "assistant" + * content "Hello!" + * sentAt "2026-06-10T12:00:00.000Z" + * ``` + */ + +import type { ChatMessage } from '../types/chat'; +import { + serializeLino, + parseLino, + branch, + leaf, + valueByKey, + type LinoNode, +} from './links-notation'; + +/** A single chat thread: an ordered list of messages plus metadata. */ +export interface Conversation { + id: string; + title: string; + createdAt: Date; + updatedAt: Date; + messages: ChatMessage[]; +} + +/** Root identifier of an exported/imported bundle document. */ +export const BUNDLE_ROOT = 'model_in_browser_bundle'; + +/** Schema version embedded in exported bundles (for future migrations). */ +export const BUNDLE_VERSION = '1'; + +/** localStorage key under which the current bundle is persisted. */ +export const STORAGE_KEY = 'mib_conversations_lino'; + +/** Derive a short conversation title from its first user message. */ +export function deriveTitle(messages: ChatMessage[]): string { + const firstUser = messages.find((m) => m.sender === 'user'); + const raw = firstUser?.content?.trim() ?? ''; + if (!raw) return 'New conversation'; + const oneLine = raw.replace(/\s+/g, ' '); + return oneLine.length > 48 ? `${oneLine.slice(0, 47)}…` : oneLine; +} + +/** Serialize a single conversation into a Links Notation node. */ +function conversationToNode(conv: Conversation): LinoNode { + const children: LinoNode[] = [ + leaf('title', conv.title), + leaf('createdAt', conv.createdAt.toISOString()), + leaf('updatedAt', conv.updatedAt.toISOString()), + ]; + for (const msg of conv.messages) { + children.push( + branch('message', msg.id, [ + leaf('role', msg.sender), + leaf('content', msg.content), + leaf('sentAt', msg.timestamp.toISOString()), + ]) + ); + } + return branch('conversation', conv.id, children); +} + +/** Parse a Links Notation `conversation` node back into a Conversation. */ +function nodeToConversation(node: LinoNode): Conversation | null { + if (node.key !== 'conversation' || !node.value) return null; + const messages: ChatMessage[] = []; + for (const child of node.children) { + if (child.key !== 'message' || !child.value) continue; + const role = valueByKey(child, 'role'); + const content = valueByKey(child, 'content') ?? ''; + const sentAt = valueByKey(child, 'sentAt'); + messages.push({ + id: child.value, + content, + sender: role === 'user' ? 'user' : 'assistant', + timestamp: sentAt ? new Date(sentAt) : new Date(), + }); + } + const createdAt = valueByKey(node, 'createdAt'); + const updatedAt = valueByKey(node, 'updatedAt'); + return { + id: node.value, + title: valueByKey(node, 'title') ?? deriveTitle(messages), + createdAt: createdAt ? new Date(createdAt) : new Date(), + updatedAt: updatedAt ? new Date(updatedAt) : new Date(), + messages, + }; +} + +/** + * Serialize a list of conversations into a `.lino` bundle document. + * + * @param conversations - conversations to serialize (most-recent-first order is + * preserved as given). + * @param exportedAt - timestamp recorded in the bundle metadata. + */ +export function serializeBundle( + conversations: Conversation[], + exportedAt: Date = new Date() +): string { + const meta: LinoNode[] = [ + leaf('version', BUNDLE_VERSION), + leaf('exported_at', exportedAt.toISOString()), + ]; + if (typeof window !== 'undefined') { + try { + meta.push(leaf('url', window.location.href)); + } catch { + /* ignore */ + } + } + const root = branch(BUNDLE_ROOT, undefined, [ + ...meta, + ...conversations.map(conversationToNode), + ]); + return serializeLino([root]); +} + +/** + * Parse a `.lino` bundle document back into conversations. + * + * Accepts both a full `model_in_browser_bundle` root and a bare list of + * `conversation` nodes (legacy / partial documents), so imports are forgiving. + * Returns an empty array for empty or unrecognized input. + */ +export function parseBundle(text: string): Conversation[] { + const roots = parseLino(text); + if (roots.length === 0) return []; + + // Collect conversation nodes from either the bundle root or the top level. + const convNodes: LinoNode[] = []; + for (const root of roots) { + if (root.key === 'conversation') { + convNodes.push(root); + } else { + for (const child of root.children) { + if (child.key === 'conversation') convNodes.push(child); + } + } + } + + return convNodes + .map(nodeToConversation) + .filter((c): c is Conversation => c !== null); +} + +/** Persist conversations to localStorage as a `.lino` bundle. */ +export function saveConversations(conversations: Conversation[]): void { + if (typeof window === 'undefined' || !window.localStorage) return; + try { + window.localStorage.setItem(STORAGE_KEY, serializeBundle(conversations)); + } catch { + /* storage may be full or unavailable — non-fatal */ + } +} + +/** Load conversations from localStorage, or an empty array if none/invalid. */ +export function loadConversations(): Conversation[] { + if (typeof window === 'undefined' || !window.localStorage) return []; + try { + const text = window.localStorage.getItem(STORAGE_KEY); + if (!text) return []; + return parseBundle(text); + } catch { + return []; + } +} diff --git a/web/src/storage/links-notation.ts b/web/src/storage/links-notation.ts new file mode 100644 index 0000000..72b8624 --- /dev/null +++ b/web/src/storage/links-notation.ts @@ -0,0 +1,193 @@ +/** + * Links Notation (`.lino`) — a tiny, indentation-based symbolic notation used to + * store and exchange all of this app's chat data in the browser. + * + * It mirrors the format used by the Formal-AI web UI + * (https://github.com/link-assistant/formal-ai), where every conversation and + * message is serialized into a portable, human-readable `.lino` document so a + * single file is enough to back up, migrate, or replay the chat state. + * + * ## Grammar + * + * A document is a tree of **nodes**, one per line. Indentation (2 spaces per + * level) expresses nesting. Each line is: + * + * ``` + * [ ""] + * ``` + * + * - `` is a bare identifier (the link "source"), e.g. `conversation`, + * `message`, `title`, `role`. + * - `""` is an optional quoted string (the link "target"). Quotes and + * backslashes inside the value are escaped as `\"` and `\\`; newlines as + * `\n`, carriage returns as `\r`, tabs as `\t`. + * - A node may have **both** a value and indented children (e.g. + * `conversation "conv_1"` followed by deeper `title`/`message` lines), or be a + * leaf with just a value (e.g. `content "Hello"`). + * + * Example: + * + * ``` + * model_in_browser_bundle + * version "1" + * conversation "conv_1" + * title "Getting started" + * message "msg_1" + * role "user" + * content "Hi" + * ``` + * + * This module is deliberately dependency-free and side-effect-free so it can be + * unit-tested in isolation and reused from both the UI and a worker. + */ + +/** A single node in a Links Notation tree. */ +export interface LinoNode { + /** The bare identifier on the left of the line (the link source). */ + key: string; + /** The optional quoted value (the link target). `undefined` when absent. */ + value?: string; + /** Indented child nodes. */ + children: LinoNode[]; +} + +const INDENT = ' '; // two spaces per nesting level + +/** Escape a raw string for use inside a quoted Links Notation value. */ +function escapeValue(raw: string): string { + return raw + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); +} + +/** Reverse {@link escapeValue}. */ +function unescapeValue(escaped: string): string { + let out = ''; + for (let i = 0; i < escaped.length; i += 1) { + const ch = escaped[i]; + if (ch === '\\' && i + 1 < escaped.length) { + const next = escaped[i + 1]; + i += 1; + switch (next) { + case 'n': + out += '\n'; + break; + case 'r': + out += '\r'; + break; + case 't': + out += '\t'; + break; + case '"': + out += '"'; + break; + case '\\': + out += '\\'; + break; + default: + out += next; + } + } else { + out += ch; + } + } + return out; +} + +/** Serialize a single node (and its subtree) at the given depth. */ +function serializeNode(node: LinoNode, depth: number): string[] { + const prefix = INDENT.repeat(depth); + const line = + node.value === undefined + ? `${prefix}${node.key}` + : `${prefix}${node.key} "${escapeValue(node.value)}"`; + const lines = [line]; + for (const child of node.children) { + lines.push(...serializeNode(child, depth + 1)); + } + return lines; +} + +/** Serialize a forest of Links Notation nodes into a `.lino` document string. */ +export function serializeLino(nodes: LinoNode[]): string { + const lines: string[] = []; + for (const node of nodes) { + lines.push(...serializeNode(node, 0)); + } + return lines.join('\n'); +} + +/** Split a raw line into its key and optional quoted value. */ +function parseLine(content: string): { key: string; value?: string } { + // Match: optionally followed by a whitespace and a "quoted value". + const match = content.match(/^(\S+)(?:\s+"((?:[^"\\]|\\.)*)")?\s*$/); + if (!match) { + // Fall back to treating the whole trimmed content as a bare key. + return { key: content.trim() }; + } + const key = match[1]; + const value = match[2] === undefined ? undefined : unescapeValue(match[2]); + return { key, value }; +} + +/** + * Parse a `.lino` document into a forest of {@link LinoNode}s. + * + * Indentation is measured in units of two leading spaces; blank lines are + * ignored. Malformed indentation is handled gracefully by attaching a node to + * the nearest shallower parent. + */ +export function parseLino(text: string): LinoNode[] { + const roots: LinoNode[] = []; + // Stack of [depth, node] for the current ancestry path. + const stack: Array<{ depth: number; node: LinoNode }> = []; + + for (const rawLine of text.split(/\r?\n/)) { + if (rawLine.trim() === '') continue; + const leading = rawLine.length - rawLine.trimStart().length; + const depth = Math.floor(leading / INDENT.length); + const { key, value } = parseLine(rawLine.trim()); + const node: LinoNode = { key, value, children: [] }; + + // Pop until the top of the stack is a strictly-shallower parent. + while (stack.length > 0 && stack[stack.length - 1].depth >= depth) { + stack.pop(); + } + + if (stack.length === 0) { + roots.push(node); + } else { + stack[stack.length - 1].node.children.push(node); + } + stack.push({ depth, node }); + } + + return roots; +} + +/** Convenience: find the first direct child with the given key. */ +export function childByKey(node: LinoNode, key: string): LinoNode | undefined { + return node.children.find((c) => c.key === key); +} + +/** Convenience: the value of the first direct child with the given key. */ +export function valueByKey(node: LinoNode, key: string): string | undefined { + return childByKey(node, key)?.value; +} + +/** Build a leaf node (`key "value"`). */ +export function leaf(key: string, value: string): LinoNode { + return { key, value, children: [] }; +} + +/** Build a branch node (`key "value"` with children). */ +export function branch( + key: string, + value: string | undefined, + children: LinoNode[] +): LinoNode { + return { key, value, children }; +} diff --git a/web/src/storage/storage.test.ts b/web/src/storage/storage.test.ts new file mode 100644 index 0000000..0f45bbb --- /dev/null +++ b/web/src/storage/storage.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from 'vitest'; +import { + serializeLino, + parseLino, + leaf, + branch, + valueByKey, + childByKey, +} from './links-notation'; +import { + serializeBundle, + parseBundle, + deriveTitle, + BUNDLE_ROOT, + type Conversation, +} from './conversations'; +import type { ChatMessage } from '../types/chat'; + +describe('links-notation', () => { + it('round-trips a simple tree', () => { + const nodes = [ + branch('root', undefined, [ + leaf('a', 'one'), + branch('b', 'id', [leaf('c', 'two')]), + ]), + ]; + const text = serializeLino(nodes); + const parsed = parseLino(text); + expect(parsed).toEqual(nodes); + }); + + it('serializes with two-space indentation', () => { + const text = serializeLino([ + branch('root', undefined, [leaf('a', 'x')]), + ]); + expect(text).toBe('root\n a "x"'); + }); + + it('escapes and unescapes special characters', () => { + const tricky = 'He said "hi"\nLine2\tTabbed \\ backslash'; + const text = serializeLino([leaf('content', tricky)]); + // The raw newline must be escaped so the value stays on one line. + expect(text).not.toContain('\n '); + expect(text.split('\n')).toHaveLength(1); + const parsed = parseLino(text); + expect(parsed[0].value).toBe(tricky); + }); + + it('parses values containing escaped quotes', () => { + const parsed = parseLino('content "a \\"quoted\\" word"'); + expect(parsed[0].value).toBe('a "quoted" word'); + }); + + it('ignores blank lines', () => { + const parsed = parseLino('root\n\n a "1"\n\n b "2"\n'); + expect(parsed[0].children).toHaveLength(2); + }); + + it('exposes helpers childByKey / valueByKey', () => { + const [root] = parseLino('root\n title "T"\n body "B"'); + expect(valueByKey(root, 'title')).toBe('T'); + expect(childByKey(root, 'body')?.value).toBe('B'); + expect(valueByKey(root, 'missing')).toBeUndefined(); + }); +}); + +function msg( + id: string, + sender: 'user' | 'assistant', + content: string, + iso: string +): ChatMessage { + return { id, sender, content, timestamp: new Date(iso) }; +} + +describe('conversations bundle', () => { + const conv: Conversation = { + id: 'conv_1', + title: 'Greeting', + createdAt: new Date('2026-06-10T11:00:00.000Z'), + updatedAt: new Date('2026-06-10T11:05:00.000Z'), + messages: [ + msg('m1', 'user', 'Hello there', '2026-06-10T11:00:30.000Z'), + msg('m2', 'assistant', 'Hi! How can I help?', '2026-06-10T11:01:00.000Z'), + ], + }; + + it('round-trips conversations through a .lino bundle', () => { + const text = serializeBundle([conv], new Date('2026-06-10T12:00:00.000Z')); + expect(text.startsWith(BUNDLE_ROOT)).toBe(true); + const parsed = parseBundle(text); + expect(parsed).toHaveLength(1); + const [out] = parsed; + expect(out.id).toBe(conv.id); + expect(out.title).toBe(conv.title); + expect(out.messages).toHaveLength(2); + expect(out.messages[0]).toMatchObject({ + id: 'm1', + sender: 'user', + content: 'Hello there', + }); + expect(out.messages[1].sender).toBe('assistant'); + expect(out.createdAt.toISOString()).toBe(conv.createdAt.toISOString()); + }); + + it('preserves multiline message content', () => { + const multi: Conversation = { + ...conv, + messages: [ + msg('m1', 'user', 'Line 1\nLine 2\n\nLine 4', '2026-06-10T11:00:30.000Z'), + ], + }; + const parsed = parseBundle(serializeBundle([multi])); + expect(parsed[0].messages[0].content).toBe('Line 1\nLine 2\n\nLine 4'); + }); + + it('parses a bare list of conversation nodes (forgiving import)', () => { + const text = serializeBundle([conv]); + // Strip the bundle root + metadata, leaving conversation nodes dedented. + const lines = text.split('\n').filter((l) => !/^\s*(version|exported_at|url) /.test(l)); + const body = lines + .filter((l) => l !== BUNDLE_ROOT) + .map((l) => l.replace(/^ {2}/, '')) + .join('\n'); + const parsed = parseBundle(body); + expect(parsed).toHaveLength(1); + expect(parsed[0].id).toBe('conv_1'); + }); + + it('returns [] for empty or unrecognized input', () => { + expect(parseBundle('')).toEqual([]); + expect(parseBundle('garbage line without structure')).toEqual([]); + }); + + it('derives a title from the first user message', () => { + expect(deriveTitle(conv.messages)).toBe('Hello there'); + expect(deriveTitle([])).toBe('New conversation'); + const long = 'x'.repeat(80); + expect(deriveTitle([msg('m', 'user', long, '2026-06-10T11:00:00.000Z')]).length).toBeLessThanOrEqual(48); + }); +}); From 02cbba0a5b3f50d1473d30434c557113ebcbbc38 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 11 Jun 2026 00:18:13 +0000 Subject: [PATCH 3/6] feat(web): chat-first three-panel layout with formal-ai UX, all engines, and Links Notation storage (issue #15) - Replace the catalogue-first single column (which pushed chat off-screen) with a three-panel shell: fixed sidebar (brand, conversations, engine picker, model catalogue, export/import) beside an always-visible chat column. - Add FormalAiProvider as the default chat engine, mirroring formal-ai UX. - Wire all six chat engines from react-chat-ui (formal-ai, chatscope, deep-chat, react-chat-elements, assistant-ui, reachat) as selectable. - Persist conversations to localStorage as a Links Notation (.lino) bundle with sidebar export/import. - Fix the reachat narrow-selector bug structurally (selector in its own column) plus defensive min-width/width CSS. --- web/package-lock.json | 86 ++++ web/package.json | 1 + web/src/App.tsx | 356 ++++++++++++--- web/src/components/ChatContainer.tsx | 8 +- .../chat-providers/DeepChatProvider.tsx | 145 +++++++ .../chat-providers/FormalAiProvider.tsx | 167 +++++++ web/src/components/chat-providers/index.ts | 2 + web/src/context/ChatProviderContext.tsx | 4 +- web/src/index.css | 408 +++++++++++++++++- web/src/types/chat.ts | 38 +- 10 files changed, 1124 insertions(+), 91 deletions(-) create mode 100644 web/src/components/chat-providers/DeepChatProvider.tsx create mode 100644 web/src/components/chat-providers/FormalAiProvider.tsx diff --git a/web/package-lock.json b/web/package-lock.json index d98dfea..b37ded0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -14,6 +14,7 @@ "@chatscope/chat-ui-kit-styles": "^1.4.0", "@huggingface/transformers": "^3.8.1", "@types/react-syntax-highlighter": "^15.5.13", + "deep-chat-react": "^2.4.2", "reachat": "^2.0.2", "react": "^18.3.1", "react-chat-elements": "^12.0.18", @@ -1934,6 +1935,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lit/react": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@lit/react/-/react-1.0.8.tgz", + "integrity": "sha512-p2+YcF+JE67SRX3mMlJ1TKCSTsgyOVdAwd/nxp3NuV1+Cb6MWALbN6nT7Ld4tpmYofcE5kcaSY1YBB9erY+6fw==", + "license": "BSD-3-Clause", + "peerDependencies": { + "@types/react": "17 || 18 || 19" + } + }, "node_modules/@marko19907/string-to-color": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@marko19907/string-to-color/-/string-to-color-1.0.1.tgz", @@ -1943,6 +1953,12 @@ "esm-seedrandom": "^3.0.5-esm.2" } }, + "node_modules/@microsoft/fetch-event-source": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", + "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==", + "license": "MIT" + }, "node_modules/@playwright/test": { "version": "1.57.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", @@ -3689,6 +3705,15 @@ "node": "^18 || >=20" } }, + "node_modules/autolinker": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/autolinker/-/autolinker-3.16.2.tgz", + "integrity": "sha512-JiYl7j2Z19F9NdTmirENSUUIIL/9MytEWtmzhfmsKPCp9E+G35Y0UNCMoM9tFigxT59qSc8Ml2dlZXOCVTYwuA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -4090,6 +4115,30 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-chat": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/deep-chat/-/deep-chat-2.4.2.tgz", + "integrity": "sha512-wQNd6Z6HDoH7OMeoQKpmxDm9XdWrRjMkBJ2yl5tsYND9Dz2UCv/BdGVM8vK2V0mhO1u+OniHYPWDGvjlNxjCeQ==", + "license": "MIT", + "dependencies": { + "@microsoft/fetch-event-source": "^2.0.1", + "remarkable": "^2.0.1", + "speech-to-element": "^1.0.4" + } + }, + "node_modules/deep-chat-react": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/deep-chat-react/-/deep-chat-react-2.4.2.tgz", + "integrity": "sha512-ST+xgauNp9pwpe2pai3/BhMGQgX/xSxfPAzdVlpkB5rXHPnyICgGvr33nA5rhtDerwwRWrozp9T2Dxr62cZh1Q==", + "license": "MIT", + "dependencies": { + "@lit/react": "^1.0.8", + "deep-chat": "2.4.2" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -7560,6 +7609,37 @@ "remark-rehype": "^11.1.0" } }, + "node_modules/remarkable": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/remarkable/-/remarkable-2.0.1.tgz", + "integrity": "sha512-YJyMcOH5lrR+kZdmB0aJJ4+93bEojRZ1HGDn9Eagu6ibg7aVZhc3OWbbShRid+Q5eAfsEqWxpe+g5W5nYNfNiA==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.10", + "autolinker": "^3.11.0" + }, + "bin": { + "remarkable": "bin/remarkable.js" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/remarkable/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/remarkable/node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -7790,6 +7870,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/speech-to-element": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/speech-to-element/-/speech-to-element-1.0.4.tgz", + "integrity": "sha512-0wDab5u0vbch7taU48N4ccqNBqHMX7EtrOeZdfwfrZ3e4Cw4OTyZZziLjCouTjWaT/oe4SmpK26/28SjNLxFLQ==", + "license": "MIT" + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", diff --git a/web/package.json b/web/package.json index 5a2eb75..2b1ffae 100644 --- a/web/package.json +++ b/web/package.json @@ -24,6 +24,7 @@ "@chatscope/chat-ui-kit-styles": "^1.4.0", "@huggingface/transformers": "^3.8.1", "@types/react-syntax-highlighter": "^15.5.13", + "deep-chat-react": "^2.4.2", "reachat": "^2.0.2", "react": "^18.3.1", "react-chat-elements": "^12.0.18", diff --git a/web/src/App.tsx b/web/src/App.tsx index fbf8d0d..d70254c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef, useEffect } from 'react'; +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { ChatProviderProvider } from './context/ChatProviderContext'; import { ChatContainer } from './components/ChatContainer'; import { ChatProviderSelector } from './components/ChatProviderSelector'; @@ -29,6 +29,14 @@ import { pickRecommended, type EvaluatedModel, } from './models/registry'; +import { + type Conversation, + deriveTitle, + serializeBundle, + parseBundle, + saveConversations, + loadConversations, +} from './storage/conversations'; // Auto-download cap: on first visit we only auto-load the recommended model // when its download stays under this size, so a large model never starts a @@ -37,6 +45,10 @@ import { // this cap. const AUTO_LOAD_MAX_BYTES = 700 * 1024 * 1024; +/** The assistant greeting that seeds every fresh conversation. */ +const GREETING = + "Hello! I'm a small language model running entirely in your browser. Pick a model in the sidebar that fits your device — the recommended one downloads automatically. You can start chatting once it's ready!"; + /** * Absolute base URL the ONNX Runtime Web wasm binaries are served from. * @@ -99,22 +111,47 @@ interface ProgressInfo { progress: number; } -// Generate unique message IDs -let messageIdCounter = 0; -function generateMessageId(): string { - return `msg-${Date.now()}-${++messageIdCounter}`; +// Generate unique ids for messages and conversations. +let idCounter = 0; +function generateId(prefix: string): string { + return `${prefix}-${Date.now()}-${++idCounter}`; +} + +/** Build a fresh conversation seeded with the assistant greeting. */ +function createConversation(): Conversation { + const now = new Date(); + return { + id: generateId('conv'), + title: 'New conversation', + createdAt: now, + updatedAt: now, + messages: [ + { + id: generateId('msg'), + content: GREETING, + sender: 'assistant', + timestamp: now, + }, + ], + }; } function App() { - const [messages, setMessages] = useState([ - { - id: generateMessageId(), - content: - "Hello! I'm a small language model running entirely in your browser. Pick a model below that fits your device — the recommended one downloads automatically. You can start chatting once it's ready!", - sender: 'assistant', - timestamp: new Date(), - }, - ]); + // All chat data lives as a list of conversations, persisted to localStorage as + // a Links Notation (.lino) bundle. On first load we restore any saved + // conversations, otherwise we start a fresh one with the greeting. + const initialConversations = useMemo(() => { + const loaded = loadConversations(); + return loaded.length > 0 ? loaded : [createConversation()]; + }, []); + const [conversations, setConversations] = + useState(initialConversations); + const [activeId, setActiveId] = useState(initialConversations[0].id); + const activeIdRef = useRef(activeId); + useEffect(() => { + activeIdRef.current = activeId; + }, [activeId]); + const [status, setStatus] = useState('idle'); const [statusText, setStatusText] = useState('Detecting device...'); const [isTyping, setIsTyping] = useState(false); @@ -139,6 +176,46 @@ function App() { const evaluatedRef = useRef([]); const catalogRef = useRef(MODEL_CATALOG); const overrideRef = useRef(readUrlOverride()); + const fileInputRef = useRef(null); + + // The active conversation and its messages drive the chat surface. + const activeConversation = + conversations.find((c) => c.id === activeId) ?? conversations[0]; + const messages = activeConversation?.messages ?? []; + + // Apply an update to the active conversation's messages, refreshing its + // derived title and timestamp. All chat mutations flow through here so the + // persisted bundle always reflects the latest state. + const updateActiveMessages = useCallback( + (updater: (msgs: ChatMessage[]) => ChatMessage[]) => { + setConversations((prev) => + prev.map((c) => { + if (c.id !== activeIdRef.current) return c; + const next = updater(c.messages); + return { + ...c, + messages: next, + updatedAt: new Date(), + title: deriveTitle(next), + }; + }) + ); + }, + [] + ); + + // Persist every change to the conversation bundle. + useEffect(() => { + saveConversations(conversations); + }, [conversations]); + + // Keep a valid active conversation selected (e.g. after a delete). + useEffect(() => { + if (conversations.length === 0) return; + if (!conversations.some((c) => c.id === activeId)) { + setActiveId(conversations[0].id); + } + }, [conversations, activeId]); // The quantization to use for an entry on this device: the per-model choice // from the evaluated catalog, or a freshly computed best dtype. @@ -272,7 +349,7 @@ function App() { case 'token': currentResponseRef.current += payload as string; - setMessages((prev) => { + updateActiveMessages((prev) => { const updated = [...prev]; const lastIdx = updated.length - 1; if ( @@ -378,13 +455,13 @@ function App() { if (!entry) return; const userMessage: ChatMessage = { - id: generateMessageId(), + id: generateId('msg'), content: text, sender: 'user', timestamp: new Date(), }; - const assistantMessageId = generateMessageId(); + const assistantMessageId = generateId('msg'); const aiPlaceholder: ChatMessage = { id: assistantMessageId, content: '', @@ -392,7 +469,7 @@ function App() { timestamp: new Date(), }; - setMessages((prev) => [...prev, userMessage, aiPlaceholder]); + updateActiveMessages((prev) => [...prev, userMessage, aiPlaceholder]); setIsTyping(true); currentResponseRef.current = ''; currentResponseIdRef.current = assistantMessageId; @@ -408,7 +485,69 @@ function App() { workerRef.current.postMessage({ type: 'generate', payload: generatePayload }); }, - [status, isTyping] + [status, isTyping, updateActiveMessages] + ); + + // ---- Conversation management ------------------------------------------------ + + const handleNewConversation = useCallback(() => { + const conv = createConversation(); + setConversations((prev) => [conv, ...prev]); + setActiveId(conv.id); + }, []); + + const handleSelectConversation = useCallback((id: string) => { + setActiveId(id); + }, []); + + const handleDeleteConversation = useCallback((id: string) => { + setConversations((prev) => { + const next = prev.filter((c) => c.id !== id); + return next.length > 0 ? next : [createConversation()]; + }); + }, []); + + // ---- Export / import (.lino) ----------------------------------------------- + + const handleExport = useCallback(() => { + const text = serializeBundle(conversations); + const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'model-in-browser-chats.lino'; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + }, [conversations]); + + const handleImportClick = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleImportFile = useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + try { + const text = await file.text(); + const imported = parseBundle(text); + if (imported.length === 0) { + setStatusText('Import failed: no conversations found in that file'); + return; + } + setConversations(imported); + setActiveId(imported[0].id); + setStatusText( + `Imported ${imported.length} conversation${imported.length === 1 ? '' : 's'}` + ); + } catch { + setStatusText('Import failed: could not read that file'); + } + }, + [] ); const getStatusIndicatorClass = () => { @@ -428,59 +567,142 @@ function App() { const loadedName = loadedEntryRef.current?.name ?? 'a model'; return ( - -
-
-

Models in Browser

-

- Small AI language models running entirely on your device — WebGPU - accelerated when available, WebAssembly otherwise -

- -
- - - -
-
- {statusText} - {status === 'error' && ( - +
    + {conversations.map((c) => ( +
  • + + +
  • + ))} +
+
+ +
+ +
+ +
+ +
+ +
+
+ + +
+ +

+ All chat data is stored in your browser as Links Notation (.lino). +

+
+ + +
+
+
+ {statusText} + {status === 'error' && ( + + )} +
+ + {progress && ( +
+
+
)} -
- {progress && ( -
-
+
- )} - -
- -
- -

- Running {loadedName} | No data sent to servers | All processing happens - locally -

+ +

+ Running {loadedName} | No data sent to servers | All processing + happens locally +

+
); diff --git a/web/src/components/ChatContainer.tsx b/web/src/components/ChatContainer.tsx index 75cce51..44e80b1 100644 --- a/web/src/components/ChatContainer.tsx +++ b/web/src/components/ChatContainer.tsx @@ -1,6 +1,8 @@ import { useChatProvider } from '../context/ChatProviderContext'; import { + FormalAiProvider, ChatscopeProvider, + DeepChatProvider, AssistantUIProvider, ReachatProvider, ReactChatElementsProvider, @@ -11,8 +13,12 @@ export function ChatContainer(props: ChatProviderProps) { const { provider } = useChatProvider(); switch (provider) { + case 'formal-ai': + return ; case 'chatscope': return ; + case 'deep-chat': + return ; case 'assistant-ui': return ; case 'reachat': @@ -20,6 +26,6 @@ export function ChatContainer(props: ChatProviderProps) { case 'react-chat-elements': return ; default: - return ; + return ; } } diff --git a/web/src/components/chat-providers/DeepChatProvider.tsx b/web/src/components/chat-providers/DeepChatProvider.tsx new file mode 100644 index 0000000..d657a32 --- /dev/null +++ b/web/src/components/chat-providers/DeepChatProvider.tsx @@ -0,0 +1,145 @@ +/** + * Deep Chat provider — wraps the `deep-chat-react` engine (one of the live chat + * engines showcased in https://github.com/link-assistant/react-chat-ui). + * + * Deep Chat owns its own message rendering and composer, so we bridge it to our + * worker-driven streaming model: + * + * - `history` seeds Deep Chat with the messages already in the active + * conversation (keyed on the first message id so switching conversations + * remounts it with fresh history). + * - A custom `connect.handler` intercepts each user submission, forwards the + * text to `onSendMessage`, and then streams the assistant's reply back into + * Deep Chat via the handler `signals` as our worker fills in the placeholder + * message. This keeps Deep Chat's bubbles in sync with the streamed tokens. + */ + +import { useCallback, useEffect, useRef, type FC } from 'react'; +import { DeepChat as DeepChatReact } from 'deep-chat-react'; +import type { ChatProviderProps } from '../../types/chat'; + +// The `deep-chat-react` wrapper only strongly types its DOM events, so we cast +// it to a permissive component to pass Deep Chat's element properties (connect, +// history, messageStyles, …) without fighting the wrapper's narrow prop type. +const DeepChat = DeepChatReact as unknown as FC>; + +interface DeepChatSignals { + onResponse: (response: { text?: string; error?: string }) => Promise | void; + onOpen?: () => void; + onClose?: () => void; +} +interface DeepChatBody { + messages?: Array<{ role?: string; text?: string }>; +} + +export function DeepChatProvider({ + messages, + isTyping, + isDisabled, + onSendMessage, +}: ChatProviderProps) { + // Refs let the (stable) handler and the streaming effect coordinate without + // re-creating Deep Chat's connect object on every render. + const signalsRef = useRef(null); + const sentLenRef = useRef(0); + const startedRef = useRef(false); + const isDisabledRef = useRef(isDisabled); + const isTypingRef = useRef(isTyping); + + useEffect(() => { + isDisabledRef.current = isDisabled; + }, [isDisabled]); + useEffect(() => { + isTypingRef.current = isTyping; + }, [isTyping]); + + // Stream the worker's tokens into Deep Chat. Each time the active assistant + // message grows we forward the delta; when generation finishes we close. + useEffect(() => { + const signals = signalsRef.current; + if (!signals) return; + if (isTyping) startedRef.current = true; + + let lastAssistant = ''; + for (let i = messages.length - 1; i >= 0; i -= 1) { + if (messages[i].sender === 'assistant') { + lastAssistant = messages[i].content; + break; + } + } + if (lastAssistant.length > sentLenRef.current) { + const delta = lastAssistant.slice(sentLenRef.current); + sentLenRef.current = lastAssistant.length; + void signals.onResponse({ text: delta }); + } + + if (startedRef.current && !isTyping) { + signals.onClose?.(); + signalsRef.current = null; + sentLenRef.current = 0; + startedRef.current = false; + } + }, [messages, isTyping]); + + const handler = useCallback( + (body: DeepChatBody, signals: DeepChatSignals) => { + const list = body?.messages ?? []; + const last = list[list.length - 1]; + const text = (typeof last?.text === 'string' ? last.text : '').trim(); + if (!text) { + signals.onClose?.(); + return; + } + if (isDisabledRef.current || isTypingRef.current) { + void signals.onResponse({ + text: 'The model is still loading — please wait a moment and try again.', + }); + signals.onClose?.(); + return; + } + signalsRef.current = signals; + sentLenRef.current = 0; + startedRef.current = false; + onSendMessage(text); + }, + [onSendMessage] + ); + + // Seed Deep Chat with the conversation so far. Remount (via key) when the + // conversation changes so its internal state is replaced, not appended to. + const history = messages.map((m) => ({ + role: m.sender === 'user' ? 'user' : 'ai', + text: m.content, + })); + const mountKey = messages[0]?.id ?? 'empty'; + + return ( +
+ +
+ ); +} diff --git a/web/src/components/chat-providers/FormalAiProvider.tsx b/web/src/components/chat-providers/FormalAiProvider.tsx new file mode 100644 index 0000000..c775c8f --- /dev/null +++ b/web/src/components/chat-providers/FormalAiProvider.tsx @@ -0,0 +1,167 @@ +/** + * Formal-AI–style chat surface — the default, built-in provider. + * + * This is a small, dependency-free chat UI that mirrors the look and feel of the + * Formal-AI web app (https://github.com/link-assistant/formal-ai): a scrolling + * message list with avatars, author + timestamp meta rows, per-message "copy" + * buttons, markdown rendering with syntax-highlighted code, a "Thinking…" pending + * indicator, and an auto-growing composer that sends on Enter (Shift+Enter inserts + * a newline). It renders purely from the external `messages` array so it works + * with the streaming worker the same way every other provider does. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { MarkdownRenderer } from '../MarkdownRenderer'; +import type { ChatProviderProps, ChatMessage } from '../../types/chat'; + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + const onCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1200); + } catch { + /* clipboard may be unavailable (e.g. insecure context) — ignore */ + } + }, [text]); + return ( + + ); +} + +function MessageBubble({ message }: { message: ChatMessage }) { + const isUser = message.sender === 'user'; + return ( +
+ +
+
+ + {isUser ? 'You' : 'Assistant'} + + + {message.content.trim().length > 0 && ( + + )} +
+ +
+
+ ); +} + +export function FormalAiProvider({ + messages, + isTyping, + isDisabled, + onSendMessage, +}: ChatProviderProps) { + const [value, setValue] = useState(''); + const textareaRef = useRef(null); + const listEndRef = useRef(null); + + // Auto-grow the textarea up to a max height as the user types. + const resize = useCallback(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = `${Math.min(el.scrollHeight, 200)}px`; + }, []); + + useEffect(() => { + resize(); + }, [value, resize]); + + // Keep the latest message in view as the conversation grows / streams. + useEffect(() => { + listEndRef.current?.scrollIntoView({ block: 'end' }); + }, [messages, isTyping]); + + const send = useCallback(() => { + const text = value.trim(); + if (!text || isDisabled || isTyping) return; + onSendMessage(text); + setValue(''); + }, [value, isDisabled, isTyping, onSendMessage]); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + send(); + } + }, + [send] + ); + + return ( +
+
+ {messages.map((msg) => ( + + ))} + {isTyping && ( +
+ + + + Thinking... +
+ )} +
+
+ +
{ + e.preventDefault(); + send(); + }} + > +