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/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
- const blocks = spec.blocks || [];
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
- const LAYOUT_COMPONENTS = { panel: Panel, records: RecordsLayout, steps: StepsLayout };
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
- export function createAppState(defaults, { debounceMs = 400 } = {}) {
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(() => storage.set(DATA_KEY, data), debounceMs);
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
- storage.get(DATA_KEY, undefined),
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
- let records = ctx.data.records;
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("span", { className: "caf-count" }, `${records.length} ${label}${records.length === 1 ? "" : "s"}`),
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
  }