|
| 1 | +/** |
| 2 | + * Real-model artifact-edit eval harness. |
| 3 | + * |
| 4 | + * Reproduces the EXACT HuggingChat path: inject ARTIFACTS_SYSTEM_PROMPT, ask the |
| 5 | + * real router model to create an artifact, then to edit it, assemble the assistant |
| 6 | + * messages the way chat-ui does (reasoning wrapped in <think>, then content), and |
| 7 | + * run the captured transcript through the real client parser (collectArtifacts). |
| 8 | + * |
| 9 | + * Run: SCENARIO=color MODEL=zai-org/GLM-5.2 RUN=1 npx vite-node scripts/artifact-real-eval.ts |
| 10 | + * Prints one JSON result line to stdout and writes the raw transcript to eval-output/. |
| 11 | + */ |
| 12 | +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; |
| 13 | +import { collectArtifacts, splitArtifactSegments } from "../src/lib/utils/artifacts"; |
| 14 | +import { ARTIFACTS_SYSTEM_PROMPT } from "../src/lib/server/textGeneration/artifacts"; |
| 15 | + |
| 16 | +const SCENARIOS: Record<string, { create: string; edit: string; expect: RegExp }> = { |
| 17 | + color: { |
| 18 | + create: "make a green button", |
| 19 | + edit: "make it red instead", |
| 20 | + // Common red hexes/keywords the models actually pick (avoid matching greens). |
| 21 | + expect: |
| 22 | + /ef4444|dc2626|f87171|ff6b6b|e83939|b91c1c|c0392b|e74c3c|db2828|\bred\b|crimson|firebrick/i, |
| 23 | + }, |
| 24 | + label: { |
| 25 | + create: "make a button that says Click Me", |
| 26 | + edit: "change the button label to Submit", |
| 27 | + expect: />\s*Submit\s*</i, |
| 28 | + }, |
| 29 | + multi: { |
| 30 | + create: "make a blue button labelled Go", |
| 31 | + edit: "change the color to orange and the label to Stop", |
| 32 | + expect: /Stop/i, |
| 33 | + }, |
| 34 | + add: { |
| 35 | + create: "make a simple centered landing page with one big heading", |
| 36 | + edit: "add a short subtitle paragraph under the heading", |
| 37 | + expect: /<p|subtitle/i, |
| 38 | + }, |
| 39 | + react: { |
| 40 | + create: "make a React counter component with an increment button", |
| 41 | + edit: "make the increment button add 2 instead of 1", |
| 42 | + expect: /\+\s*2|\+=\s*2|\bcount\s*\+\s*2/i, |
| 43 | + }, |
| 44 | +}; |
| 45 | + |
| 46 | +function loadEnv(): Record<string, string> { |
| 47 | + const env: Record<string, string> = {}; |
| 48 | + for (const line of readFileSync(".env.local", "utf8").split("\n")) { |
| 49 | + const i = line.indexOf("="); |
| 50 | + if (i < 0 || line.trim().startsWith("#")) continue; |
| 51 | + env[line.slice(0, i).trim()] = line |
| 52 | + .slice(i + 1) |
| 53 | + .trim() |
| 54 | + .replace(/^"|"$/g, ""); |
| 55 | + } |
| 56 | + return env; |
| 57 | +} |
| 58 | + |
| 59 | +async function complete( |
| 60 | + base: string, |
| 61 | + key: string, |
| 62 | + model: string, |
| 63 | + messages: Array<{ role: string; content: string }> |
| 64 | +): Promise<string> { |
| 65 | + const res = await fetch(`${base}/chat/completions`, { |
| 66 | + method: "POST", |
| 67 | + headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` }, |
| 68 | + body: JSON.stringify({ model, messages, max_tokens: 12000, temperature: 0.6 }), |
| 69 | + }); |
| 70 | + if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`); |
| 71 | + const j = (await res.json()) as { |
| 72 | + choices?: Array<{ |
| 73 | + message?: { content?: string; reasoning?: string; reasoning_content?: string }; |
| 74 | + }>; |
| 75 | + }; |
| 76 | + const msg = j.choices?.[0]?.message ?? {}; |
| 77 | + const reasoning = msg.reasoning ?? msg.reasoning_content ?? ""; |
| 78 | + const content = typeof msg.content === "string" ? msg.content : ""; |
| 79 | + // Assemble exactly like chat-ui's OpenAI path: reasoning becomes a <think> block |
| 80 | + // before the visible content. This is what collectArtifacts() actually receives. |
| 81 | + return reasoning ? `<think>${reasoning}</think>${content}` : content; |
| 82 | +} |
| 83 | + |
| 84 | +async function main() { |
| 85 | + const scenarioId = process.env.SCENARIO ?? "color"; |
| 86 | + const model = process.env.MODEL ?? "zai-org/GLM-5.2"; |
| 87 | + const run = process.env.RUN ?? "1"; |
| 88 | + const scenario = SCENARIOS[scenarioId]; |
| 89 | + if (!scenario) throw new Error(`unknown scenario ${scenarioId}`); |
| 90 | + |
| 91 | + const env = loadEnv(); |
| 92 | + const base = env.OPENAI_BASE_URL; |
| 93 | + const key = env.OPENAI_API_KEY; |
| 94 | + const sys = { role: "system", content: ARTIFACTS_SYSTEM_PROMPT }; |
| 95 | + |
| 96 | + const createOut = await complete(base, key, model, [ |
| 97 | + sys, |
| 98 | + { role: "user", content: scenario.create }, |
| 99 | + ]); |
| 100 | + const editOut = await complete(base, key, model, [ |
| 101 | + sys, |
| 102 | + { role: "user", content: scenario.create }, |
| 103 | + { role: "assistant", content: createOut }, |
| 104 | + { role: "user", content: scenario.edit }, |
| 105 | + ]); |
| 106 | + |
| 107 | + const msgs = [ |
| 108 | + { id: "u1", from: "user" as const, content: scenario.create }, |
| 109 | + { id: "a1", from: "assistant" as const, content: createOut }, |
| 110 | + { id: "u2", from: "user" as const, content: scenario.edit }, |
| 111 | + { id: "a2", from: "assistant" as const, content: editOut }, |
| 112 | + ]; |
| 113 | + const registry = collectArtifacts(msgs); |
| 114 | + |
| 115 | + // How many update pairs the parser extracted from the edit turn (think stripped, as collectArtifacts does) |
| 116 | + const editVisible = editOut.replace(/<think>[\s\S]*?(?:<\/think>|$)/gi, ""); |
| 117 | + let parsedUpdatePairs = 0; |
| 118 | + let editOpKind = "none"; |
| 119 | + for (const seg of splitArtifactSegments(editVisible)) { |
| 120 | + if (seg.type === "artifact") { |
| 121 | + editOpKind = seg.op.kind; |
| 122 | + if (seg.op.kind === "update") parsedUpdatePairs += seg.op.pairs.length; |
| 123 | + } |
| 124 | + } |
| 125 | + |
| 126 | + const artifactsArr = [...registry.artifacts.values()]; |
| 127 | + const deadCard = [...registry.byMessageOp.values()].some((r) => r.version === -1); |
| 128 | + const primary = artifactsArr[0]; |
| 129 | + const versions = primary?.versions ?? []; |
| 130 | + const last = versions.at(-1); |
| 131 | + const v1 = versions[0]; |
| 132 | + const contentChanged = !!last && !!v1 && last.content !== v1.content; |
| 133 | + const editReflected = !!last && scenario.expect.test(last.content); |
| 134 | + |
| 135 | + const result = { |
| 136 | + scenario: scenarioId, |
| 137 | + model, |
| 138 | + run, |
| 139 | + artifactCount: artifactsArr.length, |
| 140 | + versions: versions.length, |
| 141 | + lastOp: last?.op ?? "none", |
| 142 | + editOpKind, // what the model emitted on the edit turn: create/update/none |
| 143 | + parsedUpdatePairs, |
| 144 | + failedPairs: last?.failedPairs ?? 0, |
| 145 | + deadCard, |
| 146 | + contentChanged, |
| 147 | + editReflected, |
| 148 | + // success = the edit produced a correct new version of the same artifact |
| 149 | + ok: |
| 150 | + artifactsArr.length === 1 && |
| 151 | + versions.length >= 2 && |
| 152 | + contentChanged && |
| 153 | + editReflected && |
| 154 | + !last?.failedPairs, |
| 155 | + }; |
| 156 | + |
| 157 | + mkdirSync("eval-output", { recursive: true }); |
| 158 | + const safeModel = model.replace(/\//g, "_"); |
| 159 | + writeFileSync( |
| 160 | + `eval-output/${scenarioId}-${safeModel}-${run}.txt`, |
| 161 | + `SCENARIO ${scenarioId} | MODEL ${model} | RUN ${run}\n\n===== CREATE OUTPUT =====\n${createOut}\n\n===== EDIT OUTPUT =====\n${editOut}\n\n===== RESULT =====\n${JSON.stringify(result, null, 2)}\n` |
| 162 | + ); |
| 163 | + console.log(JSON.stringify(result)); |
| 164 | +} |
| 165 | + |
| 166 | +main().catch((e) => { |
| 167 | + console.log(JSON.stringify({ error: String(e?.message ?? e) })); |
| 168 | + process.exit(1); |
| 169 | +}); |
0 commit comments