claude-artifact-framework 0.3.0 → 0.5.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/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
  }
package/src/steps.js ADDED
@@ -0,0 +1,106 @@
1
+ // steps.js — the wizard layout: quizzes, assessments, staged calculators,
2
+ // long forms, onboarding.
3
+ //
4
+ // The author declares the sequence; next/back/progress/result are
5
+ // framework-owned. Three rules keep it un-breakable:
6
+ //
7
+ // one data pool step fields write into the same flat `data` every other
8
+ // block uses, so a result step is just a function of data —
9
+ // no "collect answers" plumbing to half-build.
10
+ // gated next Next is disabled while any field on the CURRENT step is
11
+ // invalid; earlier steps are already valid by construction,
12
+ // later ones aren't the user's problem yet.
13
+ // resumable the current step lives in view state, so a reload lands
14
+ // on the step the user was on, holding their answers.
15
+
16
+ import { createElement as h } from "react";
17
+ import { Field, validateField, formatValue } from "./blocks.js";
18
+
19
+ function stepErrors(step, data) {
20
+ return (step.fields || []).some((f) => validateField(f, data[f.key]) !== null);
21
+ }
22
+
23
+ function ResultScreen({ step, ctx }) {
24
+ const items = step.result(ctx.data) || [];
25
+ return h(
26
+ "div",
27
+ { className: "caf-block caf-output" },
28
+ step.title ? h("h2", { className: "caf-block-title" }, step.title) : null,
29
+ h(
30
+ "div",
31
+ { className: "caf-output-grid" },
32
+ items.map((item, i) =>
33
+ h(
34
+ "div",
35
+ { key: item.label ?? i, className: item.big ? "caf-stat caf-stat-big" : "caf-stat" },
36
+ h("span", { className: "caf-stat-label" }, item.label),
37
+ h("span", { className: "caf-stat-value" }, formatValue(item.value, item.format)),
38
+ item.hint ? h("small", { className: "caf-hint" }, item.hint) : null
39
+ )
40
+ )
41
+ ),
42
+ h(
43
+ "div",
44
+ { className: "caf-step-nav" },
45
+ h("button", { type: "button", className: "caf-btn", onClick: () => ctx.setStep(0) }, "Start over")
46
+ )
47
+ );
48
+ }
49
+
50
+ export function StepsLayout({ spec, ctx }) {
51
+ const steps = spec.steps;
52
+ const i = Math.min(Math.max(ctx.view.step || 0, 0), steps.length - 1);
53
+ const step = steps[i];
54
+ const isResult = typeof step.result === "function";
55
+ const isLast = i === steps.length - 1;
56
+
57
+ return h(
58
+ "div",
59
+ { className: "caf-panel caf-panel-single" },
60
+ h(
61
+ "div",
62
+ { className: "caf-steps-progress" },
63
+ h("span", { className: "caf-hint" }, isResult ? "Result" : `Step ${i + 1} of ${steps.length}`),
64
+ h(
65
+ "div",
66
+ { className: "caf-bar-track" },
67
+ h("div", { className: "caf-bar-fill caf-steps-fill", style: { width: `${((i + 1) / steps.length) * 100}%` } })
68
+ )
69
+ ),
70
+ isResult
71
+ ? h(ResultScreen, { step, ctx })
72
+ : h(
73
+ "div",
74
+ { className: "caf-block caf-fields" },
75
+ step.title ? h("h2", { className: "caf-block-title" }, step.title) : null,
76
+ step.text ? h("p", { className: "caf-step-text" }, typeof step.text === "function" ? step.text(ctx.data) : step.text) : null,
77
+ (step.fields || []).map((field) =>
78
+ h(Field, {
79
+ key: field.key,
80
+ field,
81
+ value: ctx.data[field.key],
82
+ onChange: (v) => ctx.update((d) => { d[field.key] = v; }),
83
+ })
84
+ ),
85
+ h(
86
+ "div",
87
+ { className: "caf-step-nav" },
88
+ i > 0
89
+ ? h("button", { type: "button", className: "caf-btn", onClick: () => ctx.setStep(i - 1) }, "‹ Back")
90
+ : h("span"),
91
+ !isLast
92
+ ? h(
93
+ "button",
94
+ {
95
+ type: "button",
96
+ className: "caf-btn caf-btn-primary",
97
+ disabled: stepErrors(step, ctx.data),
98
+ onClick: () => ctx.setStep(i + 1),
99
+ },
100
+ "Next ›"
101
+ )
102
+ : null
103
+ )
104
+ )
105
+ );
106
+ }
package/src/theme.js CHANGED
@@ -146,3 +146,12 @@ export function themeCss(seed) {
146
146
  :root[data-theme="light"] { ${vars(p.light)} }
147
147
  `;
148
148
  }
149
+
150
+ // Categorical series colors for charts, derived from the seed by golden-angle
151
+ // hue rotation, so any number of segments stays distinguishable. Mid-tone
152
+ // lightness reads on both the light and dark surface.
153
+ export function seriesColors(seed, n) {
154
+ const [h0, s0] = rgbToHsl(hexToRgb(seed || "#3b6ef6"));
155
+ const s = clamp(s0, 45, 70);
156
+ return Array.from({ length: n }, (_, i) => hsl((h0 + i * 137.5) % 360, s, 52));
157
+ }