claude-artifact-framework 0.4.0 → 0.5.1
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 +119 -3
- package/dist/index.esm.js +412 -35
- package/dist/index.esm.js.map +3 -3
- package/dist/index.global.js +47 -9
- package/dist/index.global.js.map +4 -4
- package/package.json +1 -1
- package/src/app.js +150 -31
- package/src/appState.js +34 -3
- package/src/chat.js +248 -0
- package/src/records.js +64 -5
package/src/app.js
CHANGED
|
@@ -12,17 +12,68 @@
|
|
|
12
12
|
import { createElement as h, useState, useEffect, hasReact, getReact } from "react";
|
|
13
13
|
import { createAppState } from "./appState.js";
|
|
14
14
|
import { themeCss, seriesColors } from "./theme.js";
|
|
15
|
-
import { BLOCKS, BLOCK_NAMES } from "./blocks.js";
|
|
15
|
+
import { BLOCKS, BLOCK_NAMES, FIELD_TYPES } from "./blocks.js";
|
|
16
16
|
import { RecordsLayout } from "./records.js";
|
|
17
17
|
import { StepsLayout } from "./steps.js";
|
|
18
|
+
import { ChatLayout, CHAT_KEYS } from "./chat.js";
|
|
18
19
|
|
|
19
|
-
const LAYOUTS = ["panel", "records", "steps"];
|
|
20
|
+
const LAYOUTS = ["panel", "records", "steps", "chat"];
|
|
20
21
|
const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle"];
|
|
21
22
|
|
|
22
23
|
// Frontier models still invent identifiers a few percent of the time, so an
|
|
23
24
|
// unknown name has to fail loudly and name the alternatives rather than
|
|
24
25
|
// silently rendering nothing — a blank pane is indistinguishable from a bug.
|
|
26
|
+
const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared"];
|
|
27
|
+
|
|
28
|
+
// An unknown field type used to fall through silently to a text input — the
|
|
29
|
+
// exact quiet degradation the framework exists to prevent (a blind-tested
|
|
30
|
+
// Haiku wrote "checkbox" for "check" and shipped a text field).
|
|
31
|
+
function checkFields(fields, where) {
|
|
32
|
+
for (const f of fields || []) {
|
|
33
|
+
if (!f || !f.key) throw new Error(`claude-artifact-framework: every field in ${where} needs a \`key\`.`);
|
|
34
|
+
if (f.type !== undefined && !FIELD_TYPES.includes(f.type)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`claude-artifact-framework: field "${f.key}" in ${where} has unknown type "${f.type}". Valid types: ${FIELD_TYPES.join(", ")}.`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function checkBlocks(blocks) {
|
|
43
|
+
if (!Array.isArray(blocks)) {
|
|
44
|
+
throw new Error("claude-artifact-framework: `blocks` must be an array.");
|
|
45
|
+
}
|
|
46
|
+
blocks.forEach((block, i) => {
|
|
47
|
+
if (!block || typeof block !== "object") {
|
|
48
|
+
throw new Error(`claude-artifact-framework: blocks[${i}] must be an object like { fields: [...] }.`);
|
|
49
|
+
}
|
|
50
|
+
const keys = Object.keys(block).filter((k) => !RESERVED.includes(k));
|
|
51
|
+
const known = keys.filter((k) => BLOCK_NAMES.includes(k));
|
|
52
|
+
if (known.length === 0) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`claude-artifact-framework: blocks[${i}] has no recognised block type (found: ${
|
|
55
|
+
keys.join(", ") || "nothing"
|
|
56
|
+
}). Valid block types: ${BLOCK_NAMES.join(", ")}.`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (known.length > 1) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`claude-artifact-framework: blocks[${i}] declares more than one block type (${known.join(
|
|
62
|
+
", "
|
|
63
|
+
)}). Split them into separate entries of \`blocks\`.`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
checkFields(block.fields, `blocks[${i}]`);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
25
70
|
function validate(spec) {
|
|
71
|
+
const unknownTop = Object.keys(spec).filter((k) => !TOP_KEYS.includes(k));
|
|
72
|
+
if (unknownTop.length) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`claude-artifact-framework: unknown top-level keys (${unknownTop.join(", ")}). Valid keys: ${TOP_KEYS.join(", ")}.`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
26
77
|
const layout = spec.layout || "panel";
|
|
27
78
|
if (!LAYOUTS.includes(layout)) {
|
|
28
79
|
throw new Error(
|
|
@@ -49,8 +100,8 @@ function validate(spec) {
|
|
|
49
100
|
if (!st.fields && !st.text && !st.result) {
|
|
50
101
|
throw new Error(`claude-artifact-framework: steps[${i}] needs \`fields\`, \`text\`, or \`result\`.`);
|
|
51
102
|
}
|
|
103
|
+
checkFields(st.fields, `steps[${i}]`);
|
|
52
104
|
for (const f of st.fields || []) {
|
|
53
|
-
if (!f || !f.key) throw new Error(`claude-artifact-framework: every field in steps[${i}] needs a \`key\`.`);
|
|
54
105
|
if (seen.has(f.key)) {
|
|
55
106
|
throw new Error(
|
|
56
107
|
`claude-artifact-framework: field key "${f.key}" appears in more than one step — keys share one data pool and must be unique.`
|
|
@@ -61,6 +112,28 @@ function validate(spec) {
|
|
|
61
112
|
});
|
|
62
113
|
return layout;
|
|
63
114
|
}
|
|
115
|
+
if (layout === "chat") {
|
|
116
|
+
const c = spec.chat;
|
|
117
|
+
if (!c || (typeof c.system !== "string" && typeof c.system !== "function")) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
'claude-artifact-framework: layout "chat" needs `chat: { system: "..." }` (a string or a function of data).'
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const unknown = Object.keys(c).filter((k) => !CHAT_KEYS.includes(k));
|
|
123
|
+
if (unknown.length) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`claude-artifact-framework: chat has unknown keys (${unknown.join(", ")}). Valid keys: ${CHAT_KEYS.join(", ")}.`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
for (const [name, tool] of Object.entries(c.tools || {})) {
|
|
129
|
+
if (!tool || typeof tool.description !== "string" || typeof tool.run !== "function") {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`claude-artifact-framework: chat tool "${name}" needs a \`description\` string and a \`run(input, ctx)\` function.`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return layout;
|
|
136
|
+
}
|
|
64
137
|
if (layout === "records") {
|
|
65
138
|
const r = spec.records;
|
|
66
139
|
if (!r || !Array.isArray(r.fields) || r.fields.length === 0) {
|
|
@@ -68,40 +141,36 @@ function validate(spec) {
|
|
|
68
141
|
'claude-artifact-framework: layout "records" needs `records: { fields: [...] }` — the same field declarations the fields block uses.'
|
|
69
142
|
);
|
|
70
143
|
}
|
|
144
|
+
const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute"];
|
|
145
|
+
const unknownR = Object.keys(r).filter((k) => !RECORDS_KEYS.includes(k));
|
|
146
|
+
if (unknownR.length) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`claude-artifact-framework: records has unknown keys (${unknownR.join(", ")}). Valid keys: ${RECORDS_KEYS.join(", ")}.`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
checkFields(r.fields, "records");
|
|
71
152
|
const seen = new Set();
|
|
72
153
|
for (const f of r.fields) {
|
|
73
|
-
if (!f || !f.key) throw new Error("claude-artifact-framework: every records field needs a `key`.");
|
|
74
154
|
if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on records — the framework assigns it.');
|
|
75
155
|
if (seen.has(f.key)) throw new Error(`claude-artifact-framework: duplicate records field key "${f.key}".`);
|
|
76
156
|
seen.add(f.key);
|
|
77
157
|
}
|
|
158
|
+
if (spec.blocks) checkBlocks(spec.blocks);
|
|
159
|
+
for (const [key, c] of Object.entries(r.compute || {})) {
|
|
160
|
+
if (seen.has(key) || key === "id") {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`claude-artifact-framework: compute key "${key}" collides with a field key — computed values are derived, never stored.`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (typeof c !== "function" && typeof (c && c.value) !== "function") {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`claude-artifact-framework: compute "${key}" must be a function of the record, or { label, format, value: (r) => ... }.`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
78
171
|
return layout;
|
|
79
172
|
}
|
|
80
|
-
|
|
81
|
-
if (!Array.isArray(blocks)) {
|
|
82
|
-
throw new Error("claude-artifact-framework: `blocks` must be an array.");
|
|
83
|
-
}
|
|
84
|
-
blocks.forEach((block, i) => {
|
|
85
|
-
if (!block || typeof block !== "object") {
|
|
86
|
-
throw new Error(`claude-artifact-framework: blocks[${i}] must be an object like { fields: [...] }.`);
|
|
87
|
-
}
|
|
88
|
-
const keys = Object.keys(block).filter((k) => !RESERVED.includes(k));
|
|
89
|
-
const known = keys.filter((k) => BLOCK_NAMES.includes(k));
|
|
90
|
-
if (known.length === 0) {
|
|
91
|
-
throw new Error(
|
|
92
|
-
`claude-artifact-framework: blocks[${i}] has no recognised block type (found: ${
|
|
93
|
-
keys.join(", ") || "nothing"
|
|
94
|
-
}). Valid block types: ${BLOCK_NAMES.join(", ")}.`
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
if (known.length > 1) {
|
|
98
|
-
throw new Error(
|
|
99
|
-
`claude-artifact-framework: blocks[${i}] declares more than one block type (${known.join(
|
|
100
|
-
", "
|
|
101
|
-
)}). Split them into separate entries of \`blocks\`.`
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
});
|
|
173
|
+
checkBlocks(spec.blocks || []);
|
|
105
174
|
return layout;
|
|
106
175
|
}
|
|
107
176
|
|
|
@@ -113,6 +182,9 @@ function defaultsFrom(spec) {
|
|
|
113
182
|
if ((spec.layout || "panel") === "records" && !Array.isArray(data.records)) {
|
|
114
183
|
data.records = [];
|
|
115
184
|
}
|
|
185
|
+
if ((spec.layout || "panel") === "chat" && !data.chat) {
|
|
186
|
+
data.chat = { api: [], log: [] };
|
|
187
|
+
}
|
|
116
188
|
for (const block of [...(spec.blocks || []), ...(spec.steps || [])]) {
|
|
117
189
|
for (const field of block.fields || []) {
|
|
118
190
|
if (field && field.key !== undefined && !(field.key in data)) {
|
|
@@ -173,11 +245,38 @@ function Panel({ spec, ctx }) {
|
|
|
173
245
|
);
|
|
174
246
|
}
|
|
175
247
|
|
|
176
|
-
|
|
248
|
+
// Records + extra blocks is a natural combo (a list with a chart of itself
|
|
249
|
+
// below). Blocks render under the list, never under the detail screen, and
|
|
250
|
+
// their functions receive the same data object as everywhere else — the
|
|
251
|
+
// collection lives at d.records.
|
|
252
|
+
function RecordsWithBlocks({ spec, ctx }) {
|
|
253
|
+
const detailOpen =
|
|
254
|
+
ctx.view.screen === "record" && (ctx.data.records || []).some((r) => r.id === ctx.view.arg);
|
|
255
|
+
return h(
|
|
256
|
+
"div",
|
|
257
|
+
null,
|
|
258
|
+
h(RecordsLayout, { spec, ctx }),
|
|
259
|
+
!detailOpen && Array.isArray(spec.blocks) && spec.blocks.length
|
|
260
|
+
? h(
|
|
261
|
+
"div",
|
|
262
|
+
{ className: "caf-panel caf-records-blocks" },
|
|
263
|
+
spec.blocks.map((block, i) =>
|
|
264
|
+
h(
|
|
265
|
+
"section",
|
|
266
|
+
{ key: i, className: block.wide ? "caf-slot caf-slot-wide" : "caf-slot" },
|
|
267
|
+
h(Block, { block, ctx })
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
: null
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const LAYOUT_COMPONENTS = { panel: Panel, records: RecordsWithBlocks, steps: StepsLayout, chat: ChatLayout };
|
|
177
276
|
|
|
178
277
|
export function createApp(spec = {}) {
|
|
179
278
|
const layout = validate(spec);
|
|
180
|
-
const state = createAppState(defaultsFrom(spec));
|
|
279
|
+
const state = createAppState(defaultsFrom(spec), { shared: Boolean(spec.shared) });
|
|
181
280
|
const css = themeCss(spec.theme && spec.theme.seed) + BASE_CSS;
|
|
182
281
|
|
|
183
282
|
return function App() {
|
|
@@ -330,6 +429,26 @@ const BASE_CSS = `
|
|
|
330
429
|
.caf-steps-fill { transition: width 0.25s ease; }
|
|
331
430
|
.caf-step-text { margin: 0; color: var(--caf-muted); font-size: 0.92rem; line-height: 1.55; }
|
|
332
431
|
.caf-step-nav { display: flex; justify-content: space-between; gap: 0.6rem; margin-top: 0.4rem; }
|
|
432
|
+
.caf-chat-log { min-height: 320px; max-height: 62vh; overflow-y: auto; gap: 0.55rem; }
|
|
433
|
+
.caf-msg { max-width: 85%; padding: 0.55rem 0.75rem; border-radius: 12px; font-size: 0.92rem; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
|
|
434
|
+
.caf-msg-user { align-self: flex-end; background: var(--caf-accent); color: var(--caf-accentText); border-bottom-right-radius: 4px; }
|
|
435
|
+
.caf-msg-assistant { align-self: flex-start; background: var(--caf-sunken); border-bottom-left-radius: 4px; }
|
|
436
|
+
.caf-msg-live::after { content: "▍"; opacity: 0.6; }
|
|
437
|
+
.caf-msg-tools { align-self: flex-start; font-size: 0.74rem; color: var(--caf-muted); padding: 0.15rem 0.55rem; border: 1px solid var(--caf-border); border-radius: 999px; }
|
|
438
|
+
.caf-msg-error { align-self: stretch; max-width: none; display: flex; flex-direction: column; gap: 0.2rem; background: transparent; border: 1px solid var(--caf-danger); }
|
|
439
|
+
.caf-msg-error strong { color: var(--caf-danger); font-size: 0.85rem; }
|
|
440
|
+
.caf-chat-input { display: flex; gap: 0.6rem; margin-top: 0.8rem; }
|
|
441
|
+
.caf-chat-input input { flex: 1; font: inherit; font-size: 0.95rem; padding: 0.55rem 0.7rem; border-radius: 8px; border: 1px solid var(--caf-border); background: var(--caf-surface); color: var(--caf-text); }
|
|
442
|
+
.caf-chat-input input:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
|
|
443
|
+
.caf-search {
|
|
444
|
+
font: inherit; font-size: 0.9rem; width: 100%;
|
|
445
|
+
padding: 0.5rem 0.65rem; margin: 0 0 0.3rem;
|
|
446
|
+
border-radius: 7px; border: 1px solid var(--caf-border);
|
|
447
|
+
background: var(--caf-sunken); color: var(--caf-text);
|
|
448
|
+
}
|
|
449
|
+
.caf-search:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
|
|
450
|
+
.caf-computed { border-top: 1px solid var(--caf-border); padding-top: 0.7rem; }
|
|
451
|
+
.caf-records-blocks { margin-top: 1rem; }
|
|
333
452
|
.caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
|
|
334
453
|
.caf-block-error { border-color: var(--caf-danger); gap: 0.4rem; }
|
|
335
454
|
.caf-block-error strong { font-size: 0.9rem; color: var(--caf-danger); }
|
package/src/appState.js
CHANGED
|
@@ -44,10 +44,20 @@ function debounce(fn, ms) {
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
// With `shared: true` the DATA pool lives in the shared storage scope, so
|
|
48
|
+
// every viewer of a published artifact converges on the same data (polled,
|
|
49
|
+
// last-write-wins). View state — navigation, selection, the open record —
|
|
50
|
+
// stays personal ALWAYS: two people looking at the same list should not
|
|
51
|
+
// fight over which screen is open.
|
|
52
|
+
export function createAppState(defaults, { debounceMs = 400, shared = false, pollMs = 2500 } = {}) {
|
|
53
|
+
const dataStore = shared ? storage.shared : storage;
|
|
48
54
|
let data = structuredCloneish(defaults);
|
|
49
55
|
let view = { ...EMPTY_VIEW };
|
|
50
56
|
let hydrated = false;
|
|
57
|
+
// Counts update() calls so a poll never overwrites edits that haven't been
|
|
58
|
+
// persisted yet: remote state only applies when we owe storage nothing.
|
|
59
|
+
let writes = 0;
|
|
60
|
+
let persistedWrites = 0;
|
|
51
61
|
const listeners = new Set();
|
|
52
62
|
|
|
53
63
|
// Notification is batched to a microtask: three mutations in a row produce
|
|
@@ -63,12 +73,32 @@ export function createAppState(defaults, { debounceMs = 400 } = {}) {
|
|
|
63
73
|
});
|
|
64
74
|
}
|
|
65
75
|
|
|
66
|
-
const persistData = debounce(() =>
|
|
76
|
+
const persistData = debounce(() => {
|
|
77
|
+
const at = writes;
|
|
78
|
+
Promise.resolve(dataStore.set(DATA_KEY, data)).then(() => {
|
|
79
|
+
persistedWrites = Math.max(persistedWrites, at);
|
|
80
|
+
}).catch(() => {});
|
|
81
|
+
}, debounceMs);
|
|
67
82
|
const persistView = debounce(() => storage.set(VIEW_KEY, view), debounceMs);
|
|
68
83
|
|
|
84
|
+
if (shared) {
|
|
85
|
+
setInterval(async () => {
|
|
86
|
+
if (!hydrated || writes > persistedWrites) return; // local edits in flight win
|
|
87
|
+
try {
|
|
88
|
+
const remote = await dataStore.get(DATA_KEY, undefined);
|
|
89
|
+
if (remote !== undefined && JSON.stringify(remote) !== JSON.stringify(data)) {
|
|
90
|
+
data = mergeDefaults(defaults, remote);
|
|
91
|
+
notify();
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// a failed poll just means we try again next tick
|
|
95
|
+
}
|
|
96
|
+
}, pollMs);
|
|
97
|
+
}
|
|
98
|
+
|
|
69
99
|
const ready = (async () => {
|
|
70
100
|
const [storedData, storedView] = await Promise.all([
|
|
71
|
-
|
|
101
|
+
dataStore.get(DATA_KEY, undefined),
|
|
72
102
|
storage.get(VIEW_KEY, undefined),
|
|
73
103
|
]);
|
|
74
104
|
if (storedData !== undefined) data = mergeDefaults(defaults, storedData);
|
|
@@ -82,6 +112,7 @@ export function createAppState(defaults, { debounceMs = 400 } = {}) {
|
|
|
82
112
|
// Handlers may either mutate the draft or return a replacement.
|
|
83
113
|
if (result !== undefined) data = result;
|
|
84
114
|
data = { ...data };
|
|
115
|
+
writes++;
|
|
85
116
|
persistData();
|
|
86
117
|
notify();
|
|
87
118
|
}
|
package/src/chat.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// chat.js — the conversation layout: tutors, advisors, generators, roleplay.
|
|
2
|
+
//
|
|
3
|
+
// Every piece of the network contract here is copied from code proven inside
|
|
4
|
+
// the real artifact runtime (the PDF annotator), not designed on paper:
|
|
5
|
+
//
|
|
6
|
+
// endpoint fetch("https://api.anthropic.com/v1/messages") with NO api
|
|
7
|
+
// key — the artifact runtime proxies the call and injects auth.
|
|
8
|
+
// streaming stream: true works; SSE parsed via content_block_delta.
|
|
9
|
+
// tools the proxy does NOT support native function calling. Tools are
|
|
10
|
+
// prompted: the model replies with a ```tool_call fenced block
|
|
11
|
+
// holding a JSON array; results go back as a user message
|
|
12
|
+
// tagged [TOOL_RESULT]; loop capped at 8 rounds.
|
|
13
|
+
//
|
|
14
|
+
// The author declares the system prompt and optionally tools that operate on
|
|
15
|
+
// the app's data through ctx.update — a chat that can drive the app. The
|
|
16
|
+
// framework owns everything that normally ships half-built: message state,
|
|
17
|
+
// live streaming, the busy state, the tool loop, and errors as visible
|
|
18
|
+
// bubbles instead of silent console noise.
|
|
19
|
+
//
|
|
20
|
+
// State split, same philosophy as data/view: `api` holds the exact messages
|
|
21
|
+
// the API needs (including raw tool_call text and [TOOL_RESULT] payloads);
|
|
22
|
+
// `log` holds what a human should see (stripped text, tool pills, errors).
|
|
23
|
+
// Both persist under data.chat, so a conversation survives reload. The
|
|
24
|
+
// in-flight streaming bubble is deliberately ephemeral local state.
|
|
25
|
+
|
|
26
|
+
import { createElement as h, useState, useEffect, useRef } from "react";
|
|
27
|
+
|
|
28
|
+
export const CHAT_KEYS = ["system", "greeting", "placeholder", "tools", "model", "maxTokens"];
|
|
29
|
+
|
|
30
|
+
// Proven default through the artifact proxy.
|
|
31
|
+
const DEFAULT_MODEL = "claude-sonnet-4-6";
|
|
32
|
+
const MAX_ROUNDS = 8;
|
|
33
|
+
|
|
34
|
+
async function streamCall({ model, maxTokens, system, messages, onText }) {
|
|
35
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "Content-Type": "application/json" },
|
|
38
|
+
body: JSON.stringify({ model, max_tokens: maxTokens, system, messages, stream: true }),
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok || !res.body) {
|
|
41
|
+
let detail = "";
|
|
42
|
+
try {
|
|
43
|
+
const b = await res.json();
|
|
44
|
+
detail = (b && b.error && b.error.message) || JSON.stringify(b);
|
|
45
|
+
} catch {
|
|
46
|
+
detail = await res.text().catch(() => "");
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`HTTP ${res.status}${detail ? ": " + detail : ""}`);
|
|
49
|
+
}
|
|
50
|
+
const reader = res.body.getReader();
|
|
51
|
+
const decoder = new TextDecoder();
|
|
52
|
+
let full = "";
|
|
53
|
+
let buf = ""; // reassembles SSE events split across network chunks
|
|
54
|
+
for (;;) {
|
|
55
|
+
const { done, value } = await reader.read();
|
|
56
|
+
if (done) break;
|
|
57
|
+
buf += decoder.decode(value, { stream: true });
|
|
58
|
+
const lines = buf.split("\n");
|
|
59
|
+
buf = lines.pop();
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
if (!line.startsWith("data: ")) continue;
|
|
62
|
+
try {
|
|
63
|
+
const j = JSON.parse(line.slice(6));
|
|
64
|
+
if (j.type === "content_block_delta" && j.delta && j.delta.text) {
|
|
65
|
+
full += j.delta.text;
|
|
66
|
+
if (onText) onText(full);
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// non-JSON lines (pings, [DONE]) are expected — skip
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return full;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function parseToolCalls(text) {
|
|
77
|
+
const match = text.match(/```tool_call\s*\n?([\s\S]*?)```/);
|
|
78
|
+
if (!match) return null;
|
|
79
|
+
try {
|
|
80
|
+
const calls = JSON.parse(match[1]);
|
|
81
|
+
return Array.isArray(calls) && calls.length ? calls : null;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function stripToolCallBlock(text) {
|
|
88
|
+
return text.replace(/```tool_call\s*\n?[\s\S]*?```/, "").trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The instructions the model needs to use prompted tools. Appended to the
|
|
92
|
+
// author's system prompt only when tools are declared.
|
|
93
|
+
function toolProtocol(tools) {
|
|
94
|
+
const list = Object.entries(tools)
|
|
95
|
+
.map(([name, t]) => `- ${name}: ${t.description}\n Input: ${JSON.stringify(t.input || {})}`)
|
|
96
|
+
.join("\n");
|
|
97
|
+
return `
|
|
98
|
+
|
|
99
|
+
You have tools available:
|
|
100
|
+
${list}
|
|
101
|
+
|
|
102
|
+
This environment does not support native function calling. To use a tool, reply ONLY with a \`\`\`tool_call code block containing a JSON array, with no other text in that turn. Example:
|
|
103
|
+
|
|
104
|
+
\`\`\`tool_call
|
|
105
|
+
[{ "name": "tool_name", "input": { "param": "value" } }]
|
|
106
|
+
\`\`\`
|
|
107
|
+
|
|
108
|
+
You may include several calls in one array. NEVER invent a tool's result — emit the pure tool_call block and wait for the real result, which arrives in the next message tagged [TOOL_RESULT]. When you have everything you need, answer in natural language with no tool_call block.`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function runTools(calls, tools, ctx) {
|
|
112
|
+
return calls.map((call) => {
|
|
113
|
+
const tool = tools[call.name];
|
|
114
|
+
if (!tool) {
|
|
115
|
+
// Fed back to the model, loud and in-band: it can correct itself on the
|
|
116
|
+
// next round instead of hallucinating a result.
|
|
117
|
+
return { name: call.name, error: `unknown tool "${call.name}". Valid tools: ${Object.keys(tools).join(", ")}` };
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const result = tool.run(call.input || {}, ctx);
|
|
121
|
+
return { name: call.name, result: result === undefined ? { ok: true } : result };
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return { name: call.name, error: String((e && e.message) || e) };
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function Bubble({ m }) {
|
|
129
|
+
if (m.role === "tools") {
|
|
130
|
+
return h("div", { className: "caf-msg-tools" }, `used: ${m.tools.join(", ")}`);
|
|
131
|
+
}
|
|
132
|
+
if (m.role === "error") {
|
|
133
|
+
return h(
|
|
134
|
+
"div",
|
|
135
|
+
{ className: "caf-msg caf-msg-error" },
|
|
136
|
+
h("strong", null, "The call failed"),
|
|
137
|
+
h("span", null, m.content),
|
|
138
|
+
h("span", { className: "caf-hint" }, "Your message is kept — send again to retry.")
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return h("div", { className: m.role === "user" ? "caf-msg caf-msg-user" : "caf-msg caf-msg-assistant" }, m.content);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function ChatLayout({ spec, ctx }) {
|
|
145
|
+
const c = spec.chat;
|
|
146
|
+
const [draft, setDraft] = useState("");
|
|
147
|
+
const [busy, setBusy] = useState(false);
|
|
148
|
+
const [live, setLive] = useState(null);
|
|
149
|
+
const endRef = useRef(null);
|
|
150
|
+
|
|
151
|
+
const chat = ctx.data.chat || { api: [], log: [] };
|
|
152
|
+
const log = chat.log || [];
|
|
153
|
+
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (endRef.current) endRef.current.scrollIntoView({ block: "end" });
|
|
156
|
+
}, [log.length, live]);
|
|
157
|
+
|
|
158
|
+
async function send() {
|
|
159
|
+
const text = draft.trim();
|
|
160
|
+
if (!text || busy) return;
|
|
161
|
+
setDraft("");
|
|
162
|
+
setBusy(true);
|
|
163
|
+
|
|
164
|
+
// api/log are threaded locally through the loop; ctx.data would be stale
|
|
165
|
+
// inside this closure after each update().
|
|
166
|
+
let api = [...(chat.api || []), { role: "user", content: text }];
|
|
167
|
+
let logNow = [...log, { role: "user", content: text }];
|
|
168
|
+
const commit = () => ctx.update((d) => { d.chat = { api, log: logNow }; });
|
|
169
|
+
commit();
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const system =
|
|
173
|
+
(typeof c.system === "function" ? c.system(ctx.data) : c.system || "") +
|
|
174
|
+
(c.tools ? toolProtocol(c.tools) : "");
|
|
175
|
+
|
|
176
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
177
|
+
const raw = await streamCall({
|
|
178
|
+
model: c.model || DEFAULT_MODEL,
|
|
179
|
+
maxTokens: c.maxTokens || 2000,
|
|
180
|
+
system,
|
|
181
|
+
messages: api,
|
|
182
|
+
onText: (t) => setLive(stripToolCallBlock(t)),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
api = [...api, { role: "assistant", content: raw }];
|
|
186
|
+
const shown = stripToolCallBlock(raw);
|
|
187
|
+
const calls = c.tools ? parseToolCalls(raw) : null;
|
|
188
|
+
|
|
189
|
+
if (!calls) {
|
|
190
|
+
logNow = [...logNow, { role: "assistant", content: shown || raw }];
|
|
191
|
+
commit();
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const results = runTools(calls, c.tools, ctx);
|
|
196
|
+
if (shown) logNow = [...logNow, { role: "assistant", content: shown }];
|
|
197
|
+
logNow = [...logNow, { role: "tools", tools: calls.map((x) => x.name) }];
|
|
198
|
+
api = [
|
|
199
|
+
...api,
|
|
200
|
+
{
|
|
201
|
+
role: "user",
|
|
202
|
+
content: `[TOOL_RESULT]\n${JSON.stringify(results)}\n[/TOOL_RESULT]\n\nContinue with this information. If you have everything you need, answer in natural language without further tool_call.`,
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
commit();
|
|
206
|
+
setLive(null);
|
|
207
|
+
}
|
|
208
|
+
} catch (e) {
|
|
209
|
+
logNow = [...logNow, { role: "error", content: String((e && e.message) || e) }];
|
|
210
|
+
commit();
|
|
211
|
+
} finally {
|
|
212
|
+
setLive(null);
|
|
213
|
+
setBusy(false);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return h(
|
|
218
|
+
"div",
|
|
219
|
+
{ className: "caf-panel caf-panel-single" },
|
|
220
|
+
h(
|
|
221
|
+
"div",
|
|
222
|
+
{ className: "caf-block caf-chat-log" },
|
|
223
|
+
c.greeting ? h("div", { className: "caf-msg caf-msg-assistant" }, c.greeting) : null,
|
|
224
|
+
log.map((m, i) => h(Bubble, { key: i, m })),
|
|
225
|
+
live !== null ? h("div", { className: "caf-msg caf-msg-assistant caf-msg-live" }, live || "…") : null,
|
|
226
|
+
busy && live === null ? h("div", { className: "caf-msg-tools" }, "thinking…") : null,
|
|
227
|
+
h("div", { ref: endRef })
|
|
228
|
+
),
|
|
229
|
+
h(
|
|
230
|
+
"form",
|
|
231
|
+
{
|
|
232
|
+
className: "caf-chat-input",
|
|
233
|
+
onSubmit: (e) => {
|
|
234
|
+
e.preventDefault();
|
|
235
|
+
send();
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
h("input", {
|
|
239
|
+
value: draft,
|
|
240
|
+
placeholder: c.placeholder || "Message…",
|
|
241
|
+
disabled: busy,
|
|
242
|
+
onChange: (e) => setDraft(e.target.value),
|
|
243
|
+
"aria-label": "Message",
|
|
244
|
+
}),
|
|
245
|
+
h("button", { type: "submit", className: "caf-btn caf-btn-primary", disabled: busy || !draft.trim() }, "Send")
|
|
246
|
+
)
|
|
247
|
+
);
|
|
248
|
+
}
|
package/src/records.js
CHANGED
|
@@ -18,9 +18,29 @@
|
|
|
18
18
|
// in data — restoring it on reload is correct, and deleting the open record
|
|
19
19
|
// simply falls back to the list.
|
|
20
20
|
|
|
21
|
-
import { createElement as h } from "react";
|
|
21
|
+
import { createElement as h, useState } from "react";
|
|
22
22
|
import { Field, formatValue } from "./blocks.js";
|
|
23
23
|
|
|
24
|
+
// Computed fields are derived at render time and never stored, so they can't
|
|
25
|
+
// go stale. Accepts `{ subtotal: (r) => ... }` or
|
|
26
|
+
// `{ subtotal: { label, format, value: (r) => ... } }`.
|
|
27
|
+
function computedEntries(spec) {
|
|
28
|
+
return Object.entries(spec.compute || {}).map(([k, v]) => [k, typeof v === "function" ? { value: v } : v]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function withComputed(spec, rec) {
|
|
32
|
+
if (!spec.compute) return rec;
|
|
33
|
+
const out = { ...rec };
|
|
34
|
+
for (const [key, c] of computedEntries(spec)) {
|
|
35
|
+
try {
|
|
36
|
+
out[key] = c.value(rec);
|
|
37
|
+
} catch {
|
|
38
|
+
out[key] = undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
24
44
|
export function recordDefaults(fields) {
|
|
25
45
|
const rec = {};
|
|
26
46
|
for (const f of fields) {
|
|
@@ -66,8 +86,16 @@ function Summary({ spec, records }) {
|
|
|
66
86
|
|
|
67
87
|
function ListScreen({ spec, ctx }) {
|
|
68
88
|
const label = spec.label || "item";
|
|
69
|
-
|
|
89
|
+
const [query, setQuery] = useState("");
|
|
90
|
+
let records = ctx.data.records.map((r) => withComputed(spec, r));
|
|
70
91
|
if (spec.sort) records = records.slice().sort(spec.sort);
|
|
92
|
+
const total = records.length;
|
|
93
|
+
const q = query.trim().toLowerCase();
|
|
94
|
+
if (spec.search && q) {
|
|
95
|
+
records = records.filter((r) =>
|
|
96
|
+
spec.fields.some((f) => String(r[f.key] ?? "").toLowerCase().includes(q))
|
|
97
|
+
);
|
|
98
|
+
}
|
|
71
99
|
|
|
72
100
|
function add() {
|
|
73
101
|
const rec = { id: makeId(), ...recordDefaults(spec.fields) };
|
|
@@ -83,9 +111,25 @@ function ListScreen({ spec, ctx }) {
|
|
|
83
111
|
h(
|
|
84
112
|
"div",
|
|
85
113
|
{ className: "caf-toolbar" },
|
|
86
|
-
h(
|
|
114
|
+
h(
|
|
115
|
+
"span",
|
|
116
|
+
{ className: "caf-count" },
|
|
117
|
+
spec.search && q ? `${records.length} of ${total}` : `${total} ${label}${total === 1 ? "" : "s"}`
|
|
118
|
+
),
|
|
87
119
|
h("button", { type: "button", className: "caf-btn caf-btn-primary", onClick: add }, `Add ${label}`)
|
|
88
120
|
),
|
|
121
|
+
spec.search
|
|
122
|
+
? h("input", {
|
|
123
|
+
className: "caf-search",
|
|
124
|
+
value: query,
|
|
125
|
+
placeholder: `Search ${label}s…`,
|
|
126
|
+
"aria-label": "Search",
|
|
127
|
+
onChange: (e) => setQuery(e.target.value),
|
|
128
|
+
})
|
|
129
|
+
: null,
|
|
130
|
+
spec.search && q && records.length === 0 && total > 0
|
|
131
|
+
? h("div", { className: "caf-empty" }, `Nothing matches "${query}".`)
|
|
132
|
+
: null,
|
|
89
133
|
records.length === 0
|
|
90
134
|
? h("div", { className: "caf-empty" }, spec.empty || `No ${label}s yet. Add the first one.`)
|
|
91
135
|
: records.map((rec) =>
|
|
@@ -134,7 +178,22 @@ function DetailScreen({ spec, ctx, record }) {
|
|
|
134
178
|
if (rec) rec[field.key] = v;
|
|
135
179
|
}),
|
|
136
180
|
})
|
|
137
|
-
)
|
|
181
|
+
),
|
|
182
|
+
spec.compute
|
|
183
|
+
? h(
|
|
184
|
+
"div",
|
|
185
|
+
{ className: "caf-output-grid caf-computed" },
|
|
186
|
+
computedEntries(spec).map(([key, c]) => {
|
|
187
|
+
const live = withComputed(spec, record);
|
|
188
|
+
return h(
|
|
189
|
+
"div",
|
|
190
|
+
{ key, className: "caf-stat" },
|
|
191
|
+
h("span", { className: "caf-stat-label" }, c.label || key),
|
|
192
|
+
h("span", { className: "caf-stat-value" }, formatValue(live[key], c.format))
|
|
193
|
+
);
|
|
194
|
+
})
|
|
195
|
+
)
|
|
196
|
+
: null
|
|
138
197
|
);
|
|
139
198
|
}
|
|
140
199
|
|
|
@@ -152,7 +211,7 @@ export function RecordsLayout({ spec, ctx }) {
|
|
|
152
211
|
return h(
|
|
153
212
|
"div",
|
|
154
213
|
{ className: "caf-panel caf-panel-single" },
|
|
155
|
-
h(Summary, { spec: rspec, records: ctx.data.records }),
|
|
214
|
+
h(Summary, { spec: rspec, records: ctx.data.records.map((r) => withComputed(rspec, r)) }),
|
|
156
215
|
h(ListScreen, { spec: rspec, ctx })
|
|
157
216
|
);
|
|
158
217
|
}
|