@rubytech/create-maxy-lite 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -0
- package/index.mjs +150 -0
- package/lib/healthcheck.mjs +41 -0
- package/lib/log.mjs +16 -0
- package/lib/orchestrate.mjs +148 -0
- package/lib/paths.mjs +50 -0
- package/lib/pins.mjs +22 -0
- package/package.json +33 -0
- package/payload/package.json +8 -0
- package/payload/schema/SCHEMA.md +404 -0
- package/payload/validator/cli.mjs +56 -0
- package/payload/validator/frontmatter.mjs +37 -0
- package/payload/validator/schema.mjs +147 -0
- package/payload/validator/validate.mjs +166 -0
- package/payload/webchat/README.md +55 -0
- package/payload/webchat/package-lock.json +56 -0
- package/payload/webchat/package.json +17 -0
- package/payload/webchat/parse-transcript.mjs +128 -0
- package/payload/webchat/public/app.js +106 -0
- package/payload/webchat/public/index.html +32 -0
- package/payload/webchat/public/style.css +161 -0
- package/payload/webchat/server.mjs +229 -0
- package/versions.json +5 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// The validation engine. Two passes over a vault, no LLM in the decision path:
|
|
2
|
+
// 1. parse every markdown file's frontmatter and index basename -> entity type(s)
|
|
3
|
+
// 2. check each file's fields and associations against the schema
|
|
4
|
+
//
|
|
5
|
+
// Every violation is named `<field>:<rule>` (or `type:unknown` / `type:missing`)
|
|
6
|
+
// so a failure is diagnosable from the report alone.
|
|
7
|
+
|
|
8
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { join, basename } from 'node:path';
|
|
10
|
+
import { parseFrontmatter } from './frontmatter.mjs';
|
|
11
|
+
|
|
12
|
+
// A `date` is a real calendar date: `YYYY-MM-DD`, optionally with a time. js-yaml
|
|
13
|
+
// coerces a bare ISO date to a Date object; a quoted date stays a string, so we
|
|
14
|
+
// validate the string form by components (shape alone would pass 2026-13-40).
|
|
15
|
+
function isValidDateString(value) {
|
|
16
|
+
const m = value.match(/^(\d{4})-(\d{2})-(\d{2})([T ].*)?$/);
|
|
17
|
+
if (!m) return false;
|
|
18
|
+
const y = Number(m[1]);
|
|
19
|
+
const mo = Number(m[2]);
|
|
20
|
+
const d = Number(m[3]);
|
|
21
|
+
if (mo < 1 || mo > 12 || d < 1 || d > 31) return false;
|
|
22
|
+
const dt = new Date(Date.UTC(y, mo - 1, d));
|
|
23
|
+
return dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === d;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** List every `.md` file under a vault, as paths relative to the vault root. */
|
|
27
|
+
export function collectMarkdownFiles(vaultPath) {
|
|
28
|
+
return readdirSync(vaultPath, { recursive: true })
|
|
29
|
+
.map((p) => String(p))
|
|
30
|
+
.filter((p) => p.toLowerCase().endsWith('.md'))
|
|
31
|
+
.sort();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Reduce a link value to the bare basename it points at, or null if it is not a string. */
|
|
35
|
+
export function normalizeLink(value) {
|
|
36
|
+
if (typeof value !== 'string') return null;
|
|
37
|
+
let s = value.trim();
|
|
38
|
+
const wiki = s.match(/^\[\[(.+?)\]\]$/);
|
|
39
|
+
if (wiki) s = wiki[1].split('|')[0].trim(); // [[Name|alias]] -> Name (alias only meaningful in brackets)
|
|
40
|
+
s = s.replace(/\.md$/i, ''); // Name.md -> Name
|
|
41
|
+
s = s.split('/').pop(); // folder/Name -> Name
|
|
42
|
+
return s || null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function matchesScalar(type, value) {
|
|
46
|
+
switch (type) {
|
|
47
|
+
case 'text': return typeof value === 'string';
|
|
48
|
+
case 'number': return typeof value === 'number' && Number.isFinite(value);
|
|
49
|
+
case 'checkbox': return typeof value === 'boolean';
|
|
50
|
+
case 'date': return value instanceof Date || (typeof value === 'string' && isValidDateString(value));
|
|
51
|
+
default: return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Validate one file's parsed frontmatter against the schema.
|
|
57
|
+
*
|
|
58
|
+
* @returns {{ entity: string, errors: string[] }}
|
|
59
|
+
*/
|
|
60
|
+
export function validateFile(frontmatter, schema, index) {
|
|
61
|
+
const type = frontmatter.type;
|
|
62
|
+
if (type === undefined || type === null || type === '') {
|
|
63
|
+
return { entity: 'none', errors: ['type:missing'] };
|
|
64
|
+
}
|
|
65
|
+
const entity = schema.entities.get(type);
|
|
66
|
+
if (!entity) {
|
|
67
|
+
return { entity: String(type), errors: ['type:unknown'] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const errors = [];
|
|
71
|
+
|
|
72
|
+
for (const [field, spec] of entity.fields) {
|
|
73
|
+
const raw = frontmatter[field];
|
|
74
|
+
// Present means a real value. `false` and `0` count; null, undefined, and an
|
|
75
|
+
// empty/whitespace-only string do not (a required field left blank is missing).
|
|
76
|
+
const present = Object.prototype.hasOwnProperty.call(frontmatter, field)
|
|
77
|
+
&& raw !== undefined && raw !== null
|
|
78
|
+
&& !(typeof raw === 'string' && raw.trim() === '');
|
|
79
|
+
if (!present) {
|
|
80
|
+
if (spec.required) errors.push(`${field}:missing`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const value = frontmatter[field];
|
|
84
|
+
const assoc = schema.associations.get(field);
|
|
85
|
+
|
|
86
|
+
if (spec.type === 'list') {
|
|
87
|
+
if (!Array.isArray(value)) { errors.push(`${field}:type`); continue; }
|
|
88
|
+
if (assoc) checkLinks(field, value, assoc, index, errors);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (spec.type === 'link') {
|
|
92
|
+
checkLinks(field, [value], assoc, index, errors);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (spec.type === 'area') {
|
|
96
|
+
if (typeof value !== 'string') errors.push(`${field}:type`);
|
|
97
|
+
else if (!schema.areas.has(value)) errors.push(`${field}:area`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (!matchesScalar(spec.type, value)) errors.push(`${field}:type`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { entity: type, errors };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Resolve each link item and append dangling/target/type violations for `field`. */
|
|
107
|
+
function checkLinks(field, items, assoc, index, errors) {
|
|
108
|
+
let dangling = false;
|
|
109
|
+
let wrongTarget = false;
|
|
110
|
+
let malformed = false;
|
|
111
|
+
for (const item of items) {
|
|
112
|
+
const name = normalizeLink(item);
|
|
113
|
+
if (name === null) { malformed = true; continue; }
|
|
114
|
+
const targetTypes = index.get(name);
|
|
115
|
+
if (!targetTypes) { dangling = true; continue; }
|
|
116
|
+
if (assoc && assoc.targets !== 'any') {
|
|
117
|
+
const ok = [...targetTypes].some((t) => assoc.targets.includes(t));
|
|
118
|
+
if (!ok) wrongTarget = true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (malformed) errors.push(`${field}:type`);
|
|
122
|
+
if (dangling) errors.push(`${field}:dangling`);
|
|
123
|
+
if (wrongTarget) errors.push(`${field}:target`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Validate a whole vault.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} vaultPath
|
|
130
|
+
* @param {ReturnType<import('./schema.mjs').loadSchema>} schema
|
|
131
|
+
* @returns {{ results: Array<{file:string,entity:string,ok:boolean,errors:string[]}>,
|
|
132
|
+
* summary: {files:number,valid:number,invalid:number} }}
|
|
133
|
+
*/
|
|
134
|
+
export function validateVault(vaultPath, schema) {
|
|
135
|
+
const relPaths = collectMarkdownFiles(vaultPath);
|
|
136
|
+
|
|
137
|
+
// Pass 1: parse frontmatter and index basename -> set of entity types.
|
|
138
|
+
const parsed = [];
|
|
139
|
+
const index = new Map();
|
|
140
|
+
for (const rel of relPaths) {
|
|
141
|
+
const text = readFileSync(join(vaultPath, rel), 'utf8');
|
|
142
|
+
const { frontmatter } = parseFrontmatter(text);
|
|
143
|
+
parsed.push({ rel, frontmatter });
|
|
144
|
+
const type = frontmatter.type;
|
|
145
|
+
if (typeof type === 'string' && type) {
|
|
146
|
+
const key = basename(rel).replace(/\.md$/i, '');
|
|
147
|
+
if (!index.has(key)) index.set(key, new Set());
|
|
148
|
+
index.get(key).add(type);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Pass 2: validate each file.
|
|
153
|
+
const results = [];
|
|
154
|
+
let valid = 0;
|
|
155
|
+
for (const { rel, frontmatter } of parsed) {
|
|
156
|
+
const { entity, errors } = validateFile(frontmatter, schema, index);
|
|
157
|
+
const ok = errors.length === 0;
|
|
158
|
+
if (ok) valid += 1;
|
|
159
|
+
results.push({ file: rel, entity, ok, errors });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
results,
|
|
164
|
+
summary: { files: parsed.length, valid, invalid: parsed.length - valid },
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# maxy-lite web chat relay
|
|
2
|
+
|
|
3
|
+
A localhost relay that turns the raw `claude` TUI into a structured chat surface
|
|
4
|
+
on the phone browser. It spawns `claude` in a node-pty (the message you type in
|
|
5
|
+
the browser is written into the pty), and renders replies by **tailing the
|
|
6
|
+
session JSONL** — not by scraping terminal bytes. Rows stream to the browser as
|
|
7
|
+
user / assistant / tool bubbles over a WebSocket.
|
|
8
|
+
|
|
9
|
+
This is the "structured web chat" tier above the `ttyd` raw terminal in
|
|
10
|
+
[`maxy-lite-install.md`](../../../.docs/maxy-lite-install.md). It runs inside the
|
|
11
|
+
proot-distro Ubuntu layer; it is localhost-only (no off-device exposure).
|
|
12
|
+
|
|
13
|
+
## Run (inside proot Ubuntu)
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
cd ~/.maxy/packages/maxy-lite/webchat # wherever the package landed
|
|
17
|
+
npm install # builds node-pty (needs python3 make g++)
|
|
18
|
+
LITE_AGENT_HOME=~/maxy npm start # serves on http://localhost:7682
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Open **http://localhost:7682** in the phone browser. Type a message; the reply
|
|
22
|
+
renders as bubbles. A reload replays the current transcript.
|
|
23
|
+
|
|
24
|
+
## Environment
|
|
25
|
+
|
|
26
|
+
| Var | Default | Meaning |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `LITE_PORT` | `7682` | HTTP + WebSocket port (localhost). |
|
|
29
|
+
| `LITE_AGENT_HOME` | `process.cwd()` | claude's working dir = the markdown store root. |
|
|
30
|
+
| `LITE_CLAUDE_BIN` | `claude` | The binary spawned in the pty. |
|
|
31
|
+
| `LITE_STATE_DIR` | `<home>/.maxy-lite` | Where the active session id is persisted for resume. |
|
|
32
|
+
|
|
33
|
+
The relay persists the session id under `LITE_STATE_DIR`; on restart it resumes
|
|
34
|
+
that session (`claude --resume`) so the transcript survives a relay bounce.
|
|
35
|
+
Delete `session-id` there to start a fresh conversation.
|
|
36
|
+
|
|
37
|
+
## Observability
|
|
38
|
+
|
|
39
|
+
Every per-turn step emits one `[lite-chat]` line to stderr, correlated by
|
|
40
|
+
`turn=<n>`. Capture stderr to a file and reconstruct one turn:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
LITE_AGENT_HOME=~/maxy npm start 2> relay.log
|
|
44
|
+
grep '\[lite-chat\]' relay.log | grep 'turn=3'
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
A healthy turn shows the full lifeline: `inbound -> spawn (pty=true) -> jsonl
|
|
48
|
+
(newBytes>0) -> render (msgs>0) -> done`. Failure signals: `spawn pty=false`,
|
|
49
|
+
`jsonl newBytes=0` on a replying turn, `render msgs=0`, repeated `parse-skip`.
|
|
50
|
+
|
|
51
|
+
## Boundaries
|
|
52
|
+
|
|
53
|
+
The filesystem browser over the markdown store is delegated to **Obsidian** on
|
|
54
|
+
the shared vault — it is not part of this relay. Multi-session UI, auth, theming,
|
|
55
|
+
and off-device exposure are out of scope.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rubytech/maxy-lite-webchat",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"lockfileVersion": 3,
|
|
5
|
+
"requires": true,
|
|
6
|
+
"packages": {
|
|
7
|
+
"": {
|
|
8
|
+
"name": "@rubytech/maxy-lite-webchat",
|
|
9
|
+
"version": "0.0.0",
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"node-pty": "^1.0.0",
|
|
12
|
+
"ws": "^8.18.0"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"maxy-lite-webchat": "server.mjs"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"node_modules/node-addon-api": {
|
|
19
|
+
"version": "7.1.1",
|
|
20
|
+
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
|
21
|
+
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
|
22
|
+
"license": "MIT"
|
|
23
|
+
},
|
|
24
|
+
"node_modules/node-pty": {
|
|
25
|
+
"version": "1.1.0",
|
|
26
|
+
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
|
|
27
|
+
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
|
|
28
|
+
"hasInstallScript": true,
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"node-addon-api": "^7.1.0"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"node_modules/ws": {
|
|
35
|
+
"version": "8.21.0",
|
|
36
|
+
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
|
37
|
+
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=10.0.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"bufferutil": "^4.0.1",
|
|
44
|
+
"utf-8-validate": ">=5.0.2"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"bufferutil": {
|
|
48
|
+
"optional": true
|
|
49
|
+
},
|
|
50
|
+
"utf-8-validate": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rubytech/maxy-lite-webchat",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "maxy-lite structured web chat relay: drives claude via node-pty, renders the session JSONL as chat bubbles in the phone browser (on-device, localhost).",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"maxy-lite-webchat": "server.mjs"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node server.mjs"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"node-pty": "^1.0.0",
|
|
15
|
+
"ws": "^8.18.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Pure JSONL-line -> render-message classifier for the maxy-lite web chat relay.
|
|
2
|
+
// The session JSONL (~/.claude/projects/.../<id>.jsonl) is the canonical claude
|
|
3
|
+
// transcript; this module turns its rows into the three structured message kinds
|
|
4
|
+
// the lite UI renders: user (typed), assistant (prose), tool (a call or a result).
|
|
5
|
+
//
|
|
6
|
+
// A stripped sibling of the maxy-code /chat parser (platform/ui/app/lib/
|
|
7
|
+
// whatsapp-reader/parse-transcript.ts). The Pi-only apparatus that file carries
|
|
8
|
+
// — channel <channel> wrappers, queue-operation enqueue/twin dedup, composed
|
|
9
|
+
// admin frames, upload notes, api-error banners — is OUT of scope for maxy-lite:
|
|
10
|
+
// the relay drives one local interactive session, so only the plain row shapes
|
|
11
|
+
// remain. No fs, no transport — unit-testable in isolation.
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {{kind:'user', text:string, ts:string|null}} UserMessage
|
|
15
|
+
* @typedef {{kind:'assistant', text:string, ts:string|null}} AssistantMessage
|
|
16
|
+
* @typedef {{kind:'tool', phase:'call', name:string, input:unknown, ts:string|null}} ToolCallMessage
|
|
17
|
+
* @typedef {{kind:'tool', phase:'result', text:string, isError:boolean, ts:string|null}} ToolResultMessage
|
|
18
|
+
* @typedef {UserMessage|AssistantMessage|ToolCallMessage|ToolResultMessage} Message
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// CLI-synthesised user rows (slash commands, /clear, /resume) wrap their payload
|
|
22
|
+
// in these openers — never a real operator message, so they are dropped.
|
|
23
|
+
const CLI_MARKER_PREFIXES = ['<local-command-', '<command-name>', '<command-message>']
|
|
24
|
+
|
|
25
|
+
function tsOf(row) {
|
|
26
|
+
return typeof row.timestamp === 'string' ? row.timestamp : null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function asString(content) {
|
|
30
|
+
return typeof content === 'string' ? content : null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Recover a tool_result's text whether `content` is a plain string or the
|
|
34
|
+
// array-of-blocks shape claude also emits.
|
|
35
|
+
function toolResultText(content) {
|
|
36
|
+
if (typeof content === 'string') return content
|
|
37
|
+
if (Array.isArray(content)) {
|
|
38
|
+
return content
|
|
39
|
+
.map((c) => (c && typeof c === 'object' && typeof c.text === 'string' ? c.text : ''))
|
|
40
|
+
.join('')
|
|
41
|
+
}
|
|
42
|
+
return ''
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Classify one assistant turn's content blocks into render messages. */
|
|
46
|
+
function assistantMessages(content, ts) {
|
|
47
|
+
if (!Array.isArray(content)) return []
|
|
48
|
+
const out = []
|
|
49
|
+
for (const block of content) {
|
|
50
|
+
if (!block || typeof block !== 'object') continue
|
|
51
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
52
|
+
out.push({ kind: 'assistant', text: block.text, ts })
|
|
53
|
+
} else if (block.type === 'tool_use' && typeof block.name === 'string') {
|
|
54
|
+
out.push({ kind: 'tool', phase: 'call', name: block.name, input: block.input ?? null, ts })
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Classify a user turn whose content array carries tool_result blocks. */
|
|
61
|
+
function toolResultMessages(content, ts) {
|
|
62
|
+
if (!Array.isArray(content)) return []
|
|
63
|
+
const out = []
|
|
64
|
+
for (const block of content) {
|
|
65
|
+
if (!block || typeof block !== 'object' || block.type !== 'tool_result') continue
|
|
66
|
+
out.push({
|
|
67
|
+
kind: 'tool',
|
|
68
|
+
phase: 'result',
|
|
69
|
+
text: toolResultText(block.content),
|
|
70
|
+
isError: block.is_error === true,
|
|
71
|
+
ts,
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parse a list of JSONL lines into an ordered render-message list.
|
|
79
|
+
*
|
|
80
|
+
* @param {string[]} lines
|
|
81
|
+
* @param {{onSkip?: (lineIndex:number, reason:string) => void}} [opts]
|
|
82
|
+
* `onSkip` fires once per line that could not be parsed as a JSON object
|
|
83
|
+
* (the relay maps it to `op=parse-skip`). A blank line and a recognised but
|
|
84
|
+
* non-renderable row (e.g. `type:"system"`) are skipped silently — they are
|
|
85
|
+
* not parse failures. A malformed line never throws.
|
|
86
|
+
* @returns {Message[]}
|
|
87
|
+
*/
|
|
88
|
+
export function parseTranscript(lines, opts = {}) {
|
|
89
|
+
const onSkip = typeof opts.onSkip === 'function' ? opts.onSkip : null
|
|
90
|
+
const out = []
|
|
91
|
+
for (let i = 0; i < lines.length; i++) {
|
|
92
|
+
const line = lines[i]
|
|
93
|
+
if (!line || line.trim() === '') continue
|
|
94
|
+
let row
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(line)
|
|
97
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
98
|
+
if (onSkip) onSkip(i, 'not-object')
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
row = parsed
|
|
102
|
+
} catch {
|
|
103
|
+
if (onSkip) onSkip(i, 'json-parse')
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const ts = tsOf(row)
|
|
108
|
+
const msg = row.message
|
|
109
|
+
|
|
110
|
+
if (row.type === 'assistant') {
|
|
111
|
+
out.push(...assistantMessages(msg?.content, ts))
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (row.type !== 'user' || !msg) continue // system/summary/other: nothing renderable
|
|
116
|
+
|
|
117
|
+
if (Array.isArray(msg.content)) {
|
|
118
|
+
out.push(...toolResultMessages(msg.content, ts))
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const text = asString(msg.content)
|
|
123
|
+
if (text === null) continue
|
|
124
|
+
if (CLI_MARKER_PREFIXES.some((p) => text.startsWith(p))) continue
|
|
125
|
+
out.push({ kind: 'user', text, ts })
|
|
126
|
+
}
|
|
127
|
+
return out
|
|
128
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// maxy-lite web chat — minimal client. Opens a WebSocket to the relay, renders
|
|
2
|
+
// snapshot + appended messages as bubbles, and sends the composer text. A reload
|
|
3
|
+
// replays the transcript from the snapshot the server sends on connect.
|
|
4
|
+
|
|
5
|
+
const messagesEl = document.getElementById('messages')
|
|
6
|
+
const statusEl = document.getElementById('status')
|
|
7
|
+
const form = document.getElementById('composer')
|
|
8
|
+
const input = document.getElementById('input')
|
|
9
|
+
const toolIconTpl = document.getElementById('tpl-tool-icon')
|
|
10
|
+
|
|
11
|
+
function atBottom() {
|
|
12
|
+
return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 80
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function scrollDown() {
|
|
16
|
+
messagesEl.scrollTop = messagesEl.scrollHeight
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function renderMessage(m) {
|
|
20
|
+
const el = document.createElement('div')
|
|
21
|
+
if (m.kind === 'user') {
|
|
22
|
+
el.className = 'msg user'
|
|
23
|
+
el.textContent = m.text
|
|
24
|
+
} else if (m.kind === 'assistant') {
|
|
25
|
+
el.className = 'msg assistant'
|
|
26
|
+
el.textContent = m.text
|
|
27
|
+
} else if (m.kind === 'tool') {
|
|
28
|
+
el.className = 'msg tool' + (m.isError ? ' error' : '')
|
|
29
|
+
const head = document.createElement('div')
|
|
30
|
+
head.className = 'tool-head'
|
|
31
|
+
head.appendChild(toolIconTpl.content.cloneNode(true))
|
|
32
|
+
const label = document.createElement('span')
|
|
33
|
+
label.textContent = m.phase === 'call' ? m.name : m.isError ? 'tool error' : 'tool result'
|
|
34
|
+
head.appendChild(label)
|
|
35
|
+
const body = document.createElement('pre')
|
|
36
|
+
body.className = 'tool-body'
|
|
37
|
+
body.textContent =
|
|
38
|
+
m.phase === 'call'
|
|
39
|
+
? typeof m.input === 'string'
|
|
40
|
+
? m.input
|
|
41
|
+
: JSON.stringify(m.input ?? null, null, 2)
|
|
42
|
+
: m.text
|
|
43
|
+
el.appendChild(head)
|
|
44
|
+
el.appendChild(body)
|
|
45
|
+
} else {
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
messagesEl.appendChild(el)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderAll(list, replace) {
|
|
52
|
+
if (replace) messagesEl.replaceChildren()
|
|
53
|
+
const stick = atBottom()
|
|
54
|
+
for (const m of list) renderMessage(m)
|
|
55
|
+
if (replace || stick) scrollDown()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let ws
|
|
59
|
+
|
|
60
|
+
function connect() {
|
|
61
|
+
ws = new WebSocket(`ws://${location.host}/ws`)
|
|
62
|
+
ws.onopen = () => {
|
|
63
|
+
statusEl.textContent = 'connected'
|
|
64
|
+
statusEl.className = 'on'
|
|
65
|
+
}
|
|
66
|
+
ws.onclose = () => {
|
|
67
|
+
statusEl.textContent = 'reconnecting'
|
|
68
|
+
statusEl.className = 'off'
|
|
69
|
+
setTimeout(connect, 1000)
|
|
70
|
+
}
|
|
71
|
+
ws.onmessage = (ev) => {
|
|
72
|
+
let msg
|
|
73
|
+
try {
|
|
74
|
+
msg = JSON.parse(ev.data)
|
|
75
|
+
} catch {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
if (msg.type === 'snapshot') renderAll(msg.messages || [], true)
|
|
79
|
+
else if (msg.type === 'append') renderAll(msg.messages || [], false)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
connect()
|
|
84
|
+
|
|
85
|
+
function autosize() {
|
|
86
|
+
input.style.height = 'auto'
|
|
87
|
+
input.style.height = Math.min(input.scrollHeight, 140) + 'px'
|
|
88
|
+
}
|
|
89
|
+
input.addEventListener('input', autosize)
|
|
90
|
+
|
|
91
|
+
form.addEventListener('submit', (e) => {
|
|
92
|
+
e.preventDefault()
|
|
93
|
+
const text = input.value.trim()
|
|
94
|
+
if (!text || !ws || ws.readyState !== 1) return
|
|
95
|
+
ws.send(JSON.stringify({ type: 'send', text }))
|
|
96
|
+
input.value = ''
|
|
97
|
+
autosize()
|
|
98
|
+
input.focus()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
input.addEventListener('keydown', (e) => {
|
|
102
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
103
|
+
e.preventDefault()
|
|
104
|
+
form.requestSubmit()
|
|
105
|
+
}
|
|
106
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
|
+
<title>maxy-lite</title>
|
|
7
|
+
<link rel="stylesheet" href="/style.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header id="bar">
|
|
11
|
+
<span id="title">maxy-lite</span>
|
|
12
|
+
<span id="status" class="off">connecting</span>
|
|
13
|
+
</header>
|
|
14
|
+
<main id="messages" aria-live="polite"></main>
|
|
15
|
+
<form id="composer">
|
|
16
|
+
<textarea id="input" rows="1" placeholder="Message" autocomplete="off"></textarea>
|
|
17
|
+
<button id="send" type="submit" aria-label="Send">
|
|
18
|
+
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
|
|
19
|
+
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
20
|
+
<path d="M22 2 11 13" /><path d="M22 2 15 22 11 13 2 9 22 2Z" />
|
|
21
|
+
</svg>
|
|
22
|
+
</button>
|
|
23
|
+
</form>
|
|
24
|
+
<template id="tpl-tool-icon">
|
|
25
|
+
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor"
|
|
26
|
+
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
27
|
+
<path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18v3h3l6.3-6.3a4 4 0 0 0 5.4-5.4l-2.6 2.6-2-2 2.6-2.6Z" />
|
|
28
|
+
</svg>
|
|
29
|
+
</template>
|
|
30
|
+
<script src="/app.js"></script>
|
|
31
|
+
</body>
|
|
32
|
+
</html>
|