research-copilot 0.2.20 → 0.2.21
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 +1 -1
- package/app/out/main/index.mjs +2585 -48
- package/app/out/renderer/assets/{MilkdownMarkdownEditor-CCdZ2mtg.css → MilkdownMarkdownEditor-BW0Pt28W.css} +16 -1
- package/app/out/renderer/assets/{MilkdownMarkdownEditor-Bj7JSjF5.js → MilkdownMarkdownEditor-OhCrq3X0.js} +56 -51
- package/app/out/renderer/assets/{arc-CPL9nDFE.js → arc-DLr0RP8F.js} +1 -1
- package/app/out/renderer/assets/{blockDiagram-c4efeb88-BFOajDNs.js → blockDiagram-c4efeb88-XhKChw2n.js} +8 -8
- package/app/out/renderer/assets/{c4Diagram-c83219d4-LeqnQ2-5.js → c4Diagram-c83219d4-DDoJmoIQ.js} +3 -3
- package/app/out/renderer/assets/{channel-jk5Np8ud.js → channel-CJCgJSqV.js} +1 -1
- package/app/out/renderer/assets/{classDiagram-beda092f-CxOqB6OU.js → classDiagram-beda092f-CAmimZpz.js} +6 -6
- package/app/out/renderer/assets/{classDiagram-v2-2358418a-CyP_5qLa.js → classDiagram-v2-2358418a-Bma4E_Eg.js} +10 -10
- package/app/out/renderer/assets/{clone-PHFwh58n.js → clone-C338dmoI.js} +1 -1
- package/app/out/renderer/assets/{createText-1719965b-CE_0jsfj.js → createText-1719965b-_up4NJqB.js} +2 -2
- package/app/out/renderer/assets/{edges-96097737-DBk1JhZS.js → edges-96097737-Bpp6hVLn.js} +3 -3
- package/app/out/renderer/assets/{erDiagram-0228fc6a-DnR_LkSB.js → erDiagram-0228fc6a-bjTh_7ap.js} +5 -5
- package/app/out/renderer/assets/{flowDb-c6c81e3f-CJrZUKlS.js → flowDb-c6c81e3f-BjVV4DVk.js} +1 -1
- package/app/out/renderer/assets/{flowDiagram-50d868cf-CfNfrt17.js → flowDiagram-50d868cf-gmeaaZ6z.js} +12 -12
- package/app/out/renderer/assets/{flowDiagram-v2-4f6560a1-BGQtiK3j.js → flowDiagram-v2-4f6560a1-nem5zs2M.js} +12 -12
- package/app/out/renderer/assets/{flowchart-elk-definition-6af322e1-BXLraghz.js → flowchart-elk-definition-6af322e1-DPaGAYRw.js} +6 -6
- package/app/out/renderer/assets/{ganttDiagram-a2739b55-CAwaEMMm.js → ganttDiagram-a2739b55-CnAti19E.js} +3 -3
- package/app/out/renderer/assets/{gitGraphDiagram-82fe8481-vuSEC6ny.js → gitGraphDiagram-82fe8481-DQWHD3SJ.js} +2 -2
- package/app/out/renderer/assets/{graph-CZfltE7S.js → graph-DKiKgH8m.js} +1 -1
- package/app/out/renderer/assets/{index-DIZJXKQ6.js → index-4s-c5d65.js} +3 -3
- package/app/out/renderer/assets/{index-5325376f-DWTrHDEo.js → index-5325376f-G-0aO-2i.js} +6 -6
- package/app/out/renderer/assets/{index-CwPfquqm.js → index-9q_P5ULR.js} +4 -4
- package/app/out/renderer/assets/{index-EaGZvaBp.js → index-B1A3JxQj.js} +3 -3
- package/app/out/renderer/assets/{index-C2tqvXjC.js → index-BBUrmGmY.js} +6 -6
- package/app/out/renderer/assets/{index-D_7yOLk3.js → index-BQho5LH-.js} +6 -6
- package/app/out/renderer/assets/{index-B6f2bVW_.js → index-BUVlmsgO.js} +3 -3
- package/app/out/renderer/assets/{index-DpXI4mHb.js → index-BzEthrJ4.js} +3 -3
- package/app/out/renderer/assets/{index-CUsEKU8Q.js → index-C1YzkB4z.js} +93 -36
- package/app/out/renderer/assets/{index-CMfKxpBP.js → index-CGo665vD.js} +3 -3
- package/app/out/renderer/assets/{index-B5Mkpo9f.js → index-CPZaxR35.js} +3 -3
- package/app/out/renderer/assets/{index-BpdWQuss.js → index-CSyD1mbL.js} +3 -3
- package/app/out/renderer/assets/{index-DB8ImtMy.js → index-Cf7vlFSn.js} +3 -3
- package/app/out/renderer/assets/{index-CyDfvefg.js → index-CluH1o2q.js} +6 -6
- package/app/out/renderer/assets/{index-7dcVwInU.js → index-Cw1n3klA.js} +5 -5
- package/app/out/renderer/assets/{index-Ul-Kq9b2.js → index-DFzvntIw.js} +3 -3
- package/app/out/renderer/assets/{index-t0-md-MG.js → index-DHzyAhWM.js} +4 -4
- package/app/out/renderer/assets/{index-Cc9coKGN.js → index-DhliHfCM.js} +6 -6
- package/app/out/renderer/assets/{index-K0o5fHYG.js → index-DkVFbCxC.js} +3 -3
- package/app/out/renderer/assets/{index-DiCqe1UR.js → index-DpZJP5MT.js} +6 -6
- package/app/out/renderer/assets/{index-CaYWMBXT.js → index-Gfd_DiMG.js} +3 -3
- package/app/out/renderer/assets/{index-Di3HmXc-.js → index-jOvNAYyP.js} +3 -3
- package/app/out/renderer/assets/{index-B4V7cFWJ.js → index-rrJkk8KV.js} +6 -6
- package/app/out/renderer/assets/{index-BgAs-p8D.js → index-vfSerSmF.js} +1 -1
- package/app/out/renderer/assets/{infoDiagram-8eee0895-BmPESCfj.js → infoDiagram-8eee0895-BCnBkXXS.js} +2 -2
- package/app/out/renderer/assets/{journeyDiagram-c64418c1-BGsCbfr_.js → journeyDiagram-c64418c1-Bq2wSX3k.js} +4 -4
- package/app/out/renderer/assets/{layout-5MwFTPs7.js → layout-BvkumzoT.js} +2 -2
- package/app/out/renderer/assets/{line-D0U74KO0.js → line-eU4el-G4.js} +1 -1
- package/app/out/renderer/assets/{linear-BclyBoiT.js → linear-DlBjMBEa.js} +1 -1
- package/app/out/renderer/assets/{mindmap-definition-8da855dc-un1bPKBj.js → mindmap-definition-8da855dc-CzLBu7ao.js} +3 -3
- package/app/out/renderer/assets/{pieDiagram-a8764435-B7KM3duv.js → pieDiagram-a8764435--olrXFr_.js} +3 -3
- package/app/out/renderer/assets/{quadrantDiagram-1e28029f-C8i5m3Os.js → quadrantDiagram-1e28029f-BnpnBBgc.js} +3 -3
- package/app/out/renderer/assets/{requirementDiagram-08caed73-FjqENNN5.js → requirementDiagram-08caed73-6O9WS7hn.js} +5 -5
- package/app/out/renderer/assets/{sankeyDiagram-a04cb91d-BKV22yuJ.js → sankeyDiagram-a04cb91d-D-iJnK91.js} +2 -2
- package/app/out/renderer/assets/{sequenceDiagram-c5b8d532-DWO-Z2i3.js → sequenceDiagram-c5b8d532-DBlK15cV.js} +3 -3
- package/app/out/renderer/assets/{stateDiagram-1ecb1508-BqohgALA.js → stateDiagram-1ecb1508-DKXKPYuk.js} +6 -6
- package/app/out/renderer/assets/{stateDiagram-v2-c2b004d7-B3sEkrB8.js → stateDiagram-v2-c2b004d7-DY288Eo5.js} +10 -10
- package/app/out/renderer/assets/{styles-b4e223ce-BGytHk8n.js → styles-b4e223ce-CRJ_xgJ-.js} +1 -1
- package/app/out/renderer/assets/{styles-ca3715f6-B0PvBknL.js → styles-ca3715f6-Bp_k5KLD.js} +1 -1
- package/app/out/renderer/assets/{styles-d45a18b0-C6F384ai.js → styles-d45a18b0-DLA8Gg6D.js} +4 -4
- package/app/out/renderer/assets/{svgDrawCommon-b86b1483-BXgThwM_.js → svgDrawCommon-b86b1483-Dm5CK2gQ.js} +1 -1
- package/app/out/renderer/assets/{timeline-definition-faaaa080-iNn5igPR.js → timeline-definition-faaaa080-D-m9BHUg.js} +3 -3
- package/app/out/renderer/assets/{xychartDiagram-f5964ef8-oF_gxlk1.js → xychartDiagram-f5964ef8-Drn4Rqev.js} +5 -5
- package/app/out/renderer/index.html +1 -1
- package/lib/skills/builtin/academic-marp-slides/SKILL.md +933 -0
- package/lib/skills/builtin/research-grants/SKILL.md +15 -11
- package/lib/skills/builtin/scholar-evaluation/SKILL.md +12 -11
- package/lib/skills/builtin/scientific-schematics/SKILL.md +463 -560
- package/lib/skills/builtin/teaching-marp-slides/SKILL.md +1218 -0
- package/package.json +1 -1
- package/scripts/audit-diagram-prompts.mjs +67 -0
- package/scripts/test-skill-routing.mjs +238 -0
- package/lib/skills/builtin/marp-slides/SKILL.md +0 -642
- package/lib/skills/builtin/scientific-schematics/references/QUICK_REFERENCE.md +0 -182
- package/lib/skills/builtin/scientific-schematics/references/README.md +0 -292
- package/lib/skills/builtin/scientific-schematics/scripts/__pycache__/generate_schematic.cpython-312.pyc +0 -0
- package/lib/skills/builtin/scientific-schematics/scripts/__pycache__/generate_schematic_ai.cpython-312.pyc +0 -0
- package/lib/skills/builtin/scientific-schematics/scripts/example_usage.sh +0 -85
- package/lib/skills/builtin/scientific-schematics/scripts/generate_schematic.py +0 -141
- package/lib/skills/builtin/scientific-schematics/scripts/generate_schematic_ai.py +0 -910
package/app/out/main/index.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { app, shell,
|
|
1
|
+
import { app, shell, BrowserWindow, ipcMain, dialog, protocol, Menu } from "electron";
|
|
2
2
|
import { setMaxListeners } from "node:events";
|
|
3
3
|
import fs, { existsSync as existsSync$1 } from "node:fs";
|
|
4
4
|
import fsp, { readFile } from "node:fs/promises";
|
|
5
5
|
import { execFile, spawn, execSync } from "node:child_process";
|
|
6
6
|
import path, { resolve, join, sep, isAbsolute, basename, extname, dirname, relative } from "path";
|
|
7
|
-
import { existsSync, statSync, readdirSync
|
|
7
|
+
import { existsSync, statSync, readdirSync, readFileSync, cpSync, writeFileSync, mkdirSync, appendFileSync, rmSync, unlinkSync, renameSync, openSync, constants, writeSync, closeSync, watch } from "fs";
|
|
8
8
|
import os$1, { homedir } from "os";
|
|
9
9
|
import { createHash, randomUUID } from "crypto";
|
|
10
10
|
import { Agent } from "@mariozechner/pi-agent-core";
|
|
@@ -15,6 +15,7 @@ import { mkdir, writeFile } from "fs/promises";
|
|
|
15
15
|
import path$1 from "node:path";
|
|
16
16
|
import crypto$1, { createHash as createHash$1 } from "node:crypto";
|
|
17
17
|
import { promisify } from "node:util";
|
|
18
|
+
import { Blob } from "node:buffer";
|
|
18
19
|
import os from "node:os";
|
|
19
20
|
import { fileURLToPath } from "node:url";
|
|
20
21
|
import { execFile as execFile$1, execSync as execSync$1 } from "child_process";
|
|
@@ -90,7 +91,7 @@ function isIgnored(relativePath, isDirectory, rules, showIgnored) {
|
|
|
90
91
|
}
|
|
91
92
|
function hasVisibleChildren(dirPath, relativePath, rules, showIgnored) {
|
|
92
93
|
try {
|
|
93
|
-
const entries = readdirSync
|
|
94
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
94
95
|
for (const entry of entries) {
|
|
95
96
|
const childRelative = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
96
97
|
if (!isIgnored(childRelative, entry.isDirectory(), rules, showIgnored)) {
|
|
@@ -107,7 +108,7 @@ function listTreeChildren(rootPath, relativePath = "", showIgnored = false, limi
|
|
|
107
108
|
if (!isWithinRoot(rootPath, basePath)) return [];
|
|
108
109
|
if (!existsSync(basePath) || !statSync(basePath).isDirectory()) return [];
|
|
109
110
|
const rules = readGitIgnoreRules(rootPath);
|
|
110
|
-
const entries = readdirSync
|
|
111
|
+
const entries = readdirSync(basePath, { withFileTypes: true });
|
|
111
112
|
const out = [];
|
|
112
113
|
entries.sort((a, b) => {
|
|
113
114
|
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
|
|
@@ -145,7 +146,7 @@ function searchTree(rootPath, query, showIgnored = false, maxResults = 200) {
|
|
|
145
146
|
const node = stack.pop();
|
|
146
147
|
let entries = [];
|
|
147
148
|
try {
|
|
148
|
-
entries = readdirSync
|
|
149
|
+
entries = readdirSync(node.absPath, { withFileTypes: true });
|
|
149
150
|
} catch {
|
|
150
151
|
continue;
|
|
151
152
|
}
|
|
@@ -180,7 +181,8 @@ function searchTree(rootPath, query, showIgnored = false, maxResults = 200) {
|
|
|
180
181
|
const DEFAULT_SETTINGS = {
|
|
181
182
|
research: { researchIntensity: "medium", webSearchDepth: "standard", autoSaveSensitivity: "balanced" },
|
|
182
183
|
dataAnalysis: { executionTimeLimit: "standard" },
|
|
183
|
-
wikiAgent: { model: "none", speed: "medium" }
|
|
184
|
+
wikiAgent: { model: "none", speed: "medium" },
|
|
185
|
+
diagram: { reviewProvider: "auto" }
|
|
184
186
|
};
|
|
185
187
|
const CONFIG_DIR = join(homedir(), ".research-copilot");
|
|
186
188
|
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
@@ -247,7 +249,8 @@ function loadSettingsFromConfig() {
|
|
|
247
249
|
return {
|
|
248
250
|
research: { ...DEFAULT_SETTINGS.research, ...config.settings.research },
|
|
249
251
|
dataAnalysis: { ...DEFAULT_SETTINGS.dataAnalysis, ...config.settings.dataAnalysis },
|
|
250
|
-
wikiAgent: { ...DEFAULT_SETTINGS.wikiAgent, ...config.settings.wikiAgent }
|
|
252
|
+
wikiAgent: { ...DEFAULT_SETTINGS.wikiAgent, ...config.settings.wikiAgent },
|
|
253
|
+
diagram: { ...DEFAULT_SETTINGS.diagram, ...config.settings.diagram }
|
|
251
254
|
};
|
|
252
255
|
}
|
|
253
256
|
function hasLlmAuth() {
|
|
@@ -421,7 +424,7 @@ function registerFileHandlers(handle, getCtx) {
|
|
|
421
424
|
const { projectPath } = getCtx();
|
|
422
425
|
if (!projectPath) return [];
|
|
423
426
|
try {
|
|
424
|
-
const entries = readdirSync
|
|
427
|
+
const entries = readdirSync(projectPath);
|
|
425
428
|
const files = [];
|
|
426
429
|
for (const entry of entries) {
|
|
427
430
|
if (entry.startsWith(".")) continue;
|
|
@@ -813,7 +816,7 @@ function registerAuthHandlers(handleRaw) {
|
|
|
813
816
|
if (!creds) return { success: false, error: "Not logged in" };
|
|
814
817
|
try {
|
|
815
818
|
const { refreshOpenAICodexToken } = await import("@mariozechner/pi-ai/oauth");
|
|
816
|
-
const newCreds = await refreshOpenAICodexToken(creds);
|
|
819
|
+
const newCreds = await refreshOpenAICodexToken(creds.refresh);
|
|
817
820
|
saveCodexCredentials(newCreds);
|
|
818
821
|
return { success: true };
|
|
819
822
|
} catch (err) {
|
|
@@ -971,7 +974,7 @@ const PATHS = {
|
|
|
971
974
|
const _warnedReaddirPaths = /* @__PURE__ */ new Set();
|
|
972
975
|
function safeReaddir(dir) {
|
|
973
976
|
try {
|
|
974
|
-
return readdirSync
|
|
977
|
+
return readdirSync(dir);
|
|
975
978
|
} catch (err) {
|
|
976
979
|
const code = err?.code;
|
|
977
980
|
if (code === "EPERM" || code === "ENOENT" || code === "ENOTDIR" || code === "EACCES") {
|
|
@@ -1720,7 +1723,7 @@ function listMemoryFiles(projectPath) {
|
|
|
1720
1723
|
const dir = memoryDir(projectPath);
|
|
1721
1724
|
if (!existsSync(dir)) return [];
|
|
1722
1725
|
try {
|
|
1723
|
-
const files = readdirSync
|
|
1726
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".md")).sort();
|
|
1724
1727
|
const entries = [];
|
|
1725
1728
|
for (const filename of files) {
|
|
1726
1729
|
const entry = readMemoryFile(projectPath, filename);
|
|
@@ -2495,6 +2498,7 @@ Hard rules:
|
|
|
2495
2498
|
- General web facts → brave_web_search or fetch.
|
|
2496
2499
|
- If a required paper PDF/full text cannot be retrieved (paywall/auth/access blocked), do NOT infer missing content. Ask user to provide/upload the file and continue only after file is available.
|
|
2497
2500
|
- Any data analysis / visualization / statistics → data-analyze (do not analyze raw data with read/grep).
|
|
2501
|
+
- Any scientific diagram / schematic / flowchart / architecture / pathway / circuit → generate_diagram (it runs a provider-backed generate → review → edit loop; do not draft SVG or ASCII diagrams by hand). If the scientific-schematics skill is loaded, use its guidance to shape the prompt before calling the tool.
|
|
2498
2502
|
- For reusable methodology, writing scaffolding, or plot/style templates, check if a relevant skill summary is already pre-loaded below. If so, follow it; call load_skill(name) for full procedures when needed. You can also browse the Skills Catalog and load any skill on demand.
|
|
2499
2503
|
- For repository/text inspection, use this order by default:
|
|
2500
2504
|
1) glob/grep to locate relevant files/sections;
|
|
@@ -4583,6 +4587,2401 @@ function createDataAnalyzeTool(ctx) {
|
|
|
4583
4587
|
}
|
|
4584
4588
|
};
|
|
4585
4589
|
}
|
|
4590
|
+
const DEFAULT_HOUSE_PROFILE = {
|
|
4591
|
+
id: "editorial-institutional-v1",
|
|
4592
|
+
themeNarrative: [
|
|
4593
|
+
"Editorial scientific figure with a fixed institutional identity.",
|
|
4594
|
+
"Off-white or warm-light background, graphite text and strokes, restrained contrast, disciplined whitespace.",
|
|
4595
|
+
'No generic "AI" aesthetic (no glow, no particles, no gradient backgrounds).',
|
|
4596
|
+
"No startup-whitepaper look (no flat-design pastels, no rounded-pill overloads).",
|
|
4597
|
+
"Every figure in this system should look like it belongs to the same visual family across papers, slides, and reports."
|
|
4598
|
+
].join(" "),
|
|
4599
|
+
typography: {
|
|
4600
|
+
hierarchy: {
|
|
4601
|
+
sectionLabel: "700 13px uppercase tracked",
|
|
4602
|
+
nodeLabel: "600 12px",
|
|
4603
|
+
edgeAnnotation: "500 10px",
|
|
4604
|
+
footLabel: "400 10px italic"
|
|
4605
|
+
},
|
|
4606
|
+
fontStack: ["Inter", "SF Pro Text", "Helvetica Neue", "Arial", "sans-serif"],
|
|
4607
|
+
voice: "Compact, disciplined, editorial. Hierarchy is fixed. No oversized headings, no micro-labels under 10px."
|
|
4608
|
+
},
|
|
4609
|
+
palette: [
|
|
4610
|
+
{ role: "text", value: "#1F2937", description: "Graphite text and primary strokes." },
|
|
4611
|
+
{ role: "stroke", value: "#1F2937", description: "Same graphite for structural outlines." },
|
|
4612
|
+
{ role: "primaryStructure", value: "#2C5282", description: "Primary structural entities — main nodes, canonical path." },
|
|
4613
|
+
{ role: "secondaryStructure", value: "#718096", description: "Secondary structural entities — supporting nodes, context." },
|
|
4614
|
+
{ role: "contextFill", value: "#F7FAFC", description: "Off-white background fill for grouping containers." },
|
|
4615
|
+
{ role: "resultAccent", value: "#2B6CB0", description: "Deep editorial blue, used sparingly on result/output elements." },
|
|
4616
|
+
{ role: "warningAccent", value: "#C53030", description: "Deep editorial red, used only for warnings or peak-state emphasis." },
|
|
4617
|
+
{ role: "grid", value: "#E2E8F0", description: "Soft graphite grid or axis line." }
|
|
4618
|
+
],
|
|
4619
|
+
geometry: {
|
|
4620
|
+
strokeWidth: { primary: "1.5px", secondary: "1px" },
|
|
4621
|
+
cornerRadius: "8px",
|
|
4622
|
+
arrowheadStyle: "Filled triangle, width 8px, extending 9px along the stroke — never hollow arrows, never emoji.",
|
|
4623
|
+
groupContainerStyle: "Dashed 1px secondary-structure border with contextFill background; no solid coloured group panels.",
|
|
4624
|
+
outerMargin: "5% of the viewBox on every side",
|
|
4625
|
+
minGutter: "32px between adjacent boxes",
|
|
4626
|
+
boxHeightSteps: ["48px", "72px", "96px"]
|
|
4627
|
+
},
|
|
4628
|
+
motifs: [
|
|
4629
|
+
"Labels sit above or to the left of the element they describe; never inside node boxes unless the box is a labelled region.",
|
|
4630
|
+
"Arrow stroke matches its source node colour.",
|
|
4631
|
+
"Consistent gutter rhythm across panels in multi-panel figures.",
|
|
4632
|
+
"Section labels (panel headers, region titles) always use the same section-label typography token.",
|
|
4633
|
+
"No more than two accent colours visible in a single figure."
|
|
4634
|
+
],
|
|
4635
|
+
avoid: [
|
|
4636
|
+
"Gradients, glow, blur, or filter effects of any kind",
|
|
4637
|
+
"Rounded-pill buttons, soft-shadow cards, or other app-UI tropes",
|
|
4638
|
+
"Neon or highly saturated colours",
|
|
4639
|
+
"Oversized emoji, cartoon icons, or mascot-style illustrations",
|
|
4640
|
+
"Display fonts, script fonts, or hand-lettering styles"
|
|
4641
|
+
]
|
|
4642
|
+
};
|
|
4643
|
+
function renderPalette(tokens) {
|
|
4644
|
+
const lines = [
|
|
4645
|
+
"Semantic role → colour mapping (house palette). Use these tokens as defaults; a user prompt may override any specific role."
|
|
4646
|
+
];
|
|
4647
|
+
for (const t of tokens) {
|
|
4648
|
+
lines.push(`- ${t.role}: ${t.value} — ${t.description}`);
|
|
4649
|
+
}
|
|
4650
|
+
lines.push("All category distinctions must remain readable in grayscale AND under colour-vision deficiency; fall back to an accessible substitute only if a house palette choice fails that test.");
|
|
4651
|
+
return lines.join("\n");
|
|
4652
|
+
}
|
|
4653
|
+
function renderGeometry(g) {
|
|
4654
|
+
return [
|
|
4655
|
+
"Geometry language (house tokens — reuse across every diagram in the system):",
|
|
4656
|
+
`- Stroke widths: primary ${g.strokeWidth.primary}, secondary ${g.strokeWidth.secondary}.`,
|
|
4657
|
+
`- Corner radius: ${g.cornerRadius} for all rounded rectangles.`,
|
|
4658
|
+
`- Arrowheads: ${g.arrowheadStyle}`,
|
|
4659
|
+
`- Group container: ${g.groupContainerStyle}`,
|
|
4660
|
+
`- Outer margin: ${g.outerMargin}. Min gutter between elements: ${g.minGutter}.`,
|
|
4661
|
+
`- Node box heights: choose one of ${g.boxHeightSteps.join(" / ")} and keep it consistent within a panel.`
|
|
4662
|
+
].join("\n");
|
|
4663
|
+
}
|
|
4664
|
+
function renderTypography(t) {
|
|
4665
|
+
return [
|
|
4666
|
+
`Typographic voice: ${t.voice}`,
|
|
4667
|
+
`Font stack (first installed wins): ${t.fontStack.join(", ")}.`,
|
|
4668
|
+
`Hierarchy tokens (use these exact sizes/weights):`,
|
|
4669
|
+
`- Section label: ${t.hierarchy.sectionLabel}`,
|
|
4670
|
+
`- Node label: ${t.hierarchy.nodeLabel}`,
|
|
4671
|
+
`- Edge annotation: ${t.hierarchy.edgeAnnotation}`,
|
|
4672
|
+
`- Foot label: ${t.hierarchy.footLabel}`
|
|
4673
|
+
].join("\n");
|
|
4674
|
+
}
|
|
4675
|
+
function renderMotifs(motifs) {
|
|
4676
|
+
if (motifs.length === 0) return "";
|
|
4677
|
+
return [
|
|
4678
|
+
'Recurring motifs (these are the "signature" details reviewers should see every time):',
|
|
4679
|
+
...motifs.map((m) => `- ${m}`)
|
|
4680
|
+
].join("\n");
|
|
4681
|
+
}
|
|
4682
|
+
function renderProfile(profile = DEFAULT_HOUSE_PROFILE) {
|
|
4683
|
+
return {
|
|
4684
|
+
theme: profile.themeNarrative,
|
|
4685
|
+
typography: renderTypography(profile.typography),
|
|
4686
|
+
palette: renderPalette(profile.palette),
|
|
4687
|
+
geometry: renderGeometry(profile.geometry),
|
|
4688
|
+
motifs: renderMotifs(profile.motifs),
|
|
4689
|
+
avoid: profile.avoid,
|
|
4690
|
+
summaryForReviewer: [
|
|
4691
|
+
`House profile: ${profile.id}.`,
|
|
4692
|
+
`Theme: ${profile.themeNarrative}`,
|
|
4693
|
+
`Palette roles: ${profile.palette.map((p) => `${p.role}=${p.value}`).join(", ")}.`,
|
|
4694
|
+
`Geometry: stroke ${profile.geometry.strokeWidth.primary}/${profile.geometry.strokeWidth.secondary}, corner ${profile.geometry.cornerRadius}, gutter ${profile.geometry.minGutter}.`,
|
|
4695
|
+
`Typography: ${profile.typography.voice} Stack: ${profile.typography.fontStack[0]}.`
|
|
4696
|
+
].join(" ")
|
|
4697
|
+
};
|
|
4698
|
+
}
|
|
4699
|
+
const DESIGN_PRINCIPLES = `【DESIGN PRINCIPLES】 Apply all nine. These are non-negotiable:
|
|
4700
|
+
|
|
4701
|
+
1. SINGLE PRIMARY AXIS — Pick one dominant spatial axis (left-to-right
|
|
4702
|
+
flow, top-to-bottom flow, or a time axis) and make every other
|
|
4703
|
+
element serve it. Never mix two competing primary axes.
|
|
4704
|
+
|
|
4705
|
+
2. NO NESTED SOLID-BORDERED BOXES — Group elements using:
|
|
4706
|
+
• a light background tint (fill only, no stroke)
|
|
4707
|
+
• a dashed border (for "optional / future / sketch" categories)
|
|
4708
|
+
• whitespace and alignment
|
|
4709
|
+
• a small header label positioned just above the group
|
|
4710
|
+
NEVER place a solid-bordered rect inside another solid-bordered rect.
|
|
4711
|
+
Small backfilled pills (fill only, no stroke) INSIDE a bordered rect
|
|
4712
|
+
are fine — one level of visual nesting, not two.
|
|
4713
|
+
|
|
4714
|
+
3. TYPOGRAPHY BY CATEGORY — Match font size, weight, and style to the
|
|
4715
|
+
element's CATEGORY, not to its perceived "importance". Consistent
|
|
4716
|
+
scales:
|
|
4717
|
+
• section header: 14 pt bold, Title Case ("Monitoring Pipeline")
|
|
4718
|
+
NEVER ALL CAPS ("MONITORING PIPELINE") — ALL
|
|
4719
|
+
CAPS is reserved for the red "!" error prefix
|
|
4720
|
+
only. Section headers in caps look like
|
|
4721
|
+
PowerPoint slides, not publication figures.
|
|
4722
|
+
• node title: 12-13 pt semibold or bold
|
|
4723
|
+
• sub-op label: 10 pt regular
|
|
4724
|
+
• caption: 9-10 pt italic
|
|
4725
|
+
• footnote: 9 pt regular, muted grey
|
|
4726
|
+
Never vary font size within the same category.
|
|
4727
|
+
|
|
4728
|
+
4. SEMANTIC COLOUR + COMPACT LEGEND — Every colour must mean something.
|
|
4729
|
+
If you introduce a colour, explain it in a small (≤ 15% of figure
|
|
4730
|
+
area) legend placed in a quiet corner. Typical semantic roles:
|
|
4731
|
+
• navy blue = current / in-scope / active
|
|
4732
|
+
• forest green = output / result
|
|
4733
|
+
• dashed muted amber = planned / future / optional
|
|
4734
|
+
• saturated red = error / problem / rejection ONLY
|
|
4735
|
+
|
|
4736
|
+
5. REAL CONTENT, NOT PLACEHOLDERS — Boxes must contain real labels,
|
|
4737
|
+
real parameters, real units. "Filter" alone is weak; "Filter —
|
|
4738
|
+
threshold 5°, deadband, clipping" tells the reader what the box
|
|
4739
|
+
actually does. Prefer specifics (100 Hz, int16, 2.4 MB/s) over
|
|
4740
|
+
generic descriptors ("fast", "large", "Module A").
|
|
4741
|
+
|
|
4742
|
+
6. ICONS REPLACE TEXT, NEVER DECORATE — Only draw an icon if it
|
|
4743
|
+
replaces a short phrase more clearly than text would. Wavy line =
|
|
4744
|
+
signal. Monitor glyph = display. Alert bell = notification.
|
|
4745
|
+
Never add icons for decoration.
|
|
4746
|
+
|
|
4747
|
+
7. ARROWS ARE ANCHORED, LABELS RIDE THE LINE, MULTI-BEND = ONE POLYLINE
|
|
4748
|
+
• Every arrow must start and end precisely on a box edge (use
|
|
4749
|
+
rect.x, rect.y, rect.x+width, or rect.y+height).
|
|
4750
|
+
• Edge labels sit ON the arrow, not floating near it. Use a small
|
|
4751
|
+
opaque backfill rect behind the label so the line is masked.
|
|
4752
|
+
• L-shape, Z-shape, or any multi-bend arrow MUST be ONE
|
|
4753
|
+
<polyline points="x1,y1 x2,y2 x3,y3 ..."> element with all
|
|
4754
|
+
vertices in a single declaration. NEVER stitch together
|
|
4755
|
+
multiple <line> elements to approximate an L-shape — they
|
|
4756
|
+
render as visually disconnected segments.
|
|
4757
|
+
|
|
4758
|
+
8. RED IS RESERVED FOR PROBLEMS — Saturated red (≈#C53030) appears
|
|
4759
|
+
only on error / loss / rejection callouts. Muted reds (rust,
|
|
4760
|
+
terracotta) may be used for categorical "sketch / proposal"
|
|
4761
|
+
borders, but high-contrast red must stay precious.
|
|
4762
|
+
|
|
4763
|
+
9. COMPACT LAYOUT — Let content density drive spacing, not the
|
|
4764
|
+
viewBox. Typical gaps:
|
|
4765
|
+
• adjacent elements in the same group: 10–30 px
|
|
4766
|
+
• distinct sections (e.g. "Planned" vs current work): 40–60 px
|
|
4767
|
+
• group backdrop padding (inside tint): 15–25 px
|
|
4768
|
+
NEVER exceed ~80 px of uninterrupted empty space unless the gap
|
|
4769
|
+
is semantically meaningful. If the user-provided viewBox is
|
|
4770
|
+
larger than your content needs, SHRINK THE VIEWBOX — do NOT
|
|
4771
|
+
stretch or spread content to fill the canvas. "Below X" means a
|
|
4772
|
+
20–30 px gap below X, NOT "somewhere at the bottom of the
|
|
4773
|
+
canvas". A figure with visible gutters of empty space looks
|
|
4774
|
+
unfinished regardless of how clean the content itself is.`;
|
|
4775
|
+
const POSITIVE_EXAMPLE_SVG = `<svg viewBox="0 0 600 440" xmlns="http://www.w3.org/2000/svg">
|
|
4776
|
+
<defs>
|
|
4777
|
+
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" orient="auto">
|
|
4778
|
+
<path d="M0,0 L10,5 L0,10 Z" fill="#1F2937"/>
|
|
4779
|
+
</marker>
|
|
4780
|
+
<marker id="arrowBlue" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" orient="auto">
|
|
4781
|
+
<path d="M0,0 L10,5 L0,10 Z" fill="#2C5282"/>
|
|
4782
|
+
</marker>
|
|
4783
|
+
<marker id="arrowAmber" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" orient="auto">
|
|
4784
|
+
<path d="M0,0 L10,5 L0,10 Z" fill="#9C5C00"/>
|
|
4785
|
+
</marker>
|
|
4786
|
+
<marker id="arrowRed" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" orient="auto">
|
|
4787
|
+
<path d="M0,0 L10,5 L0,10 Z" fill="#C53030"/>
|
|
4788
|
+
</marker>
|
|
4789
|
+
</defs>
|
|
4790
|
+
|
|
4791
|
+
<rect x="0" y="0" width="600" height="440" fill="#F7FAFC"/>
|
|
4792
|
+
|
|
4793
|
+
<!-- Legend: compact, top-right. Four categories. -->
|
|
4794
|
+
<rect x="480" y="12" width="110" height="85" rx="4" ry="4" fill="#FFFFFF" stroke="#CBD5E0" stroke-width="1"/>
|
|
4795
|
+
<rect x="492" y="24" width="16" height="10" rx="2" ry="2" fill="#FFFFFF" stroke="#2C5282" stroke-width="1.5"/>
|
|
4796
|
+
<text x="516" y="33" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">current stage</text>
|
|
4797
|
+
<rect x="492" y="42" width="16" height="10" rx="2" ry="2" fill="#FFFFFF" stroke="#2F855A" stroke-width="1.5"/>
|
|
4798
|
+
<text x="516" y="51" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">output</text>
|
|
4799
|
+
<rect x="492" y="60" width="16" height="10" rx="2" ry="2" fill="none" stroke="#9C5C00" stroke-width="1.2" stroke-dasharray="3 2"/>
|
|
4800
|
+
<text x="516" y="69" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">planned</text>
|
|
4801
|
+
<text x="494" y="84" font-family="Inter, sans-serif" font-size="11" font-weight="700" fill="#C53030">!</text>
|
|
4802
|
+
<text x="516" y="84" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">error / loss</text>
|
|
4803
|
+
|
|
4804
|
+
<!-- Section header: Title Case, bold. Above the group. -->
|
|
4805
|
+
<text x="15" y="35" font-family="Inter, sans-serif" font-size="14" font-weight="700" fill="#1F2937">Monitoring Pipeline</text>
|
|
4806
|
+
|
|
4807
|
+
<!-- Pipeline backdrop: light tint, no border — groups Parse/Filter/Aggregate. -->
|
|
4808
|
+
<rect x="155" y="40" width="325" height="170" rx="10" ry="10" fill="#EDF2F7" stroke="none"/>
|
|
4809
|
+
|
|
4810
|
+
<!-- Input: Sensor Stream. Box widened to 130 so all four text lines fit. -->
|
|
4811
|
+
<rect x="15" y="55" width="130" height="140" rx="6" ry="6" fill="#FFFFFF" stroke="#4A5568" stroke-width="1.5"/>
|
|
4812
|
+
<path d="M22,85 q3,-5 6,0 t6,0 t6,0" fill="none" stroke="#2C5282" stroke-width="1.7" stroke-linecap="round"/>
|
|
4813
|
+
<text x="45" y="89" font-family="Inter, sans-serif" font-size="11" font-weight="600" fill="#1F2937">Sensor Stream</text>
|
|
4814
|
+
<text x="25" y="112" font-family="Inter, sans-serif" font-size="9" font-style="italic" fill="#4A5568">raw telemetry • 100 Hz</text>
|
|
4815
|
+
<text x="25" y="132" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">12 channels • int16</text>
|
|
4816
|
+
<text x="25" y="152" font-family="Inter, sans-serif" font-size="9" fill="#4A5568">throughput: 2.4 MB/s</text>
|
|
4817
|
+
|
|
4818
|
+
<line x1="145" y1="125" x2="160" y2="125" stroke="#1F2937" stroke-width="1.5" marker-end="url(#arrow)"/>
|
|
4819
|
+
|
|
4820
|
+
<!-- Parse: title + divider + three sub-op pills (backfill only, no stroke). -->
|
|
4821
|
+
<rect x="160" y="55" width="95" height="140" rx="6" ry="6" fill="#FFFFFF" stroke="#2C5282" stroke-width="1.5"/>
|
|
4822
|
+
<text x="207" y="78" font-family="Inter, sans-serif" font-size="12" font-weight="700" fill="#1F2937" text-anchor="middle">Parse</text>
|
|
4823
|
+
<line x1="170" y1="85" x2="245" y2="85" stroke="#2C5282" stroke-width="0.5" opacity="0.5"/>
|
|
4824
|
+
<rect x="170" y="93" width="75" height="18" rx="3" ry="3" fill="#E6F0FA"/>
|
|
4825
|
+
<text x="175" y="105" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">schema v2</text>
|
|
4826
|
+
<rect x="170" y="117" width="75" height="18" rx="3" ry="3" fill="#E6F0FA"/>
|
|
4827
|
+
<text x="175" y="129" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">decode int16</text>
|
|
4828
|
+
<rect x="170" y="141" width="75" height="18" rx="3" ry="3" fill="#E6F0FA"/>
|
|
4829
|
+
<text x="175" y="153" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">timestamp</text>
|
|
4830
|
+
|
|
4831
|
+
<line x1="255" y1="125" x2="270" y2="125" stroke="#2C5282" stroke-width="1.5" marker-end="url(#arrowBlue)"/>
|
|
4832
|
+
|
|
4833
|
+
<!-- Filter -->
|
|
4834
|
+
<rect x="270" y="55" width="95" height="140" rx="6" ry="6" fill="#FFFFFF" stroke="#2C5282" stroke-width="1.5"/>
|
|
4835
|
+
<text x="317" y="78" font-family="Inter, sans-serif" font-size="12" font-weight="700" fill="#1F2937" text-anchor="middle">Filter</text>
|
|
4836
|
+
<line x1="280" y1="85" x2="355" y2="85" stroke="#2C5282" stroke-width="0.5" opacity="0.5"/>
|
|
4837
|
+
<rect x="280" y="93" width="75" height="18" rx="3" ry="3" fill="#E6F0FA"/>
|
|
4838
|
+
<text x="285" y="105" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">threshold 5°</text>
|
|
4839
|
+
<rect x="280" y="117" width="75" height="18" rx="3" ry="3" fill="#E6F0FA"/>
|
|
4840
|
+
<text x="285" y="129" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">deadband</text>
|
|
4841
|
+
<rect x="280" y="141" width="75" height="18" rx="3" ry="3" fill="#E6F0FA"/>
|
|
4842
|
+
<text x="285" y="153" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">clipping</text>
|
|
4843
|
+
|
|
4844
|
+
<!-- RED error callout — saturated red reserved for problems only. -->
|
|
4845
|
+
<line x1="317" y1="195" x2="317" y2="213" stroke="#C53030" stroke-width="1.2" stroke-dasharray="3 2" marker-end="url(#arrowRed)"/>
|
|
4846
|
+
<rect x="275" y="215" width="85" height="22" rx="3" ry="3" fill="#FED7D7" stroke="#C53030" stroke-width="1"/>
|
|
4847
|
+
<text x="283" y="231" font-family="Inter, sans-serif" font-size="11" font-weight="700" fill="#C53030">!</text>
|
|
4848
|
+
<text x="295" y="231" font-family="Inter, sans-serif" font-size="10" font-weight="500" fill="#C53030">invalid: 3%</text>
|
|
4849
|
+
|
|
4850
|
+
<line x1="365" y1="125" x2="380" y2="125" stroke="#2C5282" stroke-width="1.5" marker-end="url(#arrowBlue)"/>
|
|
4851
|
+
|
|
4852
|
+
<!-- Aggregate -->
|
|
4853
|
+
<rect x="380" y="55" width="95" height="140" rx="6" ry="6" fill="#FFFFFF" stroke="#2C5282" stroke-width="1.5"/>
|
|
4854
|
+
<text x="427" y="78" font-family="Inter, sans-serif" font-size="12" font-weight="700" fill="#1F2937" text-anchor="middle">Aggregate</text>
|
|
4855
|
+
<line x1="390" y1="85" x2="465" y2="85" stroke="#2C5282" stroke-width="0.5" opacity="0.5"/>
|
|
4856
|
+
<rect x="390" y="93" width="75" height="18" rx="3" ry="3" fill="#E6F0FA"/>
|
|
4857
|
+
<text x="395" y="105" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">1-min window</text>
|
|
4858
|
+
<rect x="390" y="117" width="75" height="18" rx="3" ry="3" fill="#E6F0FA"/>
|
|
4859
|
+
<text x="395" y="129" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">mean/max/p99</text>
|
|
4860
|
+
<rect x="390" y="141" width="75" height="18" rx="3" ry="3" fill="#E6F0FA"/>
|
|
4861
|
+
<text x="395" y="153" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">change rate</text>
|
|
4862
|
+
|
|
4863
|
+
<!-- Two arrows diverge from Aggregate bottom, offset horizontally so
|
|
4864
|
+
they don't leave the same pixel. Amber L-shape drops BELOW the
|
|
4865
|
+
red error callout (y=245, callout ends at y=237) to avoid overlap. -->
|
|
4866
|
+
<line x1="447" y1="195" x2="447" y2="252" stroke="#1F2937" stroke-width="1.5" marker-end="url(#arrow)"/>
|
|
4867
|
+
<polyline points="415,195 415,245 160,245 160,252" fill="none" stroke="#9C5C00" stroke-width="1.2" stroke-dasharray="4 3" marker-end="url(#arrowAmber)"/>
|
|
4868
|
+
|
|
4869
|
+
<!-- Planned extension: Title Case header above the dashed-amber box. -->
|
|
4870
|
+
<text x="20" y="250" font-family="Inter, sans-serif" font-size="12" font-weight="700" fill="#9C5C00">Planned</text>
|
|
4871
|
+
<rect x="15" y="255" width="290" height="100" rx="6" ry="6" fill="none" stroke="#9C5C00" stroke-width="1.5" stroke-dasharray="6 5"/>
|
|
4872
|
+
<text x="160" y="277" font-family="Inter, sans-serif" font-size="12" font-weight="600" fill="#1F2937" text-anchor="middle">Anomaly Detection</text>
|
|
4873
|
+
<rect x="30" y="290" width="125" height="20" rx="3" ry="3" fill="#FEF3E6"/>
|
|
4874
|
+
<text x="37" y="304" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">Autoencoder baseline</text>
|
|
4875
|
+
<rect x="165" y="290" width="125" height="20" rx="3" ry="3" fill="#FEF3E6"/>
|
|
4876
|
+
<text x="172" y="304" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">Adaptive threshold</text>
|
|
4877
|
+
<text x="30" y="335" font-family="Inter, sans-serif" font-size="9" font-style="italic" fill="#4A5568">Q3 rollout • replaces static threshold filter</text>
|
|
4878
|
+
|
|
4879
|
+
<!-- Dashboard: different colour family (green) signals OUTPUT category. -->
|
|
4880
|
+
<rect x="310" y="255" width="275" height="120" rx="6" ry="6" fill="#FFFFFF" stroke="#2F855A" stroke-width="1.5"/>
|
|
4881
|
+
<rect x="325" y="272" width="22" height="15" rx="2" ry="2" fill="none" stroke="#2F855A" stroke-width="1.3"/>
|
|
4882
|
+
<line x1="331" y1="291" x2="341" y2="291" stroke="#2F855A" stroke-width="1.3"/>
|
|
4883
|
+
<text x="355" y="285" font-family="Inter, sans-serif" font-size="13" font-weight="700" fill="#1F2937">Dashboard</text>
|
|
4884
|
+
<line x1="325" y1="299" x2="570" y2="299" stroke="#2F855A" stroke-width="0.5" opacity="0.5"/>
|
|
4885
|
+
<text x="325" y="316" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">• live charts (5-sec refresh)</text>
|
|
4886
|
+
<text x="325" y="335" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">• threshold alerts → email / SMS</text>
|
|
4887
|
+
<text x="325" y="354" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">• historical query via Grafana</text>
|
|
4888
|
+
</svg>`;
|
|
4889
|
+
const POSITIVE_EXAMPLE_BLOCK = `【POSITIVE EXAMPLE】 Study and emulate the design language of this
|
|
4890
|
+
reference SVG. It is a yardstick for "what a publication-grade systems
|
|
4891
|
+
diagram looks like" — NOT a template to copy literally.
|
|
4892
|
+
|
|
4893
|
+
${POSITIVE_EXAMPLE_SVG}
|
|
4894
|
+
|
|
4895
|
+
NOTES ON THE REFERENCE (each illustrates a specific principle):
|
|
4896
|
+
• Viewbox 600:440 ≈ 1.36:1 — single-column-friendly, not strongly
|
|
4897
|
+
landscape. Figures for a two-column paper template should usually
|
|
4898
|
+
stay near 1:1, 10:8, or 12:10. (Principle 1)
|
|
4899
|
+
• The three pipeline stages are grouped by a tinted backdrop
|
|
4900
|
+
(fill="#EDF2F7", stroke="none"), NOT by nesting another bordered
|
|
4901
|
+
rect. (Principle 2)
|
|
4902
|
+
• Each stage has title + a subtle divider line + three sub-op pills
|
|
4903
|
+
(fill only, no stroke). Pills INSIDE a bordered rect are fine
|
|
4904
|
+
because pills are backfills, not nested rects. (Principle 2)
|
|
4905
|
+
• Section header "Monitoring Pipeline" is Title Case, not ALL CAPS.
|
|
4906
|
+
The only ALL CAPS in the figure is the red "!" error prefix,
|
|
4907
|
+
where the caps-lock is deliberately emphatic. (Principles 3, 8)
|
|
4908
|
+
• Dashboard uses a DIFFERENT colour family (green, #2F855A) from
|
|
4909
|
+
pipeline stages (navy, #2C5282). The legend explains the
|
|
4910
|
+
categorical distinction. (Principle 4)
|
|
4911
|
+
• Each box contains real, specific content: "threshold 5°",
|
|
4912
|
+
"decode int16", "1-min window", "change rate", "2.4 MB/s". No
|
|
4913
|
+
generic "Module A / Stage 1" placeholders. (Principle 5)
|
|
4914
|
+
• The wavy-signal icon (~~) replaces the phrase "signal" for Sensor
|
|
4915
|
+
Stream; the monitor glyph replaces "display" for Dashboard.
|
|
4916
|
+
Nothing else is iconified. (Principle 6)
|
|
4917
|
+
• Every arrow terminates on a box edge. The "100 Hz" style edge
|
|
4918
|
+
labels (not present here, but the technique is in GEOMETRIC
|
|
4919
|
+
DISCIPLINE) sit ON the arrow with a small opaque backfill rect.
|
|
4920
|
+
(Principle 7)
|
|
4921
|
+
• Saturated red (#C53030) appears ONLY in the "! invalid: 3%"
|
|
4922
|
+
error callout. Muted amber is used for the planned category.
|
|
4923
|
+
(Principle 8)
|
|
4924
|
+
• Two arrows diverge from Aggregate's bottom edge at different x
|
|
4925
|
+
(447 and 415) so they do not leave the same pixel point. The
|
|
4926
|
+
amber L-shape polyline routes BELOW (y=245) the red error callout
|
|
4927
|
+
(which ends at y=237) rather than through it — no arrows cross
|
|
4928
|
+
unrelated boxes. (Principle 7 + GEOMETRIC DISCIPLINE)`;
|
|
4929
|
+
const GEOMETRIC_DISCIPLINE = `【GEOMETRIC DISCIPLINE】 Apply these pixel-level rules for all SVG:
|
|
4930
|
+
|
|
4931
|
+
TEXT CENTERING INSIDE A RECT
|
|
4932
|
+
• Vertical center: text.y = rect.y + rect.height/2 + font_size*0.35
|
|
4933
|
+
• Horizontal center: set text-anchor="middle" and
|
|
4934
|
+
text.x = rect.x + rect.width/2
|
|
4935
|
+
• Two-line text inside a rect:
|
|
4936
|
+
line 1 baseline y = rect.cy - 4
|
|
4937
|
+
line 2 baseline y = rect.cy + font_size + 2
|
|
4938
|
+
|
|
4939
|
+
TEXT POSITIONED ABOVE A RECT (as a title)
|
|
4940
|
+
• text.y must be ≤ rect.y - 4 (glyphs extend upward from baseline)
|
|
4941
|
+
• NEVER use text.y = rect.y + 3 style positioning — the title will
|
|
4942
|
+
cross through the rect's top stroke.
|
|
4943
|
+
|
|
4944
|
+
TEXT WIDTH ESTIMATION (Inter font family, approximate):
|
|
4945
|
+
• regular: char_count × font_size × 0.55
|
|
4946
|
+
• semibold/bold: char_count × font_size × 0.58
|
|
4947
|
+
• italic: char_count × font_size × 0.52
|
|
4948
|
+
A rect that contains text must satisfy:
|
|
4949
|
+
rect.width ≥ text_width_estimate + 16 (8 px padding each side)
|
|
4950
|
+
If the text overflows: SHORTEN the text OR SHRINK the font —
|
|
4951
|
+
NEVER let glyphs bleed past the rect's right stroke.
|
|
4952
|
+
|
|
4953
|
+
ARROWS — SOURCE AND DESTINATION ANCHORING
|
|
4954
|
+
• Arrow endpoint (x2, y2) must fall precisely on the destination
|
|
4955
|
+
rect's edge (use rect.x, rect.y, rect.x+width, or rect.y+height).
|
|
4956
|
+
• marker-end with orient="auto" and refX near the marker's tip
|
|
4957
|
+
places the arrow-head tip AT the line endpoint.
|
|
4958
|
+
|
|
4959
|
+
ARROWS CROSSING OTHER BOXES — three priorities, first wins:
|
|
4960
|
+
1. ROUTE AROUND. Shift the arrow's y or x so it never overlaps an
|
|
4961
|
+
unrelated box. This is always preferred.
|
|
4962
|
+
2. USE AN L-SHAPED POLYLINE. A right-angle bend avoids the
|
|
4963
|
+
obstructing box cleanly. Diagonal arrows that clip through
|
|
4964
|
+
other boxes are forbidden.
|
|
4965
|
+
3. Z-ORDER. If an arrow MUST cross a box, declare the arrow's
|
|
4966
|
+
<line> or <polyline> BEFORE the box in SVG source, so the
|
|
4967
|
+
box paints over the arrow (arrow renders behind the box).
|
|
4968
|
+
This is the last-resort fix.
|
|
4969
|
+
|
|
4970
|
+
MULTI-SEGMENT ARROWS (L-shape, Z-shape, any multi-bend)
|
|
4971
|
+
An arrow with one or more bends MUST be a SINGLE <polyline>
|
|
4972
|
+
element with ALL vertices in one declaration:
|
|
4973
|
+
CORRECT:
|
|
4974
|
+
<polyline points="100,50 100,80 250,80"
|
|
4975
|
+
fill="none" stroke="#1F2937" stroke-width="1.5"
|
|
4976
|
+
marker-end="url(#arrow)"/>
|
|
4977
|
+
WRONG (produces visually disconnected segments):
|
|
4978
|
+
<line x1="100" y1="50" x2="100" y2="80" .../>
|
|
4979
|
+
<line x1="100" y1="80" x2="250" y2="80" .../>
|
|
4980
|
+
Even when the endpoints line up arithmetically, SVG renders
|
|
4981
|
+
consecutive <line> elements as independent strokes with no
|
|
4982
|
+
shared corner — the result is a broken-looking path. Always
|
|
4983
|
+
use <polyline points="..."> for multi-bend arrows.
|
|
4984
|
+
|
|
4985
|
+
MULTIPLE ARROWS FROM ONE BOX
|
|
4986
|
+
When a single box emits more than one outgoing arrow, offset the
|
|
4987
|
+
starting x (or y) by 20–30 px so the arrows diverge at the box's
|
|
4988
|
+
edge. Two arrows leaving the exact same pixel and then splaying
|
|
4989
|
+
outward looks chaotic.
|
|
4990
|
+
|
|
4991
|
+
EDGE LABELS ON ARROWS
|
|
4992
|
+
Draw a small opaque rect (fill = page background color) BEFORE the
|
|
4993
|
+
text, covering the arrow line at the label's y-midpoint. Then draw
|
|
4994
|
+
the text on top. This masks the line behind the label so the
|
|
4995
|
+
label is readable without floating off the arrow.`;
|
|
4996
|
+
const COMMON_MISTAKES = `【COMMON MISTAKES — DO NOT】
|
|
4997
|
+
|
|
4998
|
+
✗ text.y = rect.y + 3 or text.y = rect.y + font_size*0.2 inside a
|
|
4999
|
+
rect → glyphs cross the rect's top stroke, creating a
|
|
5000
|
+
strikethrough effect. Use the centering formula from GEOMETRIC
|
|
5001
|
+
DISCIPLINE.
|
|
5002
|
+
|
|
5003
|
+
✗ rect.width narrower than the text's estimated width → glyphs
|
|
5004
|
+
spill past the rect's right stroke. Either widen the rect or
|
|
5005
|
+
shorten / shrink the text.
|
|
5006
|
+
|
|
5007
|
+
✗ solid-bordered rect inside another solid-bordered rect → visual
|
|
5008
|
+
noise, the "box in box in box" pathology. Use tinted backdrops or
|
|
5009
|
+
dashed borders for grouping instead.
|
|
5010
|
+
|
|
5011
|
+
✗ a label floating 10–20 px away from its arrow → the reader cannot
|
|
5012
|
+
tell which arrow the label annotates. Place the label ON the
|
|
5013
|
+
arrow with a small opaque bg rect behind the text.
|
|
5014
|
+
|
|
5015
|
+
✗ diagonal arrows that clip through unrelated boxes → use
|
|
5016
|
+
L-shaped polylines (right-angle bends) to route around instead.
|
|
5017
|
+
|
|
5018
|
+
✗ two arrows leaving the same pixel of a box and then diverging →
|
|
5019
|
+
offset the starting x or y by 20–30 px so the arrows separate
|
|
5020
|
+
cleanly at the box edge.
|
|
5021
|
+
|
|
5022
|
+
✗ saturated red used for a non-error element (a section header in
|
|
5023
|
+
red, a decorative border in red) → dilutes the semantic weight
|
|
5024
|
+
of red. Reserve saturated red for errors, losses, rejections.
|
|
5025
|
+
|
|
5026
|
+
✗ ALL CAPS for regular section headers → dilutes emphasis. Title
|
|
5027
|
+
Case for normal headers; ALL CAPS only where semantic emphasis
|
|
5028
|
+
is intended (red error tags).
|
|
5029
|
+
|
|
5030
|
+
✗ generic placeholder text: "stage 1", "Component A", "Module" →
|
|
5031
|
+
makes the figure feel empty. Use real domain content ("Filter —
|
|
5032
|
+
threshold 5°", "100 Hz samples", "int16 decode").
|
|
5033
|
+
|
|
5034
|
+
✗ figures that are strongly landscape (ratio > 1.6:1) for a paper
|
|
5035
|
+
where the template is two-column → the figure will either span
|
|
5036
|
+
both columns (wasteful) or be shrunk into unreadability. Prefer
|
|
5037
|
+
near-square or gently landscape (1:1 to 12:8) for single-column
|
|
5038
|
+
placement.
|
|
5039
|
+
|
|
5040
|
+
✗ large uninterrupted empty regions (> 80 px in any direction) in
|
|
5041
|
+
the viewBox. Whether you shrink the viewBox or tighten the
|
|
5042
|
+
spacing, a figure with visible gutters looks unfinished. If the
|
|
5043
|
+
request says "Planned section below Layer 1", that's a 20–30 px
|
|
5044
|
+
gap below Layer 1, NOT an empty band in the lower half of the
|
|
5045
|
+
canvas.
|
|
5046
|
+
|
|
5047
|
+
✗ multi-bend arrow drawn as several <line> elements chained end-
|
|
5048
|
+
to-end → renders as visually disconnected segments. Use a single
|
|
5049
|
+
<polyline points="x1,y1 x2,y2 x3,y3 ..."> element instead.
|
|
5050
|
+
|
|
5051
|
+
✗ edge label placed on an arrow whose path crosses through a
|
|
5052
|
+
tinted group backdrop → the backdrop, drawn last, paints over
|
|
5053
|
+
the label. Either route the label to lie entirely outside any
|
|
5054
|
+
backdrop, or draw the label AFTER the backdrop in SVG source.`;
|
|
5055
|
+
const DIAGRAM_TYPE_DETAILS = {
|
|
5056
|
+
flowchart: `Standard shapes: rectangle = process, diamond = decision, rounded rectangle = start/end. Arrows must point unambiguously from source to target. Every numeric count (n=…) and every branch label (yes/no, conditions) is MANDATORY when mentioned in the subject. Prefer vertical top-to-bottom flow unless specified.`,
|
|
5057
|
+
architecture: `Layered or blocked composition; each component is a labelled box. Show data flow direction with arrows; annotate protocols or interfaces on edges. Group related components (shared background tint or dashed boundary). Consistent box sizes within a layer.`,
|
|
5058
|
+
pathway: `Every molecule/gene/protein labelled with its exact symbol. Arrows distinguish activation (→) from inhibition (⊣ or ⊥). Preserve the exact order and directionality specified. Oval = molecule, rectangle = complex, rounded = process. Include cellular compartments (membrane, nucleus) when relevant.`,
|
|
5059
|
+
circuit: `Standard electronic symbols (IEEE/IEC): resistor zigzag, capacitor parallel lines, ground triangle, op-amp triangle with + and − inputs labelled. Component values carry units ("1kΩ", "10µF", "5V"). Wires: dot at crossing = connection, no dot = jump-over. Ground and supply rails explicitly marked.`,
|
|
5060
|
+
network: `Nodes are labelled entities; edges are relationships. Consistent node sizes unless hierarchy is intentional. For neural networks, label layer types and dimensions ("Dense 128", "Conv 3×3"). For hierarchies, root at top.`,
|
|
5061
|
+
// Previously this said "light graphical flourishes (icons, gradients)
|
|
5062
|
+
// are acceptable if they serve comprehension". That conflicted with
|
|
5063
|
+
// the global no-gradients rule. Conceptual diagrams now follow the
|
|
5064
|
+
// same geometry/colour discipline as the rest — identity is more
|
|
5065
|
+
// valuable than flourish.
|
|
5066
|
+
conceptual: `Focus on clarity. Group related ideas; use the house palette roles to communicate categories. Geometric shapes only — no decorative icons, no photographic inserts, no illustrations.`
|
|
5067
|
+
};
|
|
5068
|
+
const COMPOSITION_HINTS = {
|
|
5069
|
+
flowchart: "Top-to-bottom vertical flow unless the subject specifies otherwise. Generous gutters between stages.",
|
|
5070
|
+
architecture: "Left-to-right or layered horizontal composition. Align boxes to a common grid.",
|
|
5071
|
+
pathway: "Directional cascade — usually top-to-bottom for signalling, left-to-right for metabolic. Group molecules by compartment.",
|
|
5072
|
+
circuit: "Standard schematic layout: input on the left, output on the right, ground at the bottom.",
|
|
5073
|
+
network: "For neural nets, encoder-left/decoder-right or input-at-top. For trees, root-up.",
|
|
5074
|
+
conceptual: "Choose a composition (flow / radial / Venn / concentric) that makes the relationship obvious, then keep it regular."
|
|
5075
|
+
};
|
|
5076
|
+
const VERBATIM_TEXT_RULE = `Render every quoted string EXACTLY as given — verbatim, character for character. Do not paraphrase, auto-correct, translate, or substitute. This includes counts like "n=350", identifiers like "EGFR", units like "1kΩ", colour hex codes like "#2B6CB0", and any symbols. If the request contains unfamiliar words or abbreviations, keep them as written.`;
|
|
5077
|
+
const UNIVERSAL_AVOID = [
|
|
5078
|
+
'Figure numbers or titles ("Figure 1:", "Fig 1.", etc.) anywhere in the image',
|
|
5079
|
+
"Captions or header bars that repeat information the slide/paper will add around the figure",
|
|
5080
|
+
"Photorealistic photographs, hand-drawn sketches, 3D shading, bevel/emboss/drop-shadow effects",
|
|
5081
|
+
'Watermarks, logos, signatures, or stylistic "AI" ornaments'
|
|
5082
|
+
];
|
|
5083
|
+
function extractLiterals(prompt) {
|
|
5084
|
+
const found = /* @__PURE__ */ new Set();
|
|
5085
|
+
for (const m of prompt.matchAll(/"([^"]+)"|'([^']+)'/g)) {
|
|
5086
|
+
const value = (m[1] ?? m[2] ?? "").trim();
|
|
5087
|
+
if (value) found.add(`"${value}"`);
|
|
5088
|
+
}
|
|
5089
|
+
for (const m of prompt.matchAll(/\bn\s*=\s*([\d,]+)/gi)) {
|
|
5090
|
+
found.add(`n=${m[1].replace(/,/g, "")}`);
|
|
5091
|
+
}
|
|
5092
|
+
for (const m of prompt.matchAll(/\b\d+(?:\.\d+)?\s*(?:kΩ|Ω|µF|uF|mF|nF|pF|kHz|MHz|GHz|Hz|mV|V|mA|A|pt|px|nm|µm|mm|cm|m|ns|µs|ms|s|nodes|layers)\b/gi)) {
|
|
5093
|
+
found.add(m[0].trim());
|
|
5094
|
+
}
|
|
5095
|
+
for (const m of prompt.matchAll(/#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3}(?:[0-9a-fA-F]{2})?)?\b/g)) {
|
|
5096
|
+
found.add(m[0]);
|
|
5097
|
+
}
|
|
5098
|
+
for (const m of prompt.matchAll(/\b(?:rgba?|hsla?)\([^)]+\)/gi)) {
|
|
5099
|
+
found.add(m[0]);
|
|
5100
|
+
}
|
|
5101
|
+
return Array.from(found);
|
|
5102
|
+
}
|
|
5103
|
+
function resolveDiagramType(type) {
|
|
5104
|
+
return type === "auto" ? "conceptual" : type;
|
|
5105
|
+
}
|
|
5106
|
+
function buildBrief(s) {
|
|
5107
|
+
const lines = [];
|
|
5108
|
+
const svgPath = s.format === "svg";
|
|
5109
|
+
const includeGuide = !s.skipDesignGuide;
|
|
5110
|
+
lines.push(`【SCENE / USE】 ${s.sceneUse}`);
|
|
5111
|
+
lines.push(`【SUBJECT】 ${s.subject}`);
|
|
5112
|
+
if (includeGuide) {
|
|
5113
|
+
lines.push("");
|
|
5114
|
+
lines.push(DESIGN_PRINCIPLES);
|
|
5115
|
+
if (svgPath) {
|
|
5116
|
+
lines.push("");
|
|
5117
|
+
lines.push(POSITIVE_EXAMPLE_BLOCK);
|
|
5118
|
+
}
|
|
5119
|
+
}
|
|
5120
|
+
lines.push("");
|
|
5121
|
+
lines.push(`【COMPOSITION】 ${s.composition}`);
|
|
5122
|
+
lines.push(`【KEY DETAILS】`);
|
|
5123
|
+
lines.push(s.keyDetails);
|
|
5124
|
+
if (includeGuide && svgPath) {
|
|
5125
|
+
lines.push("");
|
|
5126
|
+
lines.push(GEOMETRIC_DISCIPLINE);
|
|
5127
|
+
}
|
|
5128
|
+
if (s.motifs) {
|
|
5129
|
+
lines.push(`【MOTIFS】`);
|
|
5130
|
+
lines.push(s.motifs);
|
|
5131
|
+
}
|
|
5132
|
+
lines.push(`【TEXT】 ${s.textRules}`);
|
|
5133
|
+
if (s.mustKeep.length > 0) {
|
|
5134
|
+
lines.push(`【MUST KEEP】`);
|
|
5135
|
+
for (const item of s.mustKeep) lines.push(`- ${item}`);
|
|
5136
|
+
}
|
|
5137
|
+
if (includeGuide) {
|
|
5138
|
+
lines.push("");
|
|
5139
|
+
lines.push(COMMON_MISTAKES);
|
|
5140
|
+
}
|
|
5141
|
+
lines.push(`【AVOID】`);
|
|
5142
|
+
for (const item of s.avoid) lines.push(`- ${item}`);
|
|
5143
|
+
return lines.join("\n");
|
|
5144
|
+
}
|
|
5145
|
+
function buildKeyDetails(diagramTypeRules, rendered) {
|
|
5146
|
+
return [
|
|
5147
|
+
diagramTypeRules,
|
|
5148
|
+
"",
|
|
5149
|
+
"Typography",
|
|
5150
|
+
rendered.typography,
|
|
5151
|
+
"",
|
|
5152
|
+
"Colour palette",
|
|
5153
|
+
rendered.palette,
|
|
5154
|
+
"",
|
|
5155
|
+
"Geometry",
|
|
5156
|
+
rendered.geometry
|
|
5157
|
+
].join("\n");
|
|
5158
|
+
}
|
|
5159
|
+
function buildAvoid(profile) {
|
|
5160
|
+
return [...UNIVERSAL_AVOID, ...profile.avoid];
|
|
5161
|
+
}
|
|
5162
|
+
function composeGenerationPrompt(userPrompt, diagramType, format, profile = DEFAULT_HOUSE_PROFILE) {
|
|
5163
|
+
const dt = resolveDiagramType(diagramType);
|
|
5164
|
+
const rendered = renderProfile(profile);
|
|
5165
|
+
const literals = extractLiterals(userPrompt);
|
|
5166
|
+
return buildBrief({
|
|
5167
|
+
sceneUse: `A publication-grade scientific ${dt} diagram in the house visual system. ${rendered.theme}`,
|
|
5168
|
+
subject: userPrompt,
|
|
5169
|
+
composition: COMPOSITION_HINTS[dt],
|
|
5170
|
+
keyDetails: buildKeyDetails(DIAGRAM_TYPE_DETAILS[dt], rendered),
|
|
5171
|
+
motifs: rendered.motifs,
|
|
5172
|
+
textRules: VERBATIM_TEXT_RULE,
|
|
5173
|
+
mustKeep: literals,
|
|
5174
|
+
avoid: buildAvoid(profile),
|
|
5175
|
+
format
|
|
5176
|
+
});
|
|
5177
|
+
}
|
|
5178
|
+
function composeEditPrompt(userPrompt, diagramType, issues, preservedFixes = [], profile = DEFAULT_HOUSE_PROFILE) {
|
|
5179
|
+
const dt = resolveDiagramType(diagramType);
|
|
5180
|
+
const rendered = renderProfile(profile);
|
|
5181
|
+
const literals = extractLiterals(userPrompt);
|
|
5182
|
+
const fixes = issues.length > 0 ? issues.map((i, idx) => `${idx + 1}. [${i.kind}] ${i.fix}`).join("\n") : "Tighten the details that needed tightening; keep the rest unchanged.";
|
|
5183
|
+
const keyDetailLines = [
|
|
5184
|
+
`TARGETED FIXES (in priority order):`,
|
|
5185
|
+
fixes,
|
|
5186
|
+
`Apply ONLY these fixes. Every other element — including existing labels, arrows, boxes, whitespace, colours, and typography — must be preserved exactly as in the attached image.`
|
|
5187
|
+
];
|
|
5188
|
+
if (preservedFixes.length > 0) {
|
|
5189
|
+
const preservedLines = preservedFixes.map((p, idx) => `${idx + 1}. [${p.kind}] ${p.description} — already resolved; keep it that way`).join("\n");
|
|
5190
|
+
keyDetailLines.push(
|
|
5191
|
+
`ALREADY-RESOLVED ITEMS (DO NOT regress these — they were broken in a previous draft and fixed):`,
|
|
5192
|
+
preservedLines
|
|
5193
|
+
);
|
|
5194
|
+
}
|
|
5195
|
+
keyDetailLines.push(
|
|
5196
|
+
`Type-specific rules still apply: ${DIAGRAM_TYPE_DETAILS[dt]}`,
|
|
5197
|
+
"",
|
|
5198
|
+
`House-style tokens (unchanged from the original brief — do not drift from these):`,
|
|
5199
|
+
rendered.typography,
|
|
5200
|
+
"",
|
|
5201
|
+
rendered.palette,
|
|
5202
|
+
"",
|
|
5203
|
+
rendered.geometry
|
|
5204
|
+
);
|
|
5205
|
+
return buildBrief({
|
|
5206
|
+
sceneUse: `Surgical revision of an existing ${dt} diagram in the house visual system.`,
|
|
5207
|
+
subject: userPrompt,
|
|
5208
|
+
composition: `Keep the existing layout, sizing, colour palette, and element positions UNCHANGED unless a specific fix below requires otherwise. Do not redraw from scratch.`,
|
|
5209
|
+
keyDetails: keyDetailLines.join("\n"),
|
|
5210
|
+
motifs: rendered.motifs,
|
|
5211
|
+
textRules: VERBATIM_TEXT_RULE,
|
|
5212
|
+
mustKeep: literals,
|
|
5213
|
+
avoid: [
|
|
5214
|
+
...buildAvoid(profile),
|
|
5215
|
+
"Any change to the overall composition, aspect ratio, or background",
|
|
5216
|
+
"Any change to elements that were already correct",
|
|
5217
|
+
"Regressing any item listed in ALREADY-RESOLVED ITEMS",
|
|
5218
|
+
"Drifting away from the house-style tokens (typography, palette, geometry) listed above"
|
|
5219
|
+
],
|
|
5220
|
+
// Edit is surgical — the caller has an already-composed draft. Injecting
|
|
5221
|
+
// global design principles here would invite the model to "improve"
|
|
5222
|
+
// elements that were intentionally left alone. Suppress the guide.
|
|
5223
|
+
skipDesignGuide: true
|
|
5224
|
+
});
|
|
5225
|
+
}
|
|
5226
|
+
function composeRegenPrompt(userPrompt, diagramType, issues, format, profile = DEFAULT_HOUSE_PROFILE) {
|
|
5227
|
+
const base = composeGenerationPrompt(userPrompt, diagramType, format, profile);
|
|
5228
|
+
if (issues.length === 0) return base;
|
|
5229
|
+
const negatives = issues.map((i) => `- ${i.description} (fix: ${i.fix})`).join("\n");
|
|
5230
|
+
return `${base}
|
|
5231
|
+
|
|
5232
|
+
【PREVIOUS ATTEMPT HAD THESE PROBLEMS — DO NOT REPEAT】
|
|
5233
|
+
${negatives}`;
|
|
5234
|
+
}
|
|
5235
|
+
function composeStyleOnlyPrompt(userPrompt, diagramType, format, profile = DEFAULT_HOUSE_PROFILE) {
|
|
5236
|
+
const base = composeGenerationPrompt(userPrompt, diagramType, format, profile);
|
|
5237
|
+
return [
|
|
5238
|
+
"The attached image is provided as a STYLE REFERENCE ONLY.",
|
|
5239
|
+
"Match its visual idiom — colour palette, line weights, typography, corner radii, arrow style, spacing rhythm.",
|
|
5240
|
+
"Do NOT copy its layout, content, panel structure, or composition.",
|
|
5241
|
+
"Design a new diagram from scratch for the request below, expressed in that style.",
|
|
5242
|
+
"",
|
|
5243
|
+
base
|
|
5244
|
+
].join("\n");
|
|
5245
|
+
}
|
|
5246
|
+
function composeSurgicalRevisionPrompt(userPrompt, diagramType) {
|
|
5247
|
+
const dt = resolveDiagramType(diagramType);
|
|
5248
|
+
const literals = extractLiterals(userPrompt);
|
|
5249
|
+
const mustKeep = literals.length > 0 ? `
|
|
5250
|
+
|
|
5251
|
+
【MUST KEEP — verbatim from the request】
|
|
5252
|
+
${literals.map((l) => `- ${l}`).join("\n")}` : "";
|
|
5253
|
+
return [
|
|
5254
|
+
"【MODE】 SURGICAL REVISION of an existing SVG.",
|
|
5255
|
+
"",
|
|
5256
|
+
`The SVG source is provided separately. It is a ${dt} diagram already in the house visual system.`,
|
|
5257
|
+
"Apply ONLY the changes described in the REQUEST below. Preserve",
|
|
5258
|
+
"every other element exactly — layout, sizing, colours, typography,",
|
|
5259
|
+
"arrow routing, group boundaries, viewBox, background. Do NOT redraw",
|
|
5260
|
+
'from scratch. Do NOT "improve" anything the user did not mention.',
|
|
5261
|
+
"Do NOT introduce new design principles, legends, or colours that",
|
|
5262
|
+
"the existing SVG does not already use.",
|
|
5263
|
+
"",
|
|
5264
|
+
"If a requested change appears to conflict with something already in",
|
|
5265
|
+
"the SVG, honour the REQUEST — the user knows what they asked for.",
|
|
5266
|
+
"If a change is ambiguous, make the smallest edit that satisfies it.",
|
|
5267
|
+
"",
|
|
5268
|
+
"【REQUEST — apply only these changes】",
|
|
5269
|
+
userPrompt,
|
|
5270
|
+
mustKeep
|
|
5271
|
+
].join("\n");
|
|
5272
|
+
}
|
|
5273
|
+
function detectDiagramType(prompt) {
|
|
5274
|
+
const p = prompt.toLowerCase();
|
|
5275
|
+
const match = (keywords) => keywords.some((k) => p.includes(k));
|
|
5276
|
+
if (match(["consort", "prisma", "flowchart", "flow chart", "flow diagram", "swimlane", "decision tree"])) return "flowchart";
|
|
5277
|
+
if (match(["architecture", "system diagram", "microservice", "pipeline", "data flow", "block diagram"])) return "architecture";
|
|
5278
|
+
if (match(["pathway", "signaling", "signalling", "cascade", "mapk", "egfr", "phosphoryl", "receptor", "kinase"])) return "pathway";
|
|
5279
|
+
if (match(["circuit", "resistor", "capacitor", "op-amp", "opamp", "transistor", "voltage", "schematic circuit"])) return "circuit";
|
|
5280
|
+
if (match(["neural network", "transformer", "cnn", "rnn", "lstm", "attention", "encoder-decoder", "graph", "tree", "hierarchy"])) return "network";
|
|
5281
|
+
return "conceptual";
|
|
5282
|
+
}
|
|
5283
|
+
const GENERATIONS_URL = "https://api.openai.com/v1/images/generations";
|
|
5284
|
+
const EDITS_URL = "https://api.openai.com/v1/images/edits";
|
|
5285
|
+
const DEFAULT_MODEL$2 = "gpt-image-2";
|
|
5286
|
+
const DEFAULT_SIZE = "auto";
|
|
5287
|
+
const REQUEST_TIMEOUT_MS$2 = 3e5;
|
|
5288
|
+
function extractBytes(response) {
|
|
5289
|
+
const choice = response.data?.[0];
|
|
5290
|
+
if (!choice) throw new Error("OpenAI image response had no choices");
|
|
5291
|
+
if (choice.b64_json) {
|
|
5292
|
+
return Buffer.from(choice.b64_json, "base64");
|
|
5293
|
+
}
|
|
5294
|
+
if (choice.url) {
|
|
5295
|
+
throw new Error(
|
|
5296
|
+
"OpenAI returned an image URL instead of base64 bytes. The selected model may be dall-e-*; this backend targets gpt-image-2 which returns b64_json by default."
|
|
5297
|
+
);
|
|
5298
|
+
}
|
|
5299
|
+
throw new Error("OpenAI image response did not contain image data");
|
|
5300
|
+
}
|
|
5301
|
+
async function postJson(url, apiKey, body) {
|
|
5302
|
+
const ctl = new AbortController();
|
|
5303
|
+
const timer = setTimeout(() => ctl.abort(), REQUEST_TIMEOUT_MS$2);
|
|
5304
|
+
try {
|
|
5305
|
+
const res = await fetch(url, {
|
|
5306
|
+
method: "POST",
|
|
5307
|
+
headers: {
|
|
5308
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
5309
|
+
"Content-Type": "application/json"
|
|
5310
|
+
},
|
|
5311
|
+
body: JSON.stringify(body),
|
|
5312
|
+
signal: ctl.signal
|
|
5313
|
+
});
|
|
5314
|
+
const json = await res.json();
|
|
5315
|
+
if (!res.ok) {
|
|
5316
|
+
const msg = json.error?.message || `HTTP ${res.status}`;
|
|
5317
|
+
throw new Error(`OpenAI image API error: ${msg}`);
|
|
5318
|
+
}
|
|
5319
|
+
return json;
|
|
5320
|
+
} finally {
|
|
5321
|
+
clearTimeout(timer);
|
|
5322
|
+
}
|
|
5323
|
+
}
|
|
5324
|
+
async function postMultipart(url, apiKey, fields, files) {
|
|
5325
|
+
const form = new FormData();
|
|
5326
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
5327
|
+
form.append(k, v);
|
|
5328
|
+
}
|
|
5329
|
+
for (const [k, f] of Object.entries(files)) {
|
|
5330
|
+
const view = new Uint8Array(f.data.buffer, f.data.byteOffset, f.data.byteLength);
|
|
5331
|
+
form.append(k, new Blob([view], { type: f.contentType }), f.filename);
|
|
5332
|
+
}
|
|
5333
|
+
const ctl = new AbortController();
|
|
5334
|
+
const timer = setTimeout(() => ctl.abort(), REQUEST_TIMEOUT_MS$2);
|
|
5335
|
+
try {
|
|
5336
|
+
const res = await fetch(url, {
|
|
5337
|
+
method: "POST",
|
|
5338
|
+
headers: { "Authorization": `Bearer ${apiKey}` },
|
|
5339
|
+
body: form,
|
|
5340
|
+
signal: ctl.signal
|
|
5341
|
+
});
|
|
5342
|
+
const json = await res.json();
|
|
5343
|
+
if (!res.ok) {
|
|
5344
|
+
const msg = json.error?.message || `HTTP ${res.status}`;
|
|
5345
|
+
throw new Error(`OpenAI image edit API error: ${msg}`);
|
|
5346
|
+
}
|
|
5347
|
+
return json;
|
|
5348
|
+
} finally {
|
|
5349
|
+
clearTimeout(timer);
|
|
5350
|
+
}
|
|
5351
|
+
}
|
|
5352
|
+
function createOpenAIImageProvider(opts = {}) {
|
|
5353
|
+
const apiKey = opts.apiKey ?? process.env.OPENAI_API_KEY?.trim();
|
|
5354
|
+
if (!apiKey) {
|
|
5355
|
+
throw new Error(
|
|
5356
|
+
"OPENAI_API_KEY is required for diagram generation. Add it under Settings → API Keys, or set OPENAI_API_KEY in your shell."
|
|
5357
|
+
);
|
|
5358
|
+
}
|
|
5359
|
+
const model = opts.model || DEFAULT_MODEL$2;
|
|
5360
|
+
const size = opts.size || DEFAULT_SIZE;
|
|
5361
|
+
const defaultQuality = opts.quality || "auto";
|
|
5362
|
+
const capabilities = /* @__PURE__ */ new Set(["text_to_image", "image_to_image"]);
|
|
5363
|
+
const effectiveQuality = (callOpts) => callOpts?.quality ?? defaultQuality;
|
|
5364
|
+
return {
|
|
5365
|
+
id: `openai:${model}`,
|
|
5366
|
+
label: `OpenAI ${model}`,
|
|
5367
|
+
capabilities,
|
|
5368
|
+
async textToImage(prompt, options) {
|
|
5369
|
+
const body = {
|
|
5370
|
+
model,
|
|
5371
|
+
prompt,
|
|
5372
|
+
size,
|
|
5373
|
+
n: 1,
|
|
5374
|
+
quality: effectiveQuality(options)
|
|
5375
|
+
};
|
|
5376
|
+
const response = await postJson(GENERATIONS_URL, apiKey, body);
|
|
5377
|
+
return extractBytes(response);
|
|
5378
|
+
},
|
|
5379
|
+
async imageToImage(prompt, image, options) {
|
|
5380
|
+
const response = await postMultipart(
|
|
5381
|
+
EDITS_URL,
|
|
5382
|
+
apiKey,
|
|
5383
|
+
{ model, prompt, size, n: "1", quality: effectiveQuality(options) },
|
|
5384
|
+
{ image: { data: image, filename: "image.png", contentType: "image/png" } }
|
|
5385
|
+
);
|
|
5386
|
+
return extractBytes(response);
|
|
5387
|
+
}
|
|
5388
|
+
};
|
|
5389
|
+
}
|
|
5390
|
+
const CHAT_URL = "https://api.openai.com/v1/chat/completions";
|
|
5391
|
+
const DEFAULT_MODEL$1 = "gpt-4o";
|
|
5392
|
+
const REQUEST_TIMEOUT_MS$1 = 18e4;
|
|
5393
|
+
const OPENAI_THRESHOLDS = {
|
|
5394
|
+
journal: 8.3,
|
|
5395
|
+
conference: 7.8,
|
|
5396
|
+
thesis: 7.8,
|
|
5397
|
+
grant: 7.8,
|
|
5398
|
+
preprint: 7.3,
|
|
5399
|
+
report: 7.3,
|
|
5400
|
+
poster: 6.8,
|
|
5401
|
+
presentation: 6.3,
|
|
5402
|
+
default: 7.3
|
|
5403
|
+
};
|
|
5404
|
+
const REVIEW_SYSTEM$1 = `You are a rigorous reviewer for scientific publication-grade diagrams.
|
|
5405
|
+
Rate on a strict scale — 9+ is reserved for camera-ready figures. Return only valid JSON matching the schema. Be specific: every blocking_issue must describe exactly what is wrong AND a concrete fix another tool can act on.`;
|
|
5406
|
+
function buildUserPrompt(req, threshold) {
|
|
5407
|
+
const houseBlock = req.houseProfileSummary ? `HOUSE STYLE (the figure must belong to this visual system):
|
|
5408
|
+
${req.houseProfileSummary}
|
|
5409
|
+
|
|
5410
|
+
` : "";
|
|
5411
|
+
const fifthDimension = req.houseProfileSummary ? "5. House-style adherence & consistency — matches the supplied palette, typography voice, geometry tokens, and motifs; feels like a sibling of other figures in the same system" : "5. Professional appearance — publication-ready polish";
|
|
5412
|
+
return `Evaluate this diagram for "${req.docType}" publication (acceptance threshold: ${threshold}/10).
|
|
5413
|
+
|
|
5414
|
+
DIAGRAM TYPE: ${req.diagramType}
|
|
5415
|
+
ORIGINAL REQUEST: ${req.prompt}
|
|
5416
|
+
ITERATION: ${req.iteration}/${req.maxIterations}
|
|
5417
|
+
${houseBlock}
|
|
5418
|
+
Score five dimensions independently (0-2 each, total 0-10):
|
|
5419
|
+
1. Scientific accuracy — concepts, relationships, notation correct
|
|
5420
|
+
2. Clarity & readability — hierarchy, unambiguous at a glance
|
|
5421
|
+
3. Label quality — complete, legible, consistent
|
|
5422
|
+
4. Layout & composition — balanced, no overlap, logical flow
|
|
5423
|
+
${fifthDimension}
|
|
5424
|
+
|
|
5425
|
+
For every issue that blocks acceptance, emit an entry in blocking_issues with:
|
|
5426
|
+
- kind: wrong_content | illegible_text | layout_collision | missing_element | style_mismatch
|
|
5427
|
+
- description: what is wrong (call out house-style deviations under style_mismatch — wrong palette role, wrong corner radius, wrong typography voice, broken motif, etc.)
|
|
5428
|
+
- fix: precise instruction to correct it (will be fed to an image editor)
|
|
5429
|
+
|
|
5430
|
+
Choose verdict:
|
|
5431
|
+
- "acceptable" if score >= ${threshold} and no blocking_issues
|
|
5432
|
+
- "needs_edit" if problems are localised/cosmetic (labels, overlaps, styling) — image-to-image editing can fix them
|
|
5433
|
+
- "needs_regen" if content is wrong or structure is broken — the image must be redrawn`;
|
|
5434
|
+
}
|
|
5435
|
+
function coerceVerdict$2(raw) {
|
|
5436
|
+
if (raw === "acceptable" || raw === "needs_edit" || raw === "needs_regen") return raw;
|
|
5437
|
+
return "needs_edit";
|
|
5438
|
+
}
|
|
5439
|
+
function clampScore$2(n) {
|
|
5440
|
+
const v = typeof n === "number" ? n : Number(n);
|
|
5441
|
+
if (Number.isNaN(v)) return 0;
|
|
5442
|
+
return Math.min(10, Math.max(0, v));
|
|
5443
|
+
}
|
|
5444
|
+
const RESPONSE_SCHEMA = {
|
|
5445
|
+
type: "object",
|
|
5446
|
+
additionalProperties: false,
|
|
5447
|
+
required: ["score", "requestAlignment", "legibility", "blockingIssues", "summary", "verdict"],
|
|
5448
|
+
properties: {
|
|
5449
|
+
score: { type: "number" },
|
|
5450
|
+
requestAlignment: { type: "number" },
|
|
5451
|
+
legibility: { type: "number" },
|
|
5452
|
+
blockingIssues: {
|
|
5453
|
+
type: "array",
|
|
5454
|
+
items: {
|
|
5455
|
+
type: "object",
|
|
5456
|
+
additionalProperties: false,
|
|
5457
|
+
required: ["kind", "description", "fix"],
|
|
5458
|
+
properties: {
|
|
5459
|
+
kind: {
|
|
5460
|
+
type: "string",
|
|
5461
|
+
enum: ["wrong_content", "illegible_text", "layout_collision", "missing_element", "style_mismatch"]
|
|
5462
|
+
},
|
|
5463
|
+
description: { type: "string" },
|
|
5464
|
+
fix: { type: "string" }
|
|
5465
|
+
}
|
|
5466
|
+
}
|
|
5467
|
+
},
|
|
5468
|
+
summary: { type: "string" },
|
|
5469
|
+
verdict: {
|
|
5470
|
+
type: "string",
|
|
5471
|
+
enum: ["acceptable", "needs_edit", "needs_regen"]
|
|
5472
|
+
}
|
|
5473
|
+
}
|
|
5474
|
+
};
|
|
5475
|
+
function createOpenAIReviewProvider(opts = {}) {
|
|
5476
|
+
const apiKey = opts.apiKey ?? process.env.OPENAI_API_KEY?.trim();
|
|
5477
|
+
if (!apiKey) {
|
|
5478
|
+
throw new Error("OPENAI_API_KEY is required for OpenAI-based review.");
|
|
5479
|
+
}
|
|
5480
|
+
const model = opts.model || DEFAULT_MODEL$1;
|
|
5481
|
+
const thresholds = opts.thresholds ?? OPENAI_THRESHOLDS;
|
|
5482
|
+
async function review(req) {
|
|
5483
|
+
const threshold = thresholds[req.docType] ?? thresholds.default;
|
|
5484
|
+
const b64 = req.image.toString("base64");
|
|
5485
|
+
const dataUri = `data:image/png;base64,${b64}`;
|
|
5486
|
+
const body = {
|
|
5487
|
+
model,
|
|
5488
|
+
messages: [
|
|
5489
|
+
{ role: "system", content: REVIEW_SYSTEM$1 },
|
|
5490
|
+
{
|
|
5491
|
+
role: "user",
|
|
5492
|
+
content: [
|
|
5493
|
+
{ type: "text", text: buildUserPrompt(req, threshold) },
|
|
5494
|
+
{ type: "image_url", image_url: { url: dataUri } }
|
|
5495
|
+
]
|
|
5496
|
+
}
|
|
5497
|
+
],
|
|
5498
|
+
response_format: {
|
|
5499
|
+
type: "json_schema",
|
|
5500
|
+
json_schema: { name: "DiagramReview", strict: true, schema: RESPONSE_SCHEMA }
|
|
5501
|
+
},
|
|
5502
|
+
temperature: 0
|
|
5503
|
+
};
|
|
5504
|
+
const ctl = new AbortController();
|
|
5505
|
+
const timer = setTimeout(() => ctl.abort(), REQUEST_TIMEOUT_MS$1);
|
|
5506
|
+
try {
|
|
5507
|
+
const res = await fetch(CHAT_URL, {
|
|
5508
|
+
method: "POST",
|
|
5509
|
+
headers: {
|
|
5510
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
5511
|
+
"Content-Type": "application/json"
|
|
5512
|
+
},
|
|
5513
|
+
body: JSON.stringify(body),
|
|
5514
|
+
signal: ctl.signal
|
|
5515
|
+
});
|
|
5516
|
+
const json = await res.json();
|
|
5517
|
+
if (!res.ok) {
|
|
5518
|
+
throw new Error(`OpenAI review API error: ${json.error?.message || `HTTP ${res.status}`}`);
|
|
5519
|
+
}
|
|
5520
|
+
const content = json.choices?.[0]?.message?.content ?? "";
|
|
5521
|
+
if (!content) throw new Error("OpenAI review returned empty content");
|
|
5522
|
+
const parsed = JSON.parse(content);
|
|
5523
|
+
const result = {
|
|
5524
|
+
score: clampScore$2(parsed.score),
|
|
5525
|
+
requestAlignment: clampScore$2(parsed.requestAlignment),
|
|
5526
|
+
legibility: clampScore$2(parsed.legibility),
|
|
5527
|
+
blockingIssues: Array.isArray(parsed.blockingIssues) ? parsed.blockingIssues : [],
|
|
5528
|
+
summary: typeof parsed.summary === "string" ? parsed.summary : "",
|
|
5529
|
+
verdict: coerceVerdict$2(parsed.verdict)
|
|
5530
|
+
};
|
|
5531
|
+
return result;
|
|
5532
|
+
} finally {
|
|
5533
|
+
clearTimeout(timer);
|
|
5534
|
+
}
|
|
5535
|
+
}
|
|
5536
|
+
return {
|
|
5537
|
+
id: `openai:${model}`,
|
|
5538
|
+
label: `OpenAI ${model}`,
|
|
5539
|
+
thresholds,
|
|
5540
|
+
review
|
|
5541
|
+
};
|
|
5542
|
+
}
|
|
5543
|
+
const MESSAGES_URL = "https://api.anthropic.com/v1/messages";
|
|
5544
|
+
const DEFAULT_MODEL = "claude-opus-4-5";
|
|
5545
|
+
const ANTHROPIC_VERSION = "2023-06-01";
|
|
5546
|
+
const REQUEST_TIMEOUT_MS = 18e4;
|
|
5547
|
+
const MAX_TOKENS = 2048;
|
|
5548
|
+
const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
5549
|
+
const BETA_API_KEY = "fine-grained-tool-streaming-2025-05-14";
|
|
5550
|
+
const BETA_OAUTH = `claude-code-20250219,oauth-2025-04-20,${BETA_API_KEY}`;
|
|
5551
|
+
const ANTHROPIC_THRESHOLDS = {
|
|
5552
|
+
journal: 8.5,
|
|
5553
|
+
conference: 8,
|
|
5554
|
+
thesis: 8,
|
|
5555
|
+
grant: 8,
|
|
5556
|
+
preprint: 7.5,
|
|
5557
|
+
report: 7.5,
|
|
5558
|
+
poster: 7,
|
|
5559
|
+
presentation: 6.5,
|
|
5560
|
+
default: 7.5
|
|
5561
|
+
};
|
|
5562
|
+
const REVIEW_SYSTEM = `You are a rigorous reviewer for scientific publication-grade diagrams.
|
|
5563
|
+
Rate on a strict scale — 9+ is reserved for camera-ready figures. Always emit your answer by calling the emit_review tool. Every blocking_issue must describe what is wrong AND a concrete fix another tool can act on.`;
|
|
5564
|
+
function buildUserText(req, threshold) {
|
|
5565
|
+
const houseBlock = req.houseProfileSummary ? `
|
|
5566
|
+
HOUSE STYLE (figure must belong to this visual system):
|
|
5567
|
+
${req.houseProfileSummary}
|
|
5568
|
+
` : "";
|
|
5569
|
+
const fifthDimension = req.houseProfileSummary ? "house-style adherence & consistency" : "professional appearance";
|
|
5570
|
+
return `Evaluate this diagram for "${req.docType}" publication (acceptance threshold: ${threshold}/10).
|
|
5571
|
+
|
|
5572
|
+
DIAGRAM TYPE: ${req.diagramType}
|
|
5573
|
+
ORIGINAL REQUEST: ${req.prompt}
|
|
5574
|
+
ITERATION: ${req.iteration}/${req.maxIterations}
|
|
5575
|
+
${houseBlock}
|
|
5576
|
+
Score five dimensions (0-2 each, total 0-10): scientific accuracy, clarity & readability, label quality, layout & composition, ${fifthDimension}.
|
|
5577
|
+
|
|
5578
|
+
When scoring dimension 5, compare against the HOUSE STYLE block above — wrong palette roles, wrong corner radius, wrong typography voice, or broken motifs are all style_mismatch blocking issues.
|
|
5579
|
+
|
|
5580
|
+
Choose verdict:
|
|
5581
|
+
- "acceptable" if score >= ${threshold} and no blocking_issues
|
|
5582
|
+
- "needs_edit" if problems are localised (labels, overlaps, styling) — image-to-image can fix them
|
|
5583
|
+
- "needs_regen" if content is wrong or structure is broken — must redraw
|
|
5584
|
+
|
|
5585
|
+
Call emit_review once with your structured verdict.`;
|
|
5586
|
+
}
|
|
5587
|
+
const EMIT_REVIEW_TOOL = {
|
|
5588
|
+
name: "emit_review",
|
|
5589
|
+
description: "Emit the structured review verdict for the diagram.",
|
|
5590
|
+
input_schema: {
|
|
5591
|
+
type: "object",
|
|
5592
|
+
additionalProperties: false,
|
|
5593
|
+
required: ["score", "requestAlignment", "legibility", "blockingIssues", "summary", "verdict"],
|
|
5594
|
+
properties: {
|
|
5595
|
+
score: { type: "number" },
|
|
5596
|
+
requestAlignment: { type: "number" },
|
|
5597
|
+
legibility: { type: "number" },
|
|
5598
|
+
blockingIssues: {
|
|
5599
|
+
type: "array",
|
|
5600
|
+
items: {
|
|
5601
|
+
type: "object",
|
|
5602
|
+
additionalProperties: false,
|
|
5603
|
+
required: ["kind", "description", "fix"],
|
|
5604
|
+
properties: {
|
|
5605
|
+
kind: {
|
|
5606
|
+
type: "string",
|
|
5607
|
+
enum: ["wrong_content", "illegible_text", "layout_collision", "missing_element", "style_mismatch"]
|
|
5608
|
+
},
|
|
5609
|
+
description: { type: "string" },
|
|
5610
|
+
fix: { type: "string" }
|
|
5611
|
+
}
|
|
5612
|
+
}
|
|
5613
|
+
},
|
|
5614
|
+
summary: { type: "string" },
|
|
5615
|
+
verdict: {
|
|
5616
|
+
type: "string",
|
|
5617
|
+
enum: ["acceptable", "needs_edit", "needs_regen"]
|
|
5618
|
+
}
|
|
5619
|
+
}
|
|
5620
|
+
}
|
|
5621
|
+
};
|
|
5622
|
+
function coerceVerdict$1(raw) {
|
|
5623
|
+
if (raw === "acceptable" || raw === "needs_edit" || raw === "needs_regen") return raw;
|
|
5624
|
+
return "needs_edit";
|
|
5625
|
+
}
|
|
5626
|
+
function clampScore$1(n) {
|
|
5627
|
+
const v = typeof n === "number" ? n : Number(n);
|
|
5628
|
+
if (Number.isNaN(v)) return 0;
|
|
5629
|
+
return Math.min(10, Math.max(0, v));
|
|
5630
|
+
}
|
|
5631
|
+
function isOAuthAccessToken(token) {
|
|
5632
|
+
return token.startsWith("sk-ant-oat");
|
|
5633
|
+
}
|
|
5634
|
+
function buildHeaders(token, isOAuth) {
|
|
5635
|
+
const common = {
|
|
5636
|
+
"anthropic-version": ANTHROPIC_VERSION,
|
|
5637
|
+
"Content-Type": "application/json"
|
|
5638
|
+
};
|
|
5639
|
+
if (isOAuth) {
|
|
5640
|
+
return {
|
|
5641
|
+
...common,
|
|
5642
|
+
"Authorization": `Bearer ${token}`,
|
|
5643
|
+
"anthropic-beta": BETA_OAUTH,
|
|
5644
|
+
"anthropic-dangerous-direct-browser-access": "true",
|
|
5645
|
+
"user-agent": "claude-cli/research-copilot",
|
|
5646
|
+
"x-app": "cli"
|
|
5647
|
+
};
|
|
5648
|
+
}
|
|
5649
|
+
return {
|
|
5650
|
+
...common,
|
|
5651
|
+
"x-api-key": token,
|
|
5652
|
+
"anthropic-beta": BETA_API_KEY
|
|
5653
|
+
};
|
|
5654
|
+
}
|
|
5655
|
+
function buildRequestBody(req, threshold, isOAuth) {
|
|
5656
|
+
const b64 = req.image.toString("base64");
|
|
5657
|
+
const systemBlocks = [];
|
|
5658
|
+
if (isOAuth) {
|
|
5659
|
+
systemBlocks.push({ type: "text", text: CLAUDE_CODE_IDENTITY });
|
|
5660
|
+
}
|
|
5661
|
+
systemBlocks.push({ type: "text", text: REVIEW_SYSTEM });
|
|
5662
|
+
return {
|
|
5663
|
+
model: DEFAULT_MODEL,
|
|
5664
|
+
max_tokens: MAX_TOKENS,
|
|
5665
|
+
system: systemBlocks,
|
|
5666
|
+
messages: [
|
|
5667
|
+
{
|
|
5668
|
+
role: "user",
|
|
5669
|
+
content: [
|
|
5670
|
+
{
|
|
5671
|
+
type: "image",
|
|
5672
|
+
source: { type: "base64", media_type: "image/png", data: b64 }
|
|
5673
|
+
},
|
|
5674
|
+
{ type: "text", text: buildUserText(req, threshold) }
|
|
5675
|
+
]
|
|
5676
|
+
}
|
|
5677
|
+
],
|
|
5678
|
+
tools: [EMIT_REVIEW_TOOL],
|
|
5679
|
+
tool_choice: { type: "tool", name: "emit_review" }
|
|
5680
|
+
};
|
|
5681
|
+
}
|
|
5682
|
+
async function doRequest(token, isOAuth, body) {
|
|
5683
|
+
const ctl = new AbortController();
|
|
5684
|
+
const timer = setTimeout(() => ctl.abort(), REQUEST_TIMEOUT_MS);
|
|
5685
|
+
try {
|
|
5686
|
+
const res = await fetch(MESSAGES_URL, {
|
|
5687
|
+
method: "POST",
|
|
5688
|
+
headers: buildHeaders(token, isOAuth),
|
|
5689
|
+
body: JSON.stringify(body),
|
|
5690
|
+
signal: ctl.signal
|
|
5691
|
+
});
|
|
5692
|
+
const json = await res.json();
|
|
5693
|
+
return { status: res.status, json };
|
|
5694
|
+
} finally {
|
|
5695
|
+
clearTimeout(timer);
|
|
5696
|
+
}
|
|
5697
|
+
}
|
|
5698
|
+
function createAnthropicReviewProvider(opts = {}) {
|
|
5699
|
+
const envKey = process.env.ANTHROPIC_API_KEY?.trim();
|
|
5700
|
+
const initialTokenRaw = opts.token ?? envKey;
|
|
5701
|
+
if (!initialTokenRaw) {
|
|
5702
|
+
throw new Error("ANTHROPIC_API_KEY or an anthropic-sub OAuth token is required for Claude-based review.");
|
|
5703
|
+
}
|
|
5704
|
+
let currentToken = initialTokenRaw;
|
|
5705
|
+
const isOAuth = opts.isOAuth ?? isOAuthAccessToken(currentToken);
|
|
5706
|
+
const refreshToken = opts.refreshToken;
|
|
5707
|
+
const model = opts.model || DEFAULT_MODEL;
|
|
5708
|
+
const thresholds = opts.thresholds ?? ANTHROPIC_THRESHOLDS;
|
|
5709
|
+
async function review(req) {
|
|
5710
|
+
const threshold = thresholds[req.docType] ?? thresholds.default;
|
|
5711
|
+
const body = buildRequestBody(req, threshold, isOAuth);
|
|
5712
|
+
if (model !== DEFAULT_MODEL) {
|
|
5713
|
+
body.model = model;
|
|
5714
|
+
}
|
|
5715
|
+
let { status, json } = await doRequest(currentToken, isOAuth, body);
|
|
5716
|
+
if (status === 401 && isOAuth && refreshToken) {
|
|
5717
|
+
try {
|
|
5718
|
+
currentToken = await refreshToken();
|
|
5719
|
+
({ status, json } = await doRequest(currentToken, isOAuth, body));
|
|
5720
|
+
} catch (err) {
|
|
5721
|
+
throw new Error(`Anthropic OAuth token refresh failed: ${err.message}`);
|
|
5722
|
+
}
|
|
5723
|
+
}
|
|
5724
|
+
if (status < 200 || status >= 300) {
|
|
5725
|
+
throw new Error(`Anthropic review API error (HTTP ${status}): ${json.error?.message || "unknown"}`);
|
|
5726
|
+
}
|
|
5727
|
+
const toolUse = json.content?.find(
|
|
5728
|
+
(c) => c.type === "tool_use" && c.name === "emit_review"
|
|
5729
|
+
);
|
|
5730
|
+
if (!toolUse) {
|
|
5731
|
+
throw new Error("Claude review did not emit the expected tool_use block");
|
|
5732
|
+
}
|
|
5733
|
+
const input = toolUse.input;
|
|
5734
|
+
const result = {
|
|
5735
|
+
score: clampScore$1(input.score),
|
|
5736
|
+
requestAlignment: clampScore$1(input.requestAlignment),
|
|
5737
|
+
legibility: clampScore$1(input.legibility),
|
|
5738
|
+
blockingIssues: Array.isArray(input.blockingIssues) ? input.blockingIssues : [],
|
|
5739
|
+
summary: typeof input.summary === "string" ? input.summary : "",
|
|
5740
|
+
verdict: coerceVerdict$1(input.verdict)
|
|
5741
|
+
};
|
|
5742
|
+
return result;
|
|
5743
|
+
}
|
|
5744
|
+
return {
|
|
5745
|
+
id: `anthropic:${model}${isOAuth ? ":oauth" : ":api-key"}`,
|
|
5746
|
+
label: `Anthropic ${model}${isOAuth ? " (subscription)" : ""}`,
|
|
5747
|
+
thresholds,
|
|
5748
|
+
review
|
|
5749
|
+
};
|
|
5750
|
+
}
|
|
5751
|
+
const ASPECT_VIEWBOX = {
|
|
5752
|
+
auto: "0 0 1200 900",
|
|
5753
|
+
// 4:3 lands moderate info density
|
|
5754
|
+
square: "0 0 900 900",
|
|
5755
|
+
landscape: "0 0 1400 900",
|
|
5756
|
+
portrait: "0 0 900 1400"
|
|
5757
|
+
};
|
|
5758
|
+
const SVG_SYSTEM_PROMPT = `You are a diagram artist producing publication-quality SVG in the house visual system.
|
|
5759
|
+
Return ONLY a single valid SVG document wrapped in \`\`\`svg fences — no prose, no explanation.
|
|
5760
|
+
|
|
5761
|
+
Allowed SVG 1.1 elements:
|
|
5762
|
+
svg, defs, marker, g, rect, circle, ellipse, line, path, polyline, polygon, text, tspan, title, desc, clipPath, pattern
|
|
5763
|
+
|
|
5764
|
+
Hard requirements:
|
|
5765
|
+
- Root element has an explicit viewBox and no width/height attributes (so it scales).
|
|
5766
|
+
- No <script>, no <foreignObject>, no external href references, no embedded base64.
|
|
5767
|
+
- Font-family: use the house font stack supplied in the REQUEST; fall back to sans-serif when no listed font is available. Minimum font size 10px.
|
|
5768
|
+
- Every labelled element gets a <text> child with readable contents (no lorem ipsum, no placeholder text).
|
|
5769
|
+
- Arrows use <marker> definitions inside <defs>, referenced from <line>/<path> via marker-end — no emoji arrows, no unicode replacement characters.
|
|
5770
|
+
- Use the colour tokens supplied in the REQUEST — do not invent a new palette.
|
|
5771
|
+
- No gradients, filters, blur, or glow anywhere. No drop-shadow, bevel, or emboss effects.
|
|
5772
|
+
- Do NOT include figure numbers, titles, or captions inside the SVG ("Figure 1: …"). Those are added in-document.
|
|
5773
|
+
- Do NOT include <?xml?> declarations; start directly with <svg ...>.`;
|
|
5774
|
+
const SVG_REPAIR_SYSTEM_PROMPT = `You are repairing a previous SVG response that failed to parse.
|
|
5775
|
+
Return ONLY a single valid SVG document wrapped in \`\`\`svg fences — no prose, no commentary, no markdown headings.
|
|
5776
|
+
Hard requirements:
|
|
5777
|
+
- Start with <svg ...> and end with </svg>. No <?xml?> declaration.
|
|
5778
|
+
- Root element MUST have a viewBox attribute.
|
|
5779
|
+
- Every <marker id="X"> referenced via marker-end="url(#X)" must be defined inside <defs>.
|
|
5780
|
+
- All tags must be properly nested and closed.
|
|
5781
|
+
- No <script>, no <foreignObject>, no external href.`;
|
|
5782
|
+
function buildGenerationUser(prompt, aspect) {
|
|
5783
|
+
const viewBox = ASPECT_VIEWBOX[aspect];
|
|
5784
|
+
return `Produce an SVG diagram for the request below.
|
|
5785
|
+
|
|
5786
|
+
VIEWBOX HINT: ${viewBox}
|
|
5787
|
+
- Use this as a starting hint. If your content fits compactly in a
|
|
5788
|
+
smaller area, SHRINK either dimension (by up to 30%) to eliminate
|
|
5789
|
+
empty gutters. A figure with visible empty regions at the bottom
|
|
5790
|
+
or side looks unfinished.
|
|
5791
|
+
- Keep the overall aspect (landscape / portrait / square) close to
|
|
5792
|
+
the hint. Do NOT flip orientation.
|
|
5793
|
+
- Do NOT grow either dimension beyond the hint — that pushes the
|
|
5794
|
+
figure past canonical paper-column widths.
|
|
5795
|
+
|
|
5796
|
+
REQUEST:
|
|
5797
|
+
${prompt}
|
|
5798
|
+
|
|
5799
|
+
Emit the SVG now.`;
|
|
5800
|
+
}
|
|
5801
|
+
function buildEditUser(prompt, previousSvg) {
|
|
5802
|
+
return `Revise the SVG below by applying the changes in REQUEST. Preserve everything that is already correct — do not redraw the whole diagram from scratch.
|
|
5803
|
+
|
|
5804
|
+
VIEWBOX: preserve the viewBox attribute from the PREVIOUS SVG exactly. Do NOT change its dimensions, origin, or aspect.
|
|
5805
|
+
|
|
5806
|
+
REQUEST:
|
|
5807
|
+
${prompt}
|
|
5808
|
+
|
|
5809
|
+
PREVIOUS SVG:
|
|
5810
|
+
\`\`\`svg
|
|
5811
|
+
${previousSvg}
|
|
5812
|
+
\`\`\`
|
|
5813
|
+
|
|
5814
|
+
Emit the revised SVG now, as a single complete <svg>…</svg> document.`;
|
|
5815
|
+
}
|
|
5816
|
+
function buildRepairUser$1(originalUserPrompt, brokenRaw, failureReason) {
|
|
5817
|
+
const HEAD = 1200;
|
|
5818
|
+
const TAIL = 400;
|
|
5819
|
+
const head = brokenRaw.slice(0, HEAD);
|
|
5820
|
+
const tail = brokenRaw.length > HEAD + TAIL ? brokenRaw.slice(-TAIL) : "";
|
|
5821
|
+
const excerpt = tail ? `${head}
|
|
5822
|
+
|
|
5823
|
+
[... ${brokenRaw.length - HEAD - TAIL} chars elided ...]
|
|
5824
|
+
|
|
5825
|
+
${tail}` : head;
|
|
5826
|
+
return `Your previous response could not be parsed as a valid SVG document.
|
|
5827
|
+
|
|
5828
|
+
PARSE FAILURE: ${failureReason}
|
|
5829
|
+
|
|
5830
|
+
ORIGINAL REQUEST (unchanged):
|
|
5831
|
+
${originalUserPrompt}
|
|
5832
|
+
|
|
5833
|
+
YOUR BROKEN RESPONSE (head + tail, ${brokenRaw.length} chars total):
|
|
5834
|
+
${excerpt}
|
|
5835
|
+
|
|
5836
|
+
Emit a corrected SVG now. Output ONLY the SVG inside \`\`\`svg fences — no apology, no explanation.`;
|
|
5837
|
+
}
|
|
5838
|
+
function extractSvg(raw) {
|
|
5839
|
+
const fenceMatch = raw.match(/```(?:svg|xml|html)?\s*([\s\S]*?)```/i);
|
|
5840
|
+
const candidate = fenceMatch ? fenceMatch[1] : raw;
|
|
5841
|
+
const svgMatch = candidate.match(/<svg\b[\s\S]*?<\/svg>/i);
|
|
5842
|
+
if (!svgMatch) {
|
|
5843
|
+
const hasOpening = /<svg\b/i.test(candidate);
|
|
5844
|
+
const reason = hasOpening ? "Found <svg> opening but no matching </svg> close — response likely truncated." : "No <svg>…</svg> block found in the response.";
|
|
5845
|
+
return { ok: false, reason };
|
|
5846
|
+
}
|
|
5847
|
+
return { ok: true, svg: svgMatch[0].trim() };
|
|
5848
|
+
}
|
|
5849
|
+
function validateSvg(svg) {
|
|
5850
|
+
const rootMatch = svg.match(/<svg\b([^>]*)>/i);
|
|
5851
|
+
if (!rootMatch) {
|
|
5852
|
+
return { ok: false, reason: "No <svg> root element found after extraction (should be impossible)." };
|
|
5853
|
+
}
|
|
5854
|
+
if (!/\bviewBox\s*=/i.test(rootMatch[1])) {
|
|
5855
|
+
return { ok: false, reason: "Root <svg> element is missing a viewBox attribute." };
|
|
5856
|
+
}
|
|
5857
|
+
const refIds = /* @__PURE__ */ new Set();
|
|
5858
|
+
const refRe = /marker-(?:start|mid|end)\s*=\s*"url\(#([^)"]+)\)"/gi;
|
|
5859
|
+
let m;
|
|
5860
|
+
while ((m = refRe.exec(svg)) !== null) refIds.add(m[1]);
|
|
5861
|
+
if (refIds.size > 0) {
|
|
5862
|
+
const defIds = /* @__PURE__ */ new Set();
|
|
5863
|
+
const defRe = /<marker\b[^>]*\bid\s*=\s*"([^"]+)"/gi;
|
|
5864
|
+
while ((m = defRe.exec(svg)) !== null) defIds.add(m[1]);
|
|
5865
|
+
const missing = [];
|
|
5866
|
+
for (const id of refIds) if (!defIds.has(id)) missing.push(id);
|
|
5867
|
+
if (missing.length > 0) {
|
|
5868
|
+
return {
|
|
5869
|
+
ok: false,
|
|
5870
|
+
reason: `Marker reference(s) without matching <marker id="…">: ${missing.join(", ")}.`
|
|
5871
|
+
};
|
|
5872
|
+
}
|
|
5873
|
+
}
|
|
5874
|
+
if (!/<text\b/i.test(svg)) {
|
|
5875
|
+
return { ok: false, reason: "No <text> elements present — diagram has no labels." };
|
|
5876
|
+
}
|
|
5877
|
+
const tagRe = /<\s*(\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?(\/?)>/g;
|
|
5878
|
+
const stack = [];
|
|
5879
|
+
while ((m = tagRe.exec(svg)) !== null) {
|
|
5880
|
+
const isClose = m[1] === "/";
|
|
5881
|
+
const isSelfClosing = m[3] === "/";
|
|
5882
|
+
const name = m[2].toLowerCase();
|
|
5883
|
+
if (isClose) {
|
|
5884
|
+
const top = stack.pop();
|
|
5885
|
+
if (top !== name) {
|
|
5886
|
+
return {
|
|
5887
|
+
ok: false,
|
|
5888
|
+
reason: `Tag imbalance: </${name}> at offset ${m.index} does not match the open stack (top=${top ?? "empty"}).`
|
|
5889
|
+
};
|
|
5890
|
+
}
|
|
5891
|
+
} else if (!isSelfClosing) {
|
|
5892
|
+
stack.push(name);
|
|
5893
|
+
}
|
|
5894
|
+
}
|
|
5895
|
+
if (stack.length > 0) {
|
|
5896
|
+
return { ok: false, reason: `Unclosed tag(s) at end of document: ${stack.join(" > ")}.` };
|
|
5897
|
+
}
|
|
5898
|
+
return { ok: true, svg };
|
|
5899
|
+
}
|
|
5900
|
+
function summariseRawForError(raw) {
|
|
5901
|
+
const HEAD = 600;
|
|
5902
|
+
const TAIL = 200;
|
|
5903
|
+
const head = raw.slice(0, HEAD).replace(/\s+/g, " ").trim();
|
|
5904
|
+
if (raw.length <= HEAD + TAIL) return `${raw.length} chars: ${head}`;
|
|
5905
|
+
const tail = raw.slice(-TAIL).replace(/\s+/g, " ").trim();
|
|
5906
|
+
return `${raw.length} chars; head: ${head} … tail: ${tail}`;
|
|
5907
|
+
}
|
|
5908
|
+
function tryParseAndValidate(raw) {
|
|
5909
|
+
const extracted = extractSvg(raw);
|
|
5910
|
+
if (!extracted.ok) return extracted;
|
|
5911
|
+
return validateSvg(extracted.svg);
|
|
5912
|
+
}
|
|
5913
|
+
async function generateWithRepair(callLlm, systemPrompt, userPrompt) {
|
|
5914
|
+
const firstRaw = await callLlm(systemPrompt, userPrompt);
|
|
5915
|
+
const first = tryParseAndValidate(firstRaw);
|
|
5916
|
+
if (first.ok) return first.svg;
|
|
5917
|
+
const repairUser = buildRepairUser$1(userPrompt, firstRaw, first.reason);
|
|
5918
|
+
let secondRaw;
|
|
5919
|
+
try {
|
|
5920
|
+
secondRaw = await callLlm(SVG_REPAIR_SYSTEM_PROMPT, repairUser);
|
|
5921
|
+
} catch (err) {
|
|
5922
|
+
throw new Error(
|
|
5923
|
+
`SVG fallback: parse failed and repair call errored. First failure: ${first.reason}. First response: ${summariseRawForError(firstRaw)}. Repair error: ${err.message}`
|
|
5924
|
+
);
|
|
5925
|
+
}
|
|
5926
|
+
const second = tryParseAndValidate(secondRaw);
|
|
5927
|
+
if (second.ok) return second.svg;
|
|
5928
|
+
throw new Error(
|
|
5929
|
+
`SVG fallback: parse failed twice (initial + repair). First failure: ${first.reason}. Initial response: ${summariseRawForError(firstRaw)}. Repair failure: ${second.reason}. Repair response: ${summariseRawForError(secondRaw)}`
|
|
5930
|
+
);
|
|
5931
|
+
}
|
|
5932
|
+
function createSvgFallbackImageProvider(opts) {
|
|
5933
|
+
const { callLlm } = opts;
|
|
5934
|
+
const aspect = opts.aspect ?? "auto";
|
|
5935
|
+
const modelLabel = opts.modelLabel || "chat-model";
|
|
5936
|
+
const capabilities = /* @__PURE__ */ new Set(["text_to_image", "image_to_image"]);
|
|
5937
|
+
return {
|
|
5938
|
+
id: `svg-fallback:${modelLabel}`,
|
|
5939
|
+
label: `SVG fallback (${modelLabel})`,
|
|
5940
|
+
capabilities,
|
|
5941
|
+
async textToImage(prompt) {
|
|
5942
|
+
const userPrompt = buildGenerationUser(prompt, aspect);
|
|
5943
|
+
const svg = await generateWithRepair(callLlm, SVG_SYSTEM_PROMPT, userPrompt);
|
|
5944
|
+
return Buffer.from(svg, "utf-8");
|
|
5945
|
+
},
|
|
5946
|
+
async imageToImage(prompt, image) {
|
|
5947
|
+
const previousSvg = image.toString("utf-8");
|
|
5948
|
+
const userPrompt = buildEditUser(prompt, previousSvg);
|
|
5949
|
+
const svg = await generateWithRepair(callLlm, SVG_SYSTEM_PROMPT, userPrompt);
|
|
5950
|
+
return Buffer.from(svg, "utf-8");
|
|
5951
|
+
}
|
|
5952
|
+
};
|
|
5953
|
+
}
|
|
5954
|
+
const FALLBACK_THRESHOLDS = {
|
|
5955
|
+
journal: 8.7,
|
|
5956
|
+
conference: 8.2,
|
|
5957
|
+
thesis: 8.2,
|
|
5958
|
+
grant: 8.2,
|
|
5959
|
+
preprint: 7.7,
|
|
5960
|
+
report: 7.7,
|
|
5961
|
+
poster: 7.2,
|
|
5962
|
+
presentation: 6.7,
|
|
5963
|
+
default: 7.7
|
|
5964
|
+
};
|
|
5965
|
+
const REVIEW_SYSTEM_PROMPT = `You are a rigorous reviewer of SVG scientific diagrams. The SVG source is provided; read it as text to evaluate structure, labels, and layout. Use a strict scale — 9+ is reserved for camera-ready figures.
|
|
5966
|
+
|
|
5967
|
+
Always respond with exactly one JSON object wrapped in \`\`\`json fences. No prose before or after. Schema:
|
|
5968
|
+
|
|
5969
|
+
{
|
|
5970
|
+
"score": number, // 0-10 total
|
|
5971
|
+
"requestAlignment": number, // 0-10, matches user intent?
|
|
5972
|
+
"legibility": number, // 0-10, labels readable at intended size?
|
|
5973
|
+
"blockingIssues": [
|
|
5974
|
+
{
|
|
5975
|
+
"kind": "wrong_content" | "illegible_text" | "layout_collision" | "missing_element" | "style_mismatch",
|
|
5976
|
+
"description": string,
|
|
5977
|
+
"fix": string // concrete enough to feed back to the generator
|
|
5978
|
+
}
|
|
5979
|
+
],
|
|
5980
|
+
"summary": string,
|
|
5981
|
+
"verdict": "acceptable" | "needs_edit" | "needs_regen"
|
|
5982
|
+
}
|
|
5983
|
+
|
|
5984
|
+
Verdict rules:
|
|
5985
|
+
- "acceptable" if score >= threshold AND blockingIssues is empty
|
|
5986
|
+
- "needs_edit" for localised problems (labels, overlaps, styling) fixable by revising the SVG in place
|
|
5987
|
+
- "needs_regen" for structural or content errors — the diagram must be redrawn`;
|
|
5988
|
+
function buildUser(req, threshold, svgSource) {
|
|
5989
|
+
const houseBlock = req.houseProfileSummary ? `
|
|
5990
|
+
HOUSE STYLE (figure must belong to this visual system):
|
|
5991
|
+
${req.houseProfileSummary}
|
|
5992
|
+
|
|
5993
|
+
Under blockingIssues of kind style_mismatch, call out any deviation from this house style — wrong palette tokens, wrong stroke widths, wrong corner radii, wrong typography voice, broken motifs.
|
|
5994
|
+
` : "";
|
|
5995
|
+
return `Evaluate this diagram for "${req.docType}" publication (acceptance threshold: ${threshold}/10).
|
|
5996
|
+
|
|
5997
|
+
DIAGRAM TYPE: ${req.diagramType}
|
|
5998
|
+
ORIGINAL REQUEST: ${req.prompt}
|
|
5999
|
+
ITERATION: ${req.iteration}/${req.maxIterations}
|
|
6000
|
+
${houseBlock}
|
|
6001
|
+
SVG SOURCE:
|
|
6002
|
+
\`\`\`svg
|
|
6003
|
+
${svgSource}
|
|
6004
|
+
\`\`\`
|
|
6005
|
+
|
|
6006
|
+
Respond with the JSON review object only.`;
|
|
6007
|
+
}
|
|
6008
|
+
function coerceVerdict(raw) {
|
|
6009
|
+
if (raw === "acceptable" || raw === "needs_edit" || raw === "needs_regen") return raw;
|
|
6010
|
+
return "needs_edit";
|
|
6011
|
+
}
|
|
6012
|
+
function clampScore(n) {
|
|
6013
|
+
const v = typeof n === "number" ? n : Number(n);
|
|
6014
|
+
if (Number.isNaN(v)) return 0;
|
|
6015
|
+
return Math.min(10, Math.max(0, v));
|
|
6016
|
+
}
|
|
6017
|
+
function extractJsonObject(text) {
|
|
6018
|
+
const fenceMatch = text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/i);
|
|
6019
|
+
if (fenceMatch) {
|
|
6020
|
+
try {
|
|
6021
|
+
return JSON.parse(fenceMatch[1]);
|
|
6022
|
+
} catch {
|
|
6023
|
+
}
|
|
6024
|
+
}
|
|
6025
|
+
const firstBrace = text.indexOf("{");
|
|
6026
|
+
if (firstBrace === -1) return null;
|
|
6027
|
+
let depth = 0;
|
|
6028
|
+
for (let i = firstBrace; i < text.length; i++) {
|
|
6029
|
+
const ch = text[i];
|
|
6030
|
+
if (ch === "{") depth++;
|
|
6031
|
+
else if (ch === "}") {
|
|
6032
|
+
depth--;
|
|
6033
|
+
if (depth === 0) {
|
|
6034
|
+
try {
|
|
6035
|
+
return JSON.parse(text.slice(firstBrace, i + 1));
|
|
6036
|
+
} catch {
|
|
6037
|
+
return null;
|
|
6038
|
+
}
|
|
6039
|
+
}
|
|
6040
|
+
}
|
|
6041
|
+
}
|
|
6042
|
+
return null;
|
|
6043
|
+
}
|
|
6044
|
+
function createSvgFallbackReviewProvider(opts) {
|
|
6045
|
+
const { callLlm } = opts;
|
|
6046
|
+
const modelLabel = opts.modelLabel || "chat-model";
|
|
6047
|
+
const thresholds = opts.thresholds ?? FALLBACK_THRESHOLDS;
|
|
6048
|
+
async function review(req) {
|
|
6049
|
+
const threshold = thresholds[req.docType] ?? thresholds.default;
|
|
6050
|
+
const svgSource = req.image.toString("utf-8");
|
|
6051
|
+
const text = await callLlm(REVIEW_SYSTEM_PROMPT, buildUser(req, threshold, svgSource));
|
|
6052
|
+
const parsed = extractJsonObject(text);
|
|
6053
|
+
if (!parsed) {
|
|
6054
|
+
return {
|
|
6055
|
+
score: Math.max(0, threshold - 0.5),
|
|
6056
|
+
requestAlignment: Math.max(0, threshold - 0.5),
|
|
6057
|
+
legibility: Math.max(0, threshold - 0.5),
|
|
6058
|
+
blockingIssues: [{
|
|
6059
|
+
kind: "style_mismatch",
|
|
6060
|
+
description: "Reviewer did not return a parseable structured response.",
|
|
6061
|
+
fix: "Regenerate with clearer composition; ensure all labels are explicit and arrows have markers."
|
|
6062
|
+
}],
|
|
6063
|
+
summary: "Review parse failed — falling back to conservative needs_edit verdict.",
|
|
6064
|
+
verdict: "needs_edit"
|
|
6065
|
+
};
|
|
6066
|
+
}
|
|
6067
|
+
return {
|
|
6068
|
+
score: clampScore(parsed.score),
|
|
6069
|
+
requestAlignment: clampScore(parsed.requestAlignment),
|
|
6070
|
+
legibility: clampScore(parsed.legibility),
|
|
6071
|
+
blockingIssues: Array.isArray(parsed.blockingIssues) ? parsed.blockingIssues : [],
|
|
6072
|
+
summary: typeof parsed.summary === "string" ? parsed.summary : "",
|
|
6073
|
+
verdict: coerceVerdict(parsed.verdict)
|
|
6074
|
+
};
|
|
6075
|
+
}
|
|
6076
|
+
return {
|
|
6077
|
+
id: `svg-fallback:${modelLabel}`,
|
|
6078
|
+
label: `SVG fallback review (${modelLabel})`,
|
|
6079
|
+
thresholds,
|
|
6080
|
+
review
|
|
6081
|
+
};
|
|
6082
|
+
}
|
|
6083
|
+
function createRasterizeThenReviewProvider(opts) {
|
|
6084
|
+
const { rasterizer, inner, dimensions } = opts;
|
|
6085
|
+
async function review(req) {
|
|
6086
|
+
const png = await rasterizer(req.image, dimensions);
|
|
6087
|
+
return inner.review({ ...req, image: png });
|
|
6088
|
+
}
|
|
6089
|
+
return {
|
|
6090
|
+
id: `rasterize+${inner.id}`,
|
|
6091
|
+
label: `SVG rasterised → ${inner.label}`,
|
|
6092
|
+
thresholds: inner.thresholds,
|
|
6093
|
+
review
|
|
6094
|
+
};
|
|
6095
|
+
}
|
|
6096
|
+
const TRANSCRIPTION_SYSTEM_PROMPT = `You transcribe a finalized scientific diagram (provided as a PNG image) into clean, editable SVG markup.
|
|
6097
|
+
|
|
6098
|
+
The PNG is the ground truth. Your job is to produce SVG that, when rendered, is a faithful structural copy — NOT to redesign, improve, or "polish" anything.
|
|
6099
|
+
|
|
6100
|
+
Output rules (strict):
|
|
6101
|
+
- Output ONLY a single SVG document wrapped in \`\`\`svg fences. No prose, no apology, no commentary.
|
|
6102
|
+
- Start directly with <svg viewBox="…" xmlns="http://www.w3.org/2000/svg">. No <?xml?> declaration. No width/height attributes on root.
|
|
6103
|
+
- Use only these elements: svg, defs, marker, g, rect, circle, ellipse, line, path, polyline, polygon, text, tspan, title, desc.
|
|
6104
|
+
- No <script>, no <foreignObject>, no external href, no embedded base64 images.
|
|
6105
|
+
- No filters, no gradients, no blur, no drop-shadow.
|
|
6106
|
+
|
|
6107
|
+
Transcription requirements:
|
|
6108
|
+
- For every box you see → emit one <rect> at approximately the same position and size (drift up to ~20px is acceptable).
|
|
6109
|
+
- For every label you see → emit one <text> with the label VERBATIM. Never paraphrase, never abbreviate, never translate.
|
|
6110
|
+
- For every arrow you see → emit one <line>, <polyline>, or <path> with marker-end referencing a <marker> in <defs>.
|
|
6111
|
+
- Sample dominant colors from the PNG and use them directly as fill/stroke values (hex codes).
|
|
6112
|
+
- Match the rough rounded-corner radius you observe (sharp = 0, slightly rounded = 6-8, very rounded = 12+).
|
|
6113
|
+
- Preserve grouping: dashed-bordered containers in the PNG → <rect> with stroke-dasharray.
|
|
6114
|
+
|
|
6115
|
+
Hard prohibitions:
|
|
6116
|
+
- Do NOT add labels, captions, legends, watermarks, or any element not visible in the PNG.
|
|
6117
|
+
- Do NOT redesign the layout, change the topology, or "fix" perceived issues.
|
|
6118
|
+
- Do NOT include figure numbers or titles ("Figure 1: …").
|
|
6119
|
+
- Do NOT use placeholder text like "Lorem ipsum" or "[Label]".
|
|
6120
|
+
|
|
6121
|
+
Position drift up to ~20px is acceptable. Missing labels, paraphrased text, or invented elements are NOT.`;
|
|
6122
|
+
const REPAIR_SYSTEM_PROMPT = `You are repairing a previous SVG transcription that failed to parse.
|
|
6123
|
+
Return ONLY a single valid SVG document wrapped in \`\`\`svg fences — no prose.
|
|
6124
|
+
Hard requirements:
|
|
6125
|
+
- Start with <svg ...> and end with </svg>. No <?xml?> declaration.
|
|
6126
|
+
- Root MUST have a viewBox attribute.
|
|
6127
|
+
- Every <marker id="X"> referenced via marker-end="url(#X)" must be defined inside <defs>.
|
|
6128
|
+
- All tags properly nested and closed.
|
|
6129
|
+
- Preserve every <text> element verbatim from the original PNG content — do not drop labels.`;
|
|
6130
|
+
function buildTranscriptionUser(req) {
|
|
6131
|
+
const lines = [];
|
|
6132
|
+
lines.push("Transcribe the attached PNG diagram into editable SVG.");
|
|
6133
|
+
lines.push("");
|
|
6134
|
+
if (req.viewBoxHint) {
|
|
6135
|
+
lines.push(`VIEWBOX HINT: ${req.viewBoxHint}`);
|
|
6136
|
+
lines.push(" - Match the PNG aspect ratio within ±10%.");
|
|
6137
|
+
lines.push(" - Do NOT flip orientation.");
|
|
6138
|
+
lines.push("");
|
|
6139
|
+
}
|
|
6140
|
+
if (req.originalPromptHint) {
|
|
6141
|
+
lines.push("ORIGINAL DIAGRAM REQUEST (for disambiguating any unclear labels):");
|
|
6142
|
+
lines.push(req.originalPromptHint);
|
|
6143
|
+
lines.push("");
|
|
6144
|
+
}
|
|
6145
|
+
lines.push("Emit the SVG now. Output ONLY the SVG inside ```svg fences.");
|
|
6146
|
+
return lines.join("\n");
|
|
6147
|
+
}
|
|
6148
|
+
function buildRepairUser(originalUserPrompt, brokenRaw, failureReason) {
|
|
6149
|
+
const HEAD = 1200;
|
|
6150
|
+
const TAIL = 400;
|
|
6151
|
+
const head = brokenRaw.slice(0, HEAD);
|
|
6152
|
+
const tail = brokenRaw.length > HEAD + TAIL ? brokenRaw.slice(-TAIL) : "";
|
|
6153
|
+
const excerpt = tail ? `${head}
|
|
6154
|
+
|
|
6155
|
+
[... ${brokenRaw.length - HEAD - TAIL} chars elided ...]
|
|
6156
|
+
|
|
6157
|
+
${tail}` : head;
|
|
6158
|
+
return `Your previous SVG transcription could not be parsed.
|
|
6159
|
+
|
|
6160
|
+
PARSE FAILURE: ${failureReason}
|
|
6161
|
+
|
|
6162
|
+
ORIGINAL REQUEST (unchanged):
|
|
6163
|
+
${originalUserPrompt}
|
|
6164
|
+
|
|
6165
|
+
YOUR BROKEN RESPONSE (head + tail, ${brokenRaw.length} chars total):
|
|
6166
|
+
${excerpt}
|
|
6167
|
+
|
|
6168
|
+
Re-transcribe the attached PNG into a corrected SVG. The PNG is unchanged — produce a faithful structural copy. Output ONLY the SVG inside \`\`\`svg fences.`;
|
|
6169
|
+
}
|
|
6170
|
+
async function transcribePngToSvg(opts, req) {
|
|
6171
|
+
const image = {
|
|
6172
|
+
base64: req.png.toString("base64"),
|
|
6173
|
+
mimeType: "image/png"
|
|
6174
|
+
};
|
|
6175
|
+
const userPrompt = buildTranscriptionUser(req);
|
|
6176
|
+
const firstRaw = await opts.callLlmVision(
|
|
6177
|
+
TRANSCRIPTION_SYSTEM_PROMPT,
|
|
6178
|
+
userPrompt,
|
|
6179
|
+
[image]
|
|
6180
|
+
);
|
|
6181
|
+
const first = tryParseAndValidate(firstRaw);
|
|
6182
|
+
if (first.ok) {
|
|
6183
|
+
return { svg: first.svg, repaired: false };
|
|
6184
|
+
}
|
|
6185
|
+
const repairUser = buildRepairUser(userPrompt, firstRaw, first.reason);
|
|
6186
|
+
let secondRaw;
|
|
6187
|
+
try {
|
|
6188
|
+
secondRaw = await opts.callLlmVision(
|
|
6189
|
+
REPAIR_SYSTEM_PROMPT,
|
|
6190
|
+
repairUser,
|
|
6191
|
+
[image]
|
|
6192
|
+
);
|
|
6193
|
+
} catch (err) {
|
|
6194
|
+
throw new Error(
|
|
6195
|
+
`PNG-to-SVG transcription: parse failed and repair call errored. First failure: ${first.reason}. First response: ${summariseRawForError(firstRaw)}. Repair error: ${err.message}`
|
|
6196
|
+
);
|
|
6197
|
+
}
|
|
6198
|
+
const second = tryParseAndValidate(secondRaw);
|
|
6199
|
+
if (second.ok) {
|
|
6200
|
+
return { svg: second.svg, repaired: true, firstAttemptFailure: first.reason };
|
|
6201
|
+
}
|
|
6202
|
+
throw new Error(
|
|
6203
|
+
`PNG-to-SVG transcription: parse failed twice (initial + repair). First failure: ${first.reason}. Initial response: ${summariseRawForError(firstRaw)}. Repair failure: ${second.reason}. Repair response: ${summariseRawForError(secondRaw)}`
|
|
6204
|
+
);
|
|
6205
|
+
}
|
|
6206
|
+
function resolveAuth(auth) {
|
|
6207
|
+
if (auth) {
|
|
6208
|
+
return {
|
|
6209
|
+
openaiKey: auth.openaiKey ?? null,
|
|
6210
|
+
anthropic: auth.anthropic ?? null
|
|
6211
|
+
};
|
|
6212
|
+
}
|
|
6213
|
+
const envOpenAI = process.env.OPENAI_API_KEY?.trim() || null;
|
|
6214
|
+
const envAnthropic = process.env.ANTHROPIC_API_KEY?.trim();
|
|
6215
|
+
return {
|
|
6216
|
+
openaiKey: envOpenAI,
|
|
6217
|
+
anthropic: envAnthropic ? { token: envAnthropic, isOAuth: false } : null
|
|
6218
|
+
};
|
|
6219
|
+
}
|
|
6220
|
+
const ASPECT_TO_VIEWBOX = {
|
|
6221
|
+
auto: "0 0 1200 900",
|
|
6222
|
+
square: "0 0 900 900",
|
|
6223
|
+
landscape: "0 0 1400 900",
|
|
6224
|
+
portrait: "0 0 900 1400"
|
|
6225
|
+
};
|
|
6226
|
+
function buildPngToSvgTranscriber(callLlmVision, visionModelLabel, viewBoxHint) {
|
|
6227
|
+
return async (png, originalPromptHint) => {
|
|
6228
|
+
const result = await transcribePngToSvg(
|
|
6229
|
+
{ callLlmVision, modelLabel: visionModelLabel },
|
|
6230
|
+
{ png, viewBoxHint, originalPromptHint }
|
|
6231
|
+
);
|
|
6232
|
+
return {
|
|
6233
|
+
svg: result.svg,
|
|
6234
|
+
repaired: result.repaired,
|
|
6235
|
+
firstAttemptFailure: result.firstAttemptFailure,
|
|
6236
|
+
visionModelLabel
|
|
6237
|
+
};
|
|
6238
|
+
};
|
|
6239
|
+
}
|
|
6240
|
+
function pickImageProvider(prefs, auth, fallback) {
|
|
6241
|
+
const choice = prefs.generation ?? "openai";
|
|
6242
|
+
if (prefs.forceSvg) {
|
|
6243
|
+
if (auth.openaiKey) {
|
|
6244
|
+
if (fallback?.callLlmVision && fallback.visionCapable !== false) {
|
|
6245
|
+
const provider = createOpenAIImageProvider({
|
|
6246
|
+
apiKey: auth.openaiKey,
|
|
6247
|
+
model: prefs.imageModel,
|
|
6248
|
+
size: prefs.imageSize,
|
|
6249
|
+
quality: prefs.imageQuality
|
|
6250
|
+
});
|
|
6251
|
+
const transcriber = buildPngToSvgTranscriber(
|
|
6252
|
+
fallback.callLlmVision,
|
|
6253
|
+
fallback.modelLabel || "chat-model",
|
|
6254
|
+
ASPECT_TO_VIEWBOX[prefs.svgAspect ?? "auto"]
|
|
6255
|
+
);
|
|
6256
|
+
return { provider, pngToSvgTranscriber: transcriber, svgPath: "png_anchored" };
|
|
6257
|
+
}
|
|
6258
|
+
throw new Error(
|
|
6259
|
+
"SVG_REQUIRES_VISION_MODEL: Editable SVG output requires a vision-capable chat model (e.g. GPT-4o, Claude Opus, Gemini 2.5) to transcribe the rendered PNG into SVG markup. Either switch to a vision-capable model in Settings, or request a raster output (.png)."
|
|
6260
|
+
);
|
|
6261
|
+
}
|
|
6262
|
+
if (fallback) {
|
|
6263
|
+
const provider = createSvgFallbackImageProvider({
|
|
6264
|
+
callLlm: fallback.callLlm,
|
|
6265
|
+
modelLabel: fallback.modelLabel,
|
|
6266
|
+
aspect: prefs.svgAspect
|
|
6267
|
+
});
|
|
6268
|
+
return { provider, svgPath: "chat_model_only" };
|
|
6269
|
+
}
|
|
6270
|
+
throw new Error(
|
|
6271
|
+
"SVG output requested but neither an OpenAI API key (for the PNG-anchored path) nor a chat model (for the safety-net path) is configured."
|
|
6272
|
+
);
|
|
6273
|
+
}
|
|
6274
|
+
if (choice === "openai") {
|
|
6275
|
+
if (auth.openaiKey) {
|
|
6276
|
+
return {
|
|
6277
|
+
provider: createOpenAIImageProvider({
|
|
6278
|
+
apiKey: auth.openaiKey,
|
|
6279
|
+
model: prefs.imageModel,
|
|
6280
|
+
size: prefs.imageSize,
|
|
6281
|
+
quality: prefs.imageQuality
|
|
6282
|
+
}),
|
|
6283
|
+
svgPath: null
|
|
6284
|
+
};
|
|
6285
|
+
}
|
|
6286
|
+
if (fallback) {
|
|
6287
|
+
return {
|
|
6288
|
+
provider: createSvgFallbackImageProvider({
|
|
6289
|
+
callLlm: fallback.callLlm,
|
|
6290
|
+
modelLabel: fallback.modelLabel,
|
|
6291
|
+
aspect: prefs.svgAspect
|
|
6292
|
+
}),
|
|
6293
|
+
svgPath: "chat_model_only"
|
|
6294
|
+
};
|
|
6295
|
+
}
|
|
6296
|
+
throw new Error(
|
|
6297
|
+
"Diagram generation requires either OPENAI_API_KEY or a callLlm fallback (which the coordinator supplies from the current chat model). Neither is available here."
|
|
6298
|
+
);
|
|
6299
|
+
}
|
|
6300
|
+
throw new Error(`Unknown generation provider: ${choice}`);
|
|
6301
|
+
}
|
|
6302
|
+
function buildRealReviewProvider(choice, auth, reviewModel) {
|
|
6303
|
+
if (choice === "openai") {
|
|
6304
|
+
if (!auth.openaiKey) return null;
|
|
6305
|
+
return createOpenAIReviewProvider({ apiKey: auth.openaiKey, model: reviewModel });
|
|
6306
|
+
}
|
|
6307
|
+
if (choice === "anthropic") {
|
|
6308
|
+
if (!auth.anthropic) return null;
|
|
6309
|
+
return createAnthropicReviewProvider({
|
|
6310
|
+
token: auth.anthropic.token,
|
|
6311
|
+
isOAuth: auth.anthropic.isOAuth,
|
|
6312
|
+
refreshToken: auth.anthropic.refresh,
|
|
6313
|
+
model: reviewModel
|
|
6314
|
+
});
|
|
6315
|
+
}
|
|
6316
|
+
if (auth.anthropic) {
|
|
6317
|
+
return createAnthropicReviewProvider({
|
|
6318
|
+
token: auth.anthropic.token,
|
|
6319
|
+
isOAuth: auth.anthropic.isOAuth,
|
|
6320
|
+
refreshToken: auth.anthropic.refresh,
|
|
6321
|
+
model: reviewModel
|
|
6322
|
+
});
|
|
6323
|
+
}
|
|
6324
|
+
if (auth.openaiKey) {
|
|
6325
|
+
return createOpenAIReviewProvider({ apiKey: auth.openaiKey, model: reviewModel });
|
|
6326
|
+
}
|
|
6327
|
+
return null;
|
|
6328
|
+
}
|
|
6329
|
+
function pickReviewProvider(prefs, auth, fallback, usingSvgGen = false) {
|
|
6330
|
+
const choice = prefs.review ?? "auto";
|
|
6331
|
+
if (usingSvgGen) {
|
|
6332
|
+
if (!fallback) {
|
|
6333
|
+
throw new Error("SVG review fallback requires a callLlm fallback.");
|
|
6334
|
+
}
|
|
6335
|
+
if (fallback.rasterizeSvg) {
|
|
6336
|
+
const visionReviewer = buildRealReviewProvider(choice, auth, prefs.reviewModel);
|
|
6337
|
+
if (visionReviewer) {
|
|
6338
|
+
return createRasterizeThenReviewProvider({
|
|
6339
|
+
rasterizer: fallback.rasterizeSvg,
|
|
6340
|
+
inner: visionReviewer
|
|
6341
|
+
});
|
|
6342
|
+
}
|
|
6343
|
+
}
|
|
6344
|
+
return createSvgFallbackReviewProvider({
|
|
6345
|
+
callLlm: fallback.callLlm,
|
|
6346
|
+
modelLabel: fallback.modelLabel
|
|
6347
|
+
});
|
|
6348
|
+
}
|
|
6349
|
+
if (choice === "openai") {
|
|
6350
|
+
if (!auth.openaiKey) {
|
|
6351
|
+
throw new Error("Review provider set to OpenAI but no OpenAI API key is configured.");
|
|
6352
|
+
}
|
|
6353
|
+
return createOpenAIReviewProvider({ apiKey: auth.openaiKey, model: prefs.reviewModel });
|
|
6354
|
+
}
|
|
6355
|
+
if (choice === "anthropic") {
|
|
6356
|
+
if (!auth.anthropic) {
|
|
6357
|
+
throw new Error(
|
|
6358
|
+
"Review provider set to Anthropic but no Claude credentials are available. Either set ANTHROPIC_API_KEY under Settings → API Keys, or sign in via Claude subscription login."
|
|
6359
|
+
);
|
|
6360
|
+
}
|
|
6361
|
+
return createAnthropicReviewProvider({
|
|
6362
|
+
token: auth.anthropic.token,
|
|
6363
|
+
isOAuth: auth.anthropic.isOAuth,
|
|
6364
|
+
refreshToken: auth.anthropic.refresh,
|
|
6365
|
+
model: prefs.reviewModel
|
|
6366
|
+
});
|
|
6367
|
+
}
|
|
6368
|
+
if (auth.anthropic) {
|
|
6369
|
+
return createAnthropicReviewProvider({
|
|
6370
|
+
token: auth.anthropic.token,
|
|
6371
|
+
isOAuth: auth.anthropic.isOAuth,
|
|
6372
|
+
refreshToken: auth.anthropic.refresh,
|
|
6373
|
+
model: prefs.reviewModel
|
|
6374
|
+
});
|
|
6375
|
+
}
|
|
6376
|
+
if (auth.openaiKey) {
|
|
6377
|
+
return createOpenAIReviewProvider({ apiKey: auth.openaiKey, model: prefs.reviewModel });
|
|
6378
|
+
}
|
|
6379
|
+
throw new Error("No review provider is configured. Add OPENAI_API_KEY or sign in to Claude.");
|
|
6380
|
+
}
|
|
6381
|
+
function resolveProviders(prefs = {}, auth, fallback) {
|
|
6382
|
+
const resolved = resolveAuth(auth);
|
|
6383
|
+
const picked = pickImageProvider(prefs, resolved, fallback);
|
|
6384
|
+
const usingSvgGen = picked.svgPath === "chat_model_only";
|
|
6385
|
+
const review = pickReviewProvider(prefs, resolved, fallback, usingSvgGen);
|
|
6386
|
+
return {
|
|
6387
|
+
image: picked.provider,
|
|
6388
|
+
review,
|
|
6389
|
+
svgFallback: usingSvgGen,
|
|
6390
|
+
pngToSvgTranscriber: picked.pngToSvgTranscriber,
|
|
6391
|
+
svgPath: picked.svgPath ?? null
|
|
6392
|
+
};
|
|
6393
|
+
}
|
|
6394
|
+
const KIND_BUCKET = {
|
|
6395
|
+
layout_collision: "visual",
|
|
6396
|
+
style_mismatch: "visual",
|
|
6397
|
+
illegible_text: "visual",
|
|
6398
|
+
missing_element: "content",
|
|
6399
|
+
wrong_content: "content"
|
|
6400
|
+
};
|
|
6401
|
+
const PREFIX_LEN = 20;
|
|
6402
|
+
const MIN_SHARED_TOKENS = 5;
|
|
6403
|
+
const MIN_JACCARD = 0.2;
|
|
6404
|
+
function normalise(s) {
|
|
6405
|
+
return s.toLowerCase().replace(/\s+/g, " ").trim();
|
|
6406
|
+
}
|
|
6407
|
+
function panelNumbers(s) {
|
|
6408
|
+
const out = /* @__PURE__ */ new Set();
|
|
6409
|
+
for (const m of normalise(s).matchAll(/panel\s+(\d+)/g)) out.add(m[1]);
|
|
6410
|
+
return out;
|
|
6411
|
+
}
|
|
6412
|
+
function distinctiveTokens(s) {
|
|
6413
|
+
return new Set(
|
|
6414
|
+
normalise(s).replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length >= 4)
|
|
6415
|
+
);
|
|
6416
|
+
}
|
|
6417
|
+
function issuesMatch(a, b) {
|
|
6418
|
+
if (KIND_BUCKET[a.kind] !== KIND_BUCKET[b.kind]) return false;
|
|
6419
|
+
const panelsA = panelNumbers(a.description);
|
|
6420
|
+
const panelsB = panelNumbers(b.description);
|
|
6421
|
+
if (panelsA.size > 0 && panelsB.size > 0) {
|
|
6422
|
+
let hasShared = false;
|
|
6423
|
+
for (const p of panelsA) {
|
|
6424
|
+
if (panelsB.has(p)) {
|
|
6425
|
+
hasShared = true;
|
|
6426
|
+
break;
|
|
6427
|
+
}
|
|
6428
|
+
}
|
|
6429
|
+
if (!hasShared) return false;
|
|
6430
|
+
}
|
|
6431
|
+
const aDesc = normalise(a.description);
|
|
6432
|
+
const bDesc = normalise(b.description);
|
|
6433
|
+
if (!aDesc || !bDesc) return false;
|
|
6434
|
+
const prefixLen = Math.min(PREFIX_LEN, aDesc.length, bDesc.length);
|
|
6435
|
+
if (prefixLen >= 10 && aDesc.slice(0, prefixLen) === bDesc.slice(0, prefixLen)) {
|
|
6436
|
+
return true;
|
|
6437
|
+
}
|
|
6438
|
+
const aTokens = distinctiveTokens(a.description);
|
|
6439
|
+
const bTokens = distinctiveTokens(b.description);
|
|
6440
|
+
let shared = 0;
|
|
6441
|
+
for (const t of aTokens) if (bTokens.has(t)) shared++;
|
|
6442
|
+
if (shared < MIN_SHARED_TOKENS) return false;
|
|
6443
|
+
const union = aTokens.size + bTokens.size - shared;
|
|
6444
|
+
if (union === 0) return false;
|
|
6445
|
+
return shared / union >= MIN_JACCARD;
|
|
6446
|
+
}
|
|
6447
|
+
function findMatch(target, pool) {
|
|
6448
|
+
return pool.find((candidate) => issuesMatch(target, candidate));
|
|
6449
|
+
}
|
|
6450
|
+
function fixedBetween(previous, current) {
|
|
6451
|
+
return previous.filter((p) => !findMatch(p, current));
|
|
6452
|
+
}
|
|
6453
|
+
function regressionsAgainst(fixedHistory, current) {
|
|
6454
|
+
return current.filter((c) => findMatch(c, fixedHistory));
|
|
6455
|
+
}
|
|
6456
|
+
const VALID_DOC_TYPES = [
|
|
6457
|
+
"journal",
|
|
6458
|
+
"conference",
|
|
6459
|
+
"thesis",
|
|
6460
|
+
"grant",
|
|
6461
|
+
"preprint",
|
|
6462
|
+
"report",
|
|
6463
|
+
"poster",
|
|
6464
|
+
"presentation",
|
|
6465
|
+
"default"
|
|
6466
|
+
];
|
|
6467
|
+
const VALID_DIAGRAM_TYPES = [
|
|
6468
|
+
"flowchart",
|
|
6469
|
+
"architecture",
|
|
6470
|
+
"pathway",
|
|
6471
|
+
"circuit",
|
|
6472
|
+
"network",
|
|
6473
|
+
"conceptual",
|
|
6474
|
+
"auto"
|
|
6475
|
+
];
|
|
6476
|
+
const VALID_REFERENCE_MODES = [
|
|
6477
|
+
"revise_layout",
|
|
6478
|
+
"style_only",
|
|
6479
|
+
"local_edit"
|
|
6480
|
+
];
|
|
6481
|
+
const SUPPORTED_REFERENCE_MODES = ["revise_layout", "style_only"];
|
|
6482
|
+
const VALID_ASPECTS = ["auto", "square", "landscape", "portrait"];
|
|
6483
|
+
function aspectToSize(aspect) {
|
|
6484
|
+
switch (aspect) {
|
|
6485
|
+
case "square":
|
|
6486
|
+
return "1024x1024";
|
|
6487
|
+
case "landscape":
|
|
6488
|
+
return "1536x1024";
|
|
6489
|
+
case "portrait":
|
|
6490
|
+
return "1024x1536";
|
|
6491
|
+
case "auto":
|
|
6492
|
+
return "auto";
|
|
6493
|
+
}
|
|
6494
|
+
}
|
|
6495
|
+
function sanitizeAspect(value) {
|
|
6496
|
+
const v = typeof value === "string" ? value.trim().toLowerCase() : "auto";
|
|
6497
|
+
return VALID_ASPECTS.includes(v) ? v : "auto";
|
|
6498
|
+
}
|
|
6499
|
+
const VALID_FORMATS = ["auto", "png", "svg"];
|
|
6500
|
+
function sanitizeFormat(value) {
|
|
6501
|
+
const v = typeof value === "string" ? value.trim().toLowerCase() : "auto";
|
|
6502
|
+
return VALID_FORMATS.includes(v) ? v : "auto";
|
|
6503
|
+
}
|
|
6504
|
+
function resolveEffectiveFormat(format, extension) {
|
|
6505
|
+
if (format === "svg") return "svg";
|
|
6506
|
+
if (format === "png") return "png";
|
|
6507
|
+
return extension.toLowerCase() === ".svg" ? "svg" : "png";
|
|
6508
|
+
}
|
|
6509
|
+
const VALID_QUALITIES = ["low", "medium", "high", "auto"];
|
|
6510
|
+
const QUALITY_ORDER = ["low", "medium", "high"];
|
|
6511
|
+
function defaultQualityForDocType(docType) {
|
|
6512
|
+
switch (docType) {
|
|
6513
|
+
case "journal":
|
|
6514
|
+
case "conference":
|
|
6515
|
+
case "thesis":
|
|
6516
|
+
case "grant":
|
|
6517
|
+
return "high";
|
|
6518
|
+
case "preprint":
|
|
6519
|
+
case "report":
|
|
6520
|
+
case "poster":
|
|
6521
|
+
return "medium";
|
|
6522
|
+
case "presentation":
|
|
6523
|
+
return "low";
|
|
6524
|
+
default:
|
|
6525
|
+
return "medium";
|
|
6526
|
+
}
|
|
6527
|
+
}
|
|
6528
|
+
function sanitizeQuality(value) {
|
|
6529
|
+
if (value === void 0 || value === null) return void 0;
|
|
6530
|
+
const v = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
6531
|
+
return VALID_QUALITIES.includes(v) ? v : void 0;
|
|
6532
|
+
}
|
|
6533
|
+
function bumpedQuality(current) {
|
|
6534
|
+
const anchor = current === "auto" ? "medium" : current;
|
|
6535
|
+
const idx = QUALITY_ORDER.indexOf(anchor);
|
|
6536
|
+
if (idx < 0 || idx >= QUALITY_ORDER.length - 1) return current;
|
|
6537
|
+
return QUALITY_ORDER[idx + 1];
|
|
6538
|
+
}
|
|
6539
|
+
const GenerateDiagramSchema = Type.Object({
|
|
6540
|
+
prompt: Type.String({
|
|
6541
|
+
description: "Natural-language description of the diagram to generate. Be specific about components, labels, layout, and quantities."
|
|
6542
|
+
}),
|
|
6543
|
+
output: Type.String({
|
|
6544
|
+
description: 'Workspace-relative output path (e.g. "figures/consort.png"). Parent directories are created if missing.'
|
|
6545
|
+
}),
|
|
6546
|
+
doc_type: Type.Optional(Type.String({
|
|
6547
|
+
description: `Target publication venue. One of: ${VALID_DOC_TYPES.join(" | ")}. Controls quality threshold.`
|
|
6548
|
+
})),
|
|
6549
|
+
diagram_type: Type.Optional(Type.String({
|
|
6550
|
+
description: `Diagram category. One of: ${VALID_DIAGRAM_TYPES.join(" | ")}. Defaults to auto (keyword-detected).`
|
|
6551
|
+
})),
|
|
6552
|
+
iterations: Type.Optional(Type.Number({
|
|
6553
|
+
description: "Maximum refinement iterations (1-3). Each costs one generation + one review. Default: 2."
|
|
6554
|
+
})),
|
|
6555
|
+
aspect: Type.Optional(Type.String({
|
|
6556
|
+
description: 'Output aspect ratio. One of: auto | square | landscape | portrait. Default: auto (model picks from the prompt). Use "landscape" for wide architecture / flow diagrams with >3 horizontal panels, "portrait" for top-to-bottom CONSORT / PRISMA flows, "square" for single-concept schematics.'
|
|
6557
|
+
})),
|
|
6558
|
+
format: Type.Optional(Type.String({
|
|
6559
|
+
description: 'Desired output format: auto | png | svg. Default: auto (inferred from the output extension). Pass "svg" when the user says "SVG / 矢量图 / vector / 向量图", pass "png" when they say "PNG / 图片 / raster". Explicit format beats the extension: if format="svg" but output="foo.png", the file is renamed to foo.svg and extensionChanged is reported.'
|
|
6560
|
+
})),
|
|
6561
|
+
quality: Type.Optional(Type.String({
|
|
6562
|
+
description: "gpt-image-2 rendering quality: low | medium | high | auto. When omitted, defaults from doc_type — high for journal/conference/thesis/grant, medium for preprint/report/poster, low for presentation. Start at low for cheap exploration; the tool automatically bumps one tier on a needs_edit verdict in later iterations."
|
|
6563
|
+
})),
|
|
6564
|
+
reference_path: Type.Optional(Type.String({
|
|
6565
|
+
description: "Optional workspace-relative path to a reference image the first iteration edits instead of drawing from scratch. Requires reference_mode: revise_layout (other modes are not yet implemented)."
|
|
6566
|
+
})),
|
|
6567
|
+
reference_mode: Type.Optional(Type.String({
|
|
6568
|
+
description: `How to use the reference image. Only "revise_layout" is implemented in this version; "style_only" and "local_edit" are reserved and currently rejected. Default: revise_layout.`
|
|
6569
|
+
}))
|
|
6570
|
+
});
|
|
6571
|
+
function sanitizeDocType(value) {
|
|
6572
|
+
const v = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
6573
|
+
return VALID_DOC_TYPES.includes(v) ? v : "default";
|
|
6574
|
+
}
|
|
6575
|
+
function sanitizeDiagramType(value, prompt) {
|
|
6576
|
+
const v = typeof value === "string" ? value.trim().toLowerCase() : "auto";
|
|
6577
|
+
if (VALID_DIAGRAM_TYPES.includes(v)) {
|
|
6578
|
+
return v === "auto" ? detectDiagramType(prompt) : v;
|
|
6579
|
+
}
|
|
6580
|
+
return detectDiagramType(prompt);
|
|
6581
|
+
}
|
|
6582
|
+
function sanitizeIterations(value) {
|
|
6583
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
6584
|
+
if (!Number.isFinite(n)) return 2;
|
|
6585
|
+
return Math.max(1, Math.min(3, Math.round(n)));
|
|
6586
|
+
}
|
|
6587
|
+
function sanitizeReferenceMode(value) {
|
|
6588
|
+
if (value === void 0 || value === null || typeof value === "string" && !value.trim()) {
|
|
6589
|
+
return { ok: true, mode: "revise_layout" };
|
|
6590
|
+
}
|
|
6591
|
+
const v = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
6592
|
+
if (!VALID_REFERENCE_MODES.includes(v)) {
|
|
6593
|
+
return {
|
|
6594
|
+
ok: false,
|
|
6595
|
+
value: typeof value === "string" ? value : String(value),
|
|
6596
|
+
reason: `Unknown reference_mode. Must be one of: ${VALID_REFERENCE_MODES.join(" | ")}.`
|
|
6597
|
+
};
|
|
6598
|
+
}
|
|
6599
|
+
if (!SUPPORTED_REFERENCE_MODES.includes(v)) {
|
|
6600
|
+
return {
|
|
6601
|
+
ok: false,
|
|
6602
|
+
value: v,
|
|
6603
|
+
reason: `reference_mode "${v}" is reserved but not yet implemented. Only ${SUPPORTED_REFERENCE_MODES.join(", ")} is available in this version.`
|
|
6604
|
+
};
|
|
6605
|
+
}
|
|
6606
|
+
return { ok: true, mode: v };
|
|
6607
|
+
}
|
|
6608
|
+
const CRITICAL_ISSUE_KINDS = [
|
|
6609
|
+
"wrong_content",
|
|
6610
|
+
"missing_element",
|
|
6611
|
+
"illegible_text"
|
|
6612
|
+
];
|
|
6613
|
+
function hasCriticalIssue(issues) {
|
|
6614
|
+
return issues.some((i) => CRITICAL_ISSUE_KINDS.includes(i.kind));
|
|
6615
|
+
}
|
|
6616
|
+
function reconcileVerdict(review, threshold) {
|
|
6617
|
+
if (review.verdict === "acceptable") {
|
|
6618
|
+
if (review.score < threshold) {
|
|
6619
|
+
return {
|
|
6620
|
+
final: { ...review, verdict: "needs_edit" },
|
|
6621
|
+
override: `reviewer said acceptable but score ${review.score} < threshold ${threshold}`
|
|
6622
|
+
};
|
|
6623
|
+
}
|
|
6624
|
+
if (review.blockingIssues.length > 0) {
|
|
6625
|
+
const hasStructural = hasCriticalIssue(review.blockingIssues);
|
|
6626
|
+
return {
|
|
6627
|
+
final: { ...review, verdict: hasStructural ? "needs_regen" : "needs_edit" },
|
|
6628
|
+
override: `reviewer said acceptable but listed ${review.blockingIssues.length} blocking issues`
|
|
6629
|
+
};
|
|
6630
|
+
}
|
|
6631
|
+
return { final: review };
|
|
6632
|
+
}
|
|
6633
|
+
if (review.verdict === "needs_edit" && review.score >= threshold + 1 && !hasCriticalIssue(review.blockingIssues)) {
|
|
6634
|
+
return {
|
|
6635
|
+
final: { ...review, verdict: "acceptable" },
|
|
6636
|
+
override: `score ${review.score} ≥ threshold ${threshold} + 1.0 with no critical issues — accepting despite ${review.blockingIssues.length} minor note(s)`
|
|
6637
|
+
};
|
|
6638
|
+
}
|
|
6639
|
+
return { final: review };
|
|
6640
|
+
}
|
|
6641
|
+
function ensureInsideWorkspace(workspace, target) {
|
|
6642
|
+
const abs = path$1.resolve(workspace, target);
|
|
6643
|
+
const rel = path$1.relative(workspace, abs);
|
|
6644
|
+
if (rel.startsWith("..") || path$1.isAbsolute(rel)) {
|
|
6645
|
+
throw new Error(`Output path must live inside the workspace: ${target}`);
|
|
6646
|
+
}
|
|
6647
|
+
return abs;
|
|
6648
|
+
}
|
|
6649
|
+
function createGenerateDiagramTool(ctx) {
|
|
6650
|
+
return {
|
|
6651
|
+
name: "generate_diagram",
|
|
6652
|
+
label: "Generate Diagram",
|
|
6653
|
+
description: "Generate a publication-quality scientific diagram from a natural-language description. The tool runs a verdict-driven generate → review → (optional) edit loop using the configured image provider (OpenAI) and review provider (OpenAI or Anthropic). Supports flowcharts, architecture, pathways, circuits, networks, and conceptual frameworks. Prompt guidance: be specific about components, labels, exact counts/values, and layout direction. Decomposition: if the diagram has more than ~6 top-level components or crosses more than 2 logical regions/phases (e.g. offline+online, frontend+backend+storage, multi-stage pipelines with nested loops), prefer producing 2–3 linked diagrams (one per region/phase, plus an optional overview) over a single dense figure — gpt-image-2 reliability drops sharply on crowded geometry, and reviewer scores plateau around 7/10 for monolithic architectures.",
|
|
6654
|
+
parameters: GenerateDiagramSchema,
|
|
6655
|
+
execute: async (_toolCallId, rawParams) => {
|
|
6656
|
+
const params = rawParams;
|
|
6657
|
+
const userPrompt = typeof params.prompt === "string" ? params.prompt.trim() : "";
|
|
6658
|
+
const outputArg = typeof params.output === "string" ? params.output.trim() : "";
|
|
6659
|
+
if (!userPrompt) {
|
|
6660
|
+
return toAgentResult("generate_diagram", toolError("MISSING_PARAMETER", "Missing prompt.", {
|
|
6661
|
+
suggestions: ["Describe the diagram: type, components, labels, layout."]
|
|
6662
|
+
}));
|
|
6663
|
+
}
|
|
6664
|
+
if (!outputArg) {
|
|
6665
|
+
return toAgentResult("generate_diagram", toolError("MISSING_PARAMETER", "Missing output.", {
|
|
6666
|
+
suggestions: ["Provide a workspace-relative output path, e.g. figures/diagram.png."]
|
|
6667
|
+
}));
|
|
6668
|
+
}
|
|
6669
|
+
const docType = sanitizeDocType(params.doc_type);
|
|
6670
|
+
const diagramType = sanitizeDiagramType(params.diagram_type, userPrompt);
|
|
6671
|
+
const iterations = sanitizeIterations(params.iterations);
|
|
6672
|
+
const aspect = sanitizeAspect(params.aspect);
|
|
6673
|
+
const explicitQuality = sanitizeQuality(params.quality);
|
|
6674
|
+
const defaultQuality = defaultQualityForDocType(docType);
|
|
6675
|
+
const initialQuality = explicitQuality ?? defaultQuality;
|
|
6676
|
+
const hasReference = typeof params.reference_path === "string" && !!params.reference_path.trim();
|
|
6677
|
+
let referenceMode = "revise_layout";
|
|
6678
|
+
if (hasReference || params.reference_mode !== void 0) {
|
|
6679
|
+
const parsed = sanitizeReferenceMode(params.reference_mode);
|
|
6680
|
+
if (!parsed.ok) {
|
|
6681
|
+
return toAgentResult("generate_diagram", toolError("INVALID_PARAMETER", parsed.reason, {
|
|
6682
|
+
suggestions: [
|
|
6683
|
+
`Use reference_mode: ${SUPPORTED_REFERENCE_MODES.join(" or ")}.`,
|
|
6684
|
+
"Omit reference_path to draw without a reference image."
|
|
6685
|
+
],
|
|
6686
|
+
context: { provided: parsed.value }
|
|
6687
|
+
}));
|
|
6688
|
+
}
|
|
6689
|
+
referenceMode = parsed.mode;
|
|
6690
|
+
}
|
|
6691
|
+
let absOutput;
|
|
6692
|
+
try {
|
|
6693
|
+
absOutput = ensureInsideWorkspace(ctx.workspacePath, outputArg);
|
|
6694
|
+
} catch (err) {
|
|
6695
|
+
return toAgentResult("generate_diagram", toolError("PATH_OUTSIDE_WORKSPACE", err.message, {
|
|
6696
|
+
suggestions: ["Use a path relative to the workspace root."]
|
|
6697
|
+
}));
|
|
6698
|
+
}
|
|
6699
|
+
const outDir = path$1.dirname(absOutput);
|
|
6700
|
+
const baseName = path$1.basename(absOutput, path$1.extname(absOutput));
|
|
6701
|
+
const originalExtension = path$1.extname(absOutput) || ".png";
|
|
6702
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
6703
|
+
const formatPref = sanitizeFormat(params.format);
|
|
6704
|
+
const effectiveFormat = resolveEffectiveFormat(formatPref, originalExtension);
|
|
6705
|
+
const userRequestedSvg = effectiveFormat === "svg";
|
|
6706
|
+
const liveSettings = ctx.getSettings?.() ?? ctx.settings;
|
|
6707
|
+
const reviewPref = liveSettings?.diagram?.reviewProvider ?? "auto";
|
|
6708
|
+
const prefs = {
|
|
6709
|
+
generation: "openai",
|
|
6710
|
+
review: reviewPref,
|
|
6711
|
+
imageSize: aspectToSize(aspect),
|
|
6712
|
+
imageQuality: initialQuality,
|
|
6713
|
+
svgAspect: aspect,
|
|
6714
|
+
forceSvg: userRequestedSvg
|
|
6715
|
+
};
|
|
6716
|
+
const auth = ctx.getDiagramAuth?.();
|
|
6717
|
+
const fallback = ctx.callLlm ? {
|
|
6718
|
+
callLlm: ctx.callLlm,
|
|
6719
|
+
rasterizeSvg: ctx.rasterizeSvg,
|
|
6720
|
+
// Vision channel powers Path A (PNG-anchored SVG transcription).
|
|
6721
|
+
// When absent, registry chooses Path B (hard error if user has
|
|
6722
|
+
// OpenAI key) or Path C (chat-model-only safety net).
|
|
6723
|
+
callLlmVision: ctx.callLlmVision,
|
|
6724
|
+
visionCapable: ctx.visionCapable
|
|
6725
|
+
} : void 0;
|
|
6726
|
+
let providers;
|
|
6727
|
+
try {
|
|
6728
|
+
providers = resolveProviders(prefs, auth, fallback);
|
|
6729
|
+
} catch (err) {
|
|
6730
|
+
const msg = err.message;
|
|
6731
|
+
if (msg.startsWith("SVG_REQUIRES_VISION_MODEL:")) {
|
|
6732
|
+
return toAgentResult("generate_diagram", toolError("SVG_REQUIRES_VISION_MODEL", msg.slice("SVG_REQUIRES_VISION_MODEL:".length).trim(), {
|
|
6733
|
+
suggestions: [
|
|
6734
|
+
"Switch the chat model to a vision-capable one (GPT-4o, Claude Opus 4.5, Gemini 2.5) under Settings → Model.",
|
|
6735
|
+
'Or, request a raster output by using a .png extension instead of .svg, or by setting format: "png".'
|
|
6736
|
+
]
|
|
6737
|
+
}));
|
|
6738
|
+
}
|
|
6739
|
+
return toAgentResult("generate_diagram", toolError("LLM_UNAVAILABLE", msg, {
|
|
6740
|
+
suggestions: [
|
|
6741
|
+
"Ask the user to add OPENAI_API_KEY under Settings → API Keys for native image generation (gpt-image-2). ChatGPT / Codex subscription tokens are scoped to the Codex endpoint and cannot call the Images API.",
|
|
6742
|
+
'Alternatively, leave a "figure TBD" caption with a textual description so the user can regenerate later without re-explaining the intent.'
|
|
6743
|
+
]
|
|
6744
|
+
}));
|
|
6745
|
+
}
|
|
6746
|
+
const pathAActive = providers.svgPath === "png_anchored" && !!providers.pngToSvgTranscriber;
|
|
6747
|
+
const providerFormat = providers.svgFallback ? "svg" : "png";
|
|
6748
|
+
const extension = providerFormat === "svg" ? ".svg" : ".png";
|
|
6749
|
+
const finalExtension = pathAActive ? ".svg" : extension;
|
|
6750
|
+
const finalAbsOutput = finalExtension !== originalExtension ? path$1.join(outDir, `${baseName}${finalExtension}`) : absOutput;
|
|
6751
|
+
const promptFormat = providerFormat === "svg" ? "svg" : "raster";
|
|
6752
|
+
let referenceBytes = null;
|
|
6753
|
+
if (hasReference) {
|
|
6754
|
+
try {
|
|
6755
|
+
const refAbs = ensureInsideWorkspace(ctx.workspacePath, params.reference_path.trim());
|
|
6756
|
+
if (!fs.existsSync(refAbs)) {
|
|
6757
|
+
return toAgentResult("generate_diagram", toolError("FILE_NOT_FOUND", `Reference image not found: ${params.reference_path}`, {
|
|
6758
|
+
suggestions: ["Check the reference_path is relative to the workspace root."]
|
|
6759
|
+
}));
|
|
6760
|
+
}
|
|
6761
|
+
referenceBytes = fs.readFileSync(refAbs);
|
|
6762
|
+
} catch (err) {
|
|
6763
|
+
return toAgentResult("generate_diagram", toolError("PATH_OUTSIDE_WORKSPACE", err.message));
|
|
6764
|
+
}
|
|
6765
|
+
}
|
|
6766
|
+
const threshold = providers.review.thresholds[docType] ?? providers.review.thresholds.default;
|
|
6767
|
+
const houseProfileSummary = renderProfile(DEFAULT_HOUSE_PROFILE).summaryForReviewer;
|
|
6768
|
+
const history = [];
|
|
6769
|
+
let prevImage = referenceBytes && referenceMode !== "style_only" ? referenceBytes : null;
|
|
6770
|
+
let stoppedEarly = false;
|
|
6771
|
+
let stoppedReason;
|
|
6772
|
+
const fixedSoFar = [];
|
|
6773
|
+
let currentQuality = initialQuality;
|
|
6774
|
+
for (let i = 1; i <= iterations; i++) {
|
|
6775
|
+
const canEdit = !!providers.image.imageToImage && providers.image.capabilities.has("image_to_image");
|
|
6776
|
+
const lastReview = history[history.length - 1]?.review;
|
|
6777
|
+
let image;
|
|
6778
|
+
let usedEdit = false;
|
|
6779
|
+
let promptForThisIter;
|
|
6780
|
+
if (i === 1) {
|
|
6781
|
+
if (referenceBytes && referenceMode === "style_only" && canEdit) {
|
|
6782
|
+
promptForThisIter = composeStyleOnlyPrompt(userPrompt, diagramType, promptFormat);
|
|
6783
|
+
image = await providers.image.imageToImage(
|
|
6784
|
+
promptForThisIter,
|
|
6785
|
+
referenceBytes,
|
|
6786
|
+
{ quality: currentQuality }
|
|
6787
|
+
);
|
|
6788
|
+
usedEdit = true;
|
|
6789
|
+
} else if (referenceBytes && referenceMode === "revise_layout" && canEdit) {
|
|
6790
|
+
promptForThisIter = composeSurgicalRevisionPrompt(userPrompt, diagramType);
|
|
6791
|
+
image = await providers.image.imageToImage(
|
|
6792
|
+
promptForThisIter,
|
|
6793
|
+
referenceBytes,
|
|
6794
|
+
{ quality: currentQuality }
|
|
6795
|
+
);
|
|
6796
|
+
usedEdit = true;
|
|
6797
|
+
} else {
|
|
6798
|
+
promptForThisIter = composeGenerationPrompt(userPrompt, diagramType, promptFormat);
|
|
6799
|
+
image = await providers.image.textToImage(promptForThisIter, { quality: currentQuality });
|
|
6800
|
+
}
|
|
6801
|
+
} else if (lastReview?.verdict === "needs_edit" && prevImage && canEdit) {
|
|
6802
|
+
currentQuality = bumpedQuality(currentQuality);
|
|
6803
|
+
promptForThisIter = composeEditPrompt(
|
|
6804
|
+
userPrompt,
|
|
6805
|
+
diagramType,
|
|
6806
|
+
lastReview.blockingIssues,
|
|
6807
|
+
fixedSoFar
|
|
6808
|
+
);
|
|
6809
|
+
image = await providers.image.imageToImage(promptForThisIter, prevImage, { quality: currentQuality });
|
|
6810
|
+
usedEdit = true;
|
|
6811
|
+
} else {
|
|
6812
|
+
promptForThisIter = composeRegenPrompt(userPrompt, diagramType, lastReview?.blockingIssues ?? [], promptFormat);
|
|
6813
|
+
image = await providers.image.textToImage(promptForThisIter, { quality: currentQuality });
|
|
6814
|
+
}
|
|
6815
|
+
const iterPath = path$1.join(outDir, `${baseName}_v${i}${extension}`);
|
|
6816
|
+
fs.writeFileSync(iterPath, image);
|
|
6817
|
+
let rawReview;
|
|
6818
|
+
try {
|
|
6819
|
+
rawReview = await providers.review.review({
|
|
6820
|
+
image,
|
|
6821
|
+
prompt: userPrompt,
|
|
6822
|
+
docType,
|
|
6823
|
+
diagramType,
|
|
6824
|
+
iteration: i,
|
|
6825
|
+
maxIterations: iterations,
|
|
6826
|
+
houseProfileSummary
|
|
6827
|
+
});
|
|
6828
|
+
} catch (err) {
|
|
6829
|
+
return toAgentResult("generate_diagram", toolError("API_ERROR", `Review failed: ${err.message}`, {
|
|
6830
|
+
retryable: true,
|
|
6831
|
+
suggestions: ["Retry the tool call.", "Switch the review provider in Settings."],
|
|
6832
|
+
context: { imagePath: path$1.relative(ctx.workspacePath, iterPath) },
|
|
6833
|
+
data: { partialPath: path$1.relative(ctx.workspacePath, iterPath) }
|
|
6834
|
+
}));
|
|
6835
|
+
}
|
|
6836
|
+
const reconciled = reconcileVerdict(rawReview, threshold);
|
|
6837
|
+
const review = reconciled.final;
|
|
6838
|
+
const regressed = regressionsAgainst(fixedSoFar, review.blockingIssues);
|
|
6839
|
+
history.push({
|
|
6840
|
+
iteration: i,
|
|
6841
|
+
imagePath: path$1.relative(ctx.workspacePath, iterPath),
|
|
6842
|
+
usedEdit,
|
|
6843
|
+
quality: currentQuality,
|
|
6844
|
+
review,
|
|
6845
|
+
rawVerdict: rawReview.verdict,
|
|
6846
|
+
verdictOverrideReason: reconciled.override,
|
|
6847
|
+
regressedIssues: regressed.length > 0 ? regressed : void 0,
|
|
6848
|
+
promptSnippet: promptForThisIter.slice(0, 400)
|
|
6849
|
+
});
|
|
6850
|
+
prevImage = image;
|
|
6851
|
+
if (lastReview) {
|
|
6852
|
+
const newlyFixed = fixedBetween(lastReview.blockingIssues, review.blockingIssues);
|
|
6853
|
+
for (const f of newlyFixed) fixedSoFar.push(f);
|
|
6854
|
+
}
|
|
6855
|
+
if (review.verdict === "acceptable") {
|
|
6856
|
+
stoppedEarly = i < iterations;
|
|
6857
|
+
stoppedReason = "acceptable";
|
|
6858
|
+
break;
|
|
6859
|
+
}
|
|
6860
|
+
if (regressed.length > 0 && i < iterations) {
|
|
6861
|
+
stoppedEarly = true;
|
|
6862
|
+
stoppedReason = `regression_detected: ${regressed.length} previously-fixed issue(s) returned`;
|
|
6863
|
+
break;
|
|
6864
|
+
}
|
|
6865
|
+
}
|
|
6866
|
+
const last = history[history.length - 1];
|
|
6867
|
+
if (!last) {
|
|
6868
|
+
return toAgentResult("generate_diagram", toolError("EXECUTION_FAILED", "No iterations completed.", {
|
|
6869
|
+
retryable: true
|
|
6870
|
+
}));
|
|
6871
|
+
}
|
|
6872
|
+
const finalAbsIter = path$1.resolve(ctx.workspacePath, last.imagePath);
|
|
6873
|
+
let transcriptionRecord;
|
|
6874
|
+
if (pathAActive && providers.pngToSvgTranscriber) {
|
|
6875
|
+
const pngBytes = fs.readFileSync(finalAbsIter);
|
|
6876
|
+
let transcribed;
|
|
6877
|
+
try {
|
|
6878
|
+
transcribed = await providers.pngToSvgTranscriber(pngBytes, userPrompt);
|
|
6879
|
+
} catch (err) {
|
|
6880
|
+
return toAgentResult("generate_diagram", toolError("SVG_TRANSCRIPTION_FAILED", err.message, {
|
|
6881
|
+
retryable: true,
|
|
6882
|
+
suggestions: [
|
|
6883
|
+
'Retry the tool call with format: "png" — the PNG anchor was already generated successfully.',
|
|
6884
|
+
"Or, switch to a different vision-capable chat model and retry."
|
|
6885
|
+
],
|
|
6886
|
+
context: { pngAnchorPath: path$1.relative(ctx.workspacePath, finalAbsIter) }
|
|
6887
|
+
}));
|
|
6888
|
+
}
|
|
6889
|
+
const anchorAbs = path$1.join(outDir, `${baseName}.png`);
|
|
6890
|
+
if (history.length === 1 && finalAbsIter !== anchorAbs) {
|
|
6891
|
+
fs.renameSync(finalAbsIter, anchorAbs);
|
|
6892
|
+
last.imagePath = path$1.relative(ctx.workspacePath, anchorAbs);
|
|
6893
|
+
} else if (finalAbsIter !== anchorAbs) {
|
|
6894
|
+
fs.copyFileSync(finalAbsIter, anchorAbs);
|
|
6895
|
+
}
|
|
6896
|
+
fs.writeFileSync(finalAbsOutput, transcribed.svg, "utf-8");
|
|
6897
|
+
transcriptionRecord = {
|
|
6898
|
+
repaired: transcribed.repaired,
|
|
6899
|
+
firstAttemptFailure: transcribed.firstAttemptFailure,
|
|
6900
|
+
visionModelLabel: transcribed.visionModelLabel,
|
|
6901
|
+
anchorPngPath: path$1.relative(ctx.workspacePath, anchorAbs),
|
|
6902
|
+
svgBytes: Buffer.byteLength(transcribed.svg, "utf-8")
|
|
6903
|
+
};
|
|
6904
|
+
} else if (finalAbsIter !== finalAbsOutput) {
|
|
6905
|
+
if (history.length === 1) {
|
|
6906
|
+
fs.renameSync(finalAbsIter, finalAbsOutput);
|
|
6907
|
+
last.imagePath = path$1.relative(ctx.workspacePath, finalAbsOutput);
|
|
6908
|
+
} else {
|
|
6909
|
+
fs.copyFileSync(finalAbsIter, finalAbsOutput);
|
|
6910
|
+
}
|
|
6911
|
+
}
|
|
6912
|
+
const reviewLogPath = path$1.join(outDir, `${baseName}_review_log.json`);
|
|
6913
|
+
const logPayload = {
|
|
6914
|
+
prompt: userPrompt,
|
|
6915
|
+
docType,
|
|
6916
|
+
diagramType,
|
|
6917
|
+
aspect,
|
|
6918
|
+
format: {
|
|
6919
|
+
requested: formatPref,
|
|
6920
|
+
effectiveFromParams: effectiveFormat,
|
|
6921
|
+
actual: providerFormat,
|
|
6922
|
+
originalExtension,
|
|
6923
|
+
finalExtension
|
|
6924
|
+
},
|
|
6925
|
+
threshold,
|
|
6926
|
+
// mode classifications:
|
|
6927
|
+
// 'image' — PNG path, no SVG output requested
|
|
6928
|
+
// 'svg_fallback' — Path C, chat-model SVG synthesis (no OpenAI key)
|
|
6929
|
+
// 'png_anchored_svg' — Path A, PNG-anchored vision transcription
|
|
6930
|
+
mode: providers.svgFallback ? "svg_fallback" : pathAActive ? "png_anchored_svg" : "image",
|
|
6931
|
+
svgPath: providers.svgPath,
|
|
6932
|
+
transcription: transcriptionRecord ?? void 0,
|
|
6933
|
+
quality: {
|
|
6934
|
+
initial: initialQuality,
|
|
6935
|
+
defaultForDocType: defaultQuality,
|
|
6936
|
+
explicit: explicitQuality ?? null
|
|
6937
|
+
},
|
|
6938
|
+
houseProfile: DEFAULT_HOUSE_PROFILE.id,
|
|
6939
|
+
provider: {
|
|
6940
|
+
image: providers.image.id,
|
|
6941
|
+
review: providers.review.id
|
|
6942
|
+
},
|
|
6943
|
+
iterations: history.map((h) => ({
|
|
6944
|
+
iteration: h.iteration,
|
|
6945
|
+
imagePath: h.imagePath,
|
|
6946
|
+
usedEdit: h.usedEdit,
|
|
6947
|
+
quality: h.quality,
|
|
6948
|
+
review: h.review,
|
|
6949
|
+
rawVerdict: h.rawVerdict,
|
|
6950
|
+
verdictOverrideReason: h.verdictOverrideReason,
|
|
6951
|
+
regressedIssues: h.regressedIssues
|
|
6952
|
+
})),
|
|
6953
|
+
stoppedEarly,
|
|
6954
|
+
stoppedReason: stoppedReason ?? (stoppedEarly ? void 0 : "max_iterations"),
|
|
6955
|
+
fixedAcrossIterations: fixedSoFar
|
|
6956
|
+
};
|
|
6957
|
+
fs.writeFileSync(reviewLogPath, JSON.stringify(logPayload, null, 2), "utf-8");
|
|
6958
|
+
const payload = {
|
|
6959
|
+
outputPath: path$1.relative(ctx.workspacePath, finalAbsOutput),
|
|
6960
|
+
absoluteOutputPath: finalAbsOutput,
|
|
6961
|
+
iterations: history,
|
|
6962
|
+
finalScore: last.review.score,
|
|
6963
|
+
finalVerdict: last.review.verdict,
|
|
6964
|
+
threshold,
|
|
6965
|
+
mode: providers.svgFallback ? "svg_fallback" : pathAActive ? "png_anchored_svg" : "image",
|
|
6966
|
+
provider: {
|
|
6967
|
+
image: providers.image.id,
|
|
6968
|
+
review: providers.review.id
|
|
6969
|
+
},
|
|
6970
|
+
reviewLogPath: path$1.relative(ctx.workspacePath, reviewLogPath),
|
|
6971
|
+
stoppedEarly,
|
|
6972
|
+
stoppedReason: stoppedReason ?? (stoppedEarly ? void 0 : "max_iterations"),
|
|
6973
|
+
extensionChanged: originalExtension !== finalExtension ? { requested: originalExtension, actual: finalExtension } : void 0,
|
|
6974
|
+
transcription: transcriptionRecord ? {
|
|
6975
|
+
anchorPngPath: transcriptionRecord.anchorPngPath,
|
|
6976
|
+
repaired: transcriptionRecord.repaired,
|
|
6977
|
+
visionModelLabel: transcriptionRecord.visionModelLabel,
|
|
6978
|
+
svgBytes: transcriptionRecord.svgBytes
|
|
6979
|
+
} : void 0
|
|
6980
|
+
};
|
|
6981
|
+
return toAgentResult("generate_diagram", { success: true, data: payload });
|
|
6982
|
+
}
|
|
6983
|
+
};
|
|
6984
|
+
}
|
|
4586
6985
|
function isTerminal(state) {
|
|
4587
6986
|
return state === "completed" || state === "failed" || state === "timed_out" || state === "cancelled";
|
|
4588
6987
|
}
|
|
@@ -6847,7 +9246,7 @@ function rebuildIndex() {
|
|
|
6847
9246
|
const lines = ["# Paper Wiki Index\n"];
|
|
6848
9247
|
lines.push("## Papers\n");
|
|
6849
9248
|
if (existsSync(papersDir)) {
|
|
6850
|
-
const files = readdirSync
|
|
9249
|
+
const files = readdirSync(papersDir).filter((f) => f.endsWith(".md")).sort();
|
|
6851
9250
|
for (const file of files) {
|
|
6852
9251
|
const content = safeReadFile(join(papersDir, file));
|
|
6853
9252
|
if (!content) continue;
|
|
@@ -6859,7 +9258,7 @@ function rebuildIndex() {
|
|
|
6859
9258
|
if (lines[lines.length - 1] === "## Papers\n") lines.push("_(empty)_");
|
|
6860
9259
|
lines.push("\n## Concepts\n");
|
|
6861
9260
|
if (existsSync(conceptsDir)) {
|
|
6862
|
-
const files = readdirSync
|
|
9261
|
+
const files = readdirSync(conceptsDir).filter((f) => f.endsWith(".md")).sort();
|
|
6863
9262
|
for (const file of files) {
|
|
6864
9263
|
const content = safeReadFile(join(conceptsDir, file));
|
|
6865
9264
|
if (!content) continue;
|
|
@@ -6897,19 +9296,19 @@ function readRecentLog(n = 20) {
|
|
|
6897
9296
|
function countPaperPages() {
|
|
6898
9297
|
const dir = join(getWikiRoot(), "papers");
|
|
6899
9298
|
if (!existsSync(dir)) return 0;
|
|
6900
|
-
return readdirSync
|
|
9299
|
+
return readdirSync(dir).filter((f) => f.endsWith(".md")).length;
|
|
6901
9300
|
}
|
|
6902
9301
|
function countConceptPages() {
|
|
6903
9302
|
const dir = join(getWikiRoot(), "concepts");
|
|
6904
9303
|
if (!existsSync(dir)) return 0;
|
|
6905
|
-
return readdirSync
|
|
9304
|
+
return readdirSync(dir).filter((f) => f.endsWith(".md")).length;
|
|
6906
9305
|
}
|
|
6907
9306
|
function listWikiPages() {
|
|
6908
9307
|
const root = getWikiRoot();
|
|
6909
9308
|
const readEntries = (subdir, kind) => {
|
|
6910
9309
|
const dir = join(root, subdir);
|
|
6911
9310
|
if (!existsSync(dir)) return [];
|
|
6912
|
-
return readdirSync
|
|
9311
|
+
return readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => {
|
|
6913
9312
|
const slug = f.replace(".md", "");
|
|
6914
9313
|
const content = safeReadFile(join(dir, f));
|
|
6915
9314
|
const titleMatch = content?.match(/^#\s+(.+)$/m);
|
|
@@ -7431,7 +9830,7 @@ function rebuildMemoryIndex() {
|
|
|
7431
9830
|
if (!existsSync(papersDir)) {
|
|
7432
9831
|
return { numPapers: 0, numTokens: 0, numAliases: 0, numEdges: 0 };
|
|
7433
9832
|
}
|
|
7434
|
-
const files = readdirSync
|
|
9833
|
+
const files = readdirSync(papersDir).filter((f) => f.endsWith(".md"));
|
|
7435
9834
|
const papers = [];
|
|
7436
9835
|
for (const file of files) {
|
|
7437
9836
|
const slug = file.replace(/\.md$/, "");
|
|
@@ -7906,7 +10305,7 @@ function listAllPaperSlugs() {
|
|
|
7906
10305
|
const dir = join(getWikiRoot(), "papers");
|
|
7907
10306
|
if (!existsSync(dir)) return [];
|
|
7908
10307
|
try {
|
|
7909
|
-
return readdirSync
|
|
10308
|
+
return readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""));
|
|
7910
10309
|
} catch {
|
|
7911
10310
|
return [];
|
|
7912
10311
|
}
|
|
@@ -8348,6 +10747,7 @@ function createResearchTools(ctx) {
|
|
|
8348
10747
|
tools.push(createLiteratureSearchTool(ctx));
|
|
8349
10748
|
tools.push(createConvertDocumentTool(ctx));
|
|
8350
10749
|
tools.push(createDataAnalyzeTool(ctx));
|
|
10750
|
+
tools.push(createGenerateDiagramTool(ctx));
|
|
8351
10751
|
const artifactTools = createResearchMemoryTools({
|
|
8352
10752
|
sessionId: ctx.sessionId,
|
|
8353
10753
|
projectPath: ctx.projectPath
|
|
@@ -8407,7 +10807,7 @@ function simplifyMessages(messages, maxMessages) {
|
|
|
8407
10807
|
const text = block.text;
|
|
8408
10808
|
content += text.length > 500 ? text.slice(0, 500) + "...[truncated]" : text;
|
|
8409
10809
|
content += "\n";
|
|
8410
|
-
} else if (block.type === "
|
|
10810
|
+
} else if (block.type === "toolCall" && "name" in block) {
|
|
8411
10811
|
content += `[Called ${block.name}]
|
|
8412
10812
|
`;
|
|
8413
10813
|
}
|
|
@@ -8431,7 +10831,7 @@ function agentCalledSaveMemoryThisTurn(messages) {
|
|
|
8431
10831
|
if (msg.role === "user") break;
|
|
8432
10832
|
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
|
8433
10833
|
for (const block of msg.content) {
|
|
8434
|
-
if (block && typeof block === "object" && "type" in block && block.type === "
|
|
10834
|
+
if (block && typeof block === "object" && "type" in block && block.type === "toolCall") {
|
|
8435
10835
|
if (block.name === "save-memory") return true;
|
|
8436
10836
|
}
|
|
8437
10837
|
}
|
|
@@ -8456,6 +10856,8 @@ async function maybeExtractMemories(config, messages, turnCount, extractEveryN =
|
|
|
8456
10856
|
});
|
|
8457
10857
|
const result = await completeSimple(config.model, {
|
|
8458
10858
|
systemPrompt: config.systemPrompt,
|
|
10859
|
+
// pi-ai's Message union requires extra fields on assistant turns, but
|
|
10860
|
+
// for this context-only replay the providers read role/content/timestamp.
|
|
8459
10861
|
messages: simplified
|
|
8460
10862
|
}, {
|
|
8461
10863
|
maxTokens: 1024,
|
|
@@ -8829,11 +11231,11 @@ function detectIntentsByRules(message) {
|
|
|
8829
11231
|
return intents;
|
|
8830
11232
|
}
|
|
8831
11233
|
const MAX_SKILL_PRELOAD = 5;
|
|
8832
|
-
async function matchSkillsWithLLM(model, apiKey, message, skills) {
|
|
11234
|
+
async function matchSkillsWithLLM(model, apiKey, message, skills, priorTurns = []) {
|
|
8833
11235
|
if (!model || skills.length === 0) return [];
|
|
8834
11236
|
const skillList = skills.map((s) => `- ${s.name}: ${s.description}`).join("\n");
|
|
8835
11237
|
const systemPrompt = [
|
|
8836
|
-
"You are a skill router for a research assistant. Given a user message, select which skills should be activated.",
|
|
11238
|
+
"You are a skill router for a research assistant. Given a user message (and recent conversation context, if any), select which skills should be activated.",
|
|
8837
11239
|
"Return ONLY a JSON array of skill names. Return [] if none are relevant.",
|
|
8838
11240
|
"",
|
|
8839
11241
|
"Rules:",
|
|
@@ -8841,14 +11243,22 @@ async function matchSkillsWithLLM(model, apiKey, message, skills) {
|
|
|
8841
11243
|
"- Do not select skills speculatively",
|
|
8842
11244
|
`- Maximum ${MAX_SKILL_PRELOAD} skills`,
|
|
8843
11245
|
"- Consider both English and Chinese messages",
|
|
11246
|
+
'- If the current message is a short follow-up or confirmation (e.g. "yes", "do that", "go ahead", "好的", "继续"), infer intent from the recent context',
|
|
8844
11247
|
"",
|
|
8845
11248
|
"Available skills:",
|
|
8846
11249
|
skillList
|
|
8847
11250
|
].join("\n");
|
|
11251
|
+
const contextBlock = priorTurns.length > 0 ? priorTurns.map((t) => `User: ${t.userMessage}
|
|
11252
|
+
Assistant: ${t.response}`).join("\n\n") : "";
|
|
11253
|
+
const userContent = contextBlock ? `Recent conversation:
|
|
11254
|
+
${contextBlock}
|
|
11255
|
+
|
|
11256
|
+
Current user message:
|
|
11257
|
+
${message}` : message;
|
|
8848
11258
|
try {
|
|
8849
11259
|
const result = await completeSimple(model, {
|
|
8850
11260
|
systemPrompt,
|
|
8851
|
-
messages: [{ role: "user", content:
|
|
11261
|
+
messages: [{ role: "user", content: userContent, timestamp: Date.now() }]
|
|
8852
11262
|
}, {
|
|
8853
11263
|
maxTokens: 100,
|
|
8854
11264
|
apiKey
|
|
@@ -9035,9 +11445,41 @@ async function createCoordinator(config) {
|
|
|
9035
11445
|
const textContent = result.content.find((c) => c.type === "text");
|
|
9036
11446
|
return textContent?.text ?? "";
|
|
9037
11447
|
},
|
|
11448
|
+
// Vision-capable sibling of callLlm. Mirrors the stateless completeSimple
|
|
11449
|
+
// shape above plus the ImageContent transformation used by chat() at the
|
|
11450
|
+
// user-attached-images path. Reused by the diagram tool's PNG-anchored
|
|
11451
|
+
// SVG transcription path, where a final PNG is fed back to the model to
|
|
11452
|
+
// be re-emitted as editable SVG markup.
|
|
11453
|
+
callLlmVision: async (systemPrompt, userContent, images) => {
|
|
11454
|
+
if (!piModel) throw new Error("No model available for sub-call");
|
|
11455
|
+
if (!piModel.input.includes("image")) {
|
|
11456
|
+
throw new Error(
|
|
11457
|
+
`Selected model "${piModel.id}" does not accept image input. Switch to a vision-capable model (e.g. GPT-4o, Claude Opus, Gemini 2.5).`
|
|
11458
|
+
);
|
|
11459
|
+
}
|
|
11460
|
+
const currentKey = await resolveApiKey();
|
|
11461
|
+
const content = [
|
|
11462
|
+
{ type: "text", text: userContent },
|
|
11463
|
+
...images.map((img) => ({
|
|
11464
|
+
type: "image",
|
|
11465
|
+
data: img.base64,
|
|
11466
|
+
mimeType: img.mimeType
|
|
11467
|
+
}))
|
|
11468
|
+
];
|
|
11469
|
+
const result = await completeSimple(piModel, {
|
|
11470
|
+
systemPrompt,
|
|
11471
|
+
messages: [{ role: "user", content, timestamp: Date.now() }]
|
|
11472
|
+
}, { maxTokens: 8192, apiKey: currentKey });
|
|
11473
|
+
const textContent = result.content.find((c) => c.type === "text");
|
|
11474
|
+
return textContent?.text ?? "";
|
|
11475
|
+
},
|
|
11476
|
+
visionCapable: !!piModel?.input.includes("image"),
|
|
9038
11477
|
onToolCall,
|
|
9039
11478
|
onToolResult: wrappedOnToolResult,
|
|
9040
|
-
settings: config.resolvedSettings
|
|
11479
|
+
settings: config.resolvedSettings,
|
|
11480
|
+
getSettings: config.getResolvedSettings,
|
|
11481
|
+
getDiagramAuth: config.getDiagramAuth,
|
|
11482
|
+
rasterizeSvg: config.rasterizeSvg
|
|
9041
11483
|
};
|
|
9042
11484
|
const { tools: researchAgentTools, destroy: destroyResearchTools } = createResearchTools(toolCtx);
|
|
9043
11485
|
const codingTools = createCodingTools(projectPath);
|
|
@@ -9118,6 +11560,7 @@ async function createCoordinator(config) {
|
|
|
9118
11560
|
piModel,
|
|
9119
11561
|
settings.reserveTokens,
|
|
9120
11562
|
currentKey,
|
|
11563
|
+
void 0,
|
|
9121
11564
|
signal,
|
|
9122
11565
|
void 0,
|
|
9123
11566
|
compactionSummary
|
|
@@ -9256,8 +11699,12 @@ ${historyText}`,
|
|
|
9256
11699
|
async chat(message, mentions, images) {
|
|
9257
11700
|
try {
|
|
9258
11701
|
const intents = detectIntentsByRules(message);
|
|
11702
|
+
const priorTurns = turnHistory.slice(-2).map((t) => ({
|
|
11703
|
+
userMessage: t.userMessage,
|
|
11704
|
+
response: t.response
|
|
11705
|
+
}));
|
|
9259
11706
|
const currentKey = await resolveApiKey();
|
|
9260
|
-
const matchedSkillNames = await matchSkillsWithLLM(intentRouterModel, currentKey, message, skills);
|
|
11707
|
+
const matchedSkillNames = await matchSkillsWithLLM(intentRouterModel, currentKey, message, skills, priorTurns);
|
|
9261
11708
|
const matchedSkills = matchedSkillNames.map((name) => skills.find((s) => s.name === name)).filter((s) => s !== void 0);
|
|
9262
11709
|
for (const s of matchedSkills) {
|
|
9263
11710
|
onSkillLoaded?.(s.name);
|
|
@@ -9431,7 +11878,7 @@ ${message}` : message;
|
|
|
9431
11878
|
function readLatestExplainTurn(projectPath) {
|
|
9432
11879
|
const dir = join(projectPath, PATHS.explainDir);
|
|
9433
11880
|
if (!existsSync(dir)) return null;
|
|
9434
|
-
const files = readdirSync
|
|
11881
|
+
const files = readdirSync(dir).filter((name) => name.endsWith(".turn.json")).sort();
|
|
9435
11882
|
if (files.length === 0) return null;
|
|
9436
11883
|
const latest = files[files.length - 1];
|
|
9437
11884
|
return JSON.parse(readFileSync(join(dir, latest), "utf-8"));
|
|
@@ -9894,7 +12341,9 @@ function toPaperInput(paper) {
|
|
|
9894
12341
|
};
|
|
9895
12342
|
}
|
|
9896
12343
|
function hasCoreMetadataDelta(before, after) {
|
|
9897
|
-
return
|
|
12344
|
+
return Boolean(
|
|
12345
|
+
after.venue && after.venue !== before.venue || after.doi && after.doi !== before.doi || after.citationCount != null && after.citationCount !== before.citationCount || after.url && after.url !== before.url || after.abstract && after.abstract !== before.abstract
|
|
12346
|
+
);
|
|
9898
12347
|
}
|
|
9899
12348
|
async function enrichPaperArtifacts(options) {
|
|
9900
12349
|
const { projectPath, sessionId, debug, paperIds, onProgress } = options;
|
|
@@ -10153,7 +12602,7 @@ function resolveEntity(ref, dir, entityType, projectPath) {
|
|
|
10153
12602
|
if (!existsSync(dir)) {
|
|
10154
12603
|
return { ref, label: ref.raw, content: "", error: `No ${entityType} directory found` };
|
|
10155
12604
|
}
|
|
10156
|
-
const files = readdirSync
|
|
12605
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
10157
12606
|
const key = ref.key.toLowerCase();
|
|
10158
12607
|
for (const file of files) {
|
|
10159
12608
|
try {
|
|
@@ -10367,7 +12816,7 @@ function walk(root, rel, depth, out) {
|
|
|
10367
12816
|
const dir = rel ? join(root, rel) : root;
|
|
10368
12817
|
let entries;
|
|
10369
12818
|
try {
|
|
10370
|
-
entries = readdirSync
|
|
12819
|
+
entries = readdirSync(dir);
|
|
10371
12820
|
} catch {
|
|
10372
12821
|
return;
|
|
10373
12822
|
}
|
|
@@ -11109,7 +13558,8 @@ function resolveSettings(settings) {
|
|
|
11109
13558
|
researchIntensity: resolveResearchIntensity(settings.research.researchIntensity),
|
|
11110
13559
|
webSearch: resolveWebSearchDepth(settings.research.webSearchDepth),
|
|
11111
13560
|
dataAnalysis: { timeoutMs: resolveDataAnalysisTimeout(settings.dataAnalysis.executionTimeLimit) },
|
|
11112
|
-
autoSaveThreshold: resolveAutoSaveThreshold(settings.research.autoSaveSensitivity)
|
|
13561
|
+
autoSaveThreshold: resolveAutoSaveThreshold(settings.research.autoSaveSensitivity),
|
|
13562
|
+
diagram: { reviewProvider: settings.diagram?.reviewProvider ?? "auto" }
|
|
11113
13563
|
};
|
|
11114
13564
|
}
|
|
11115
13565
|
let _wikiLock = Promise.resolve();
|
|
@@ -11310,7 +13760,7 @@ function applyIdentityMigration(change) {
|
|
|
11310
13760
|
if (oldSlug !== newSlug) {
|
|
11311
13761
|
const conceptsDir = join(root, "concepts");
|
|
11312
13762
|
if (existsSync(conceptsDir)) {
|
|
11313
|
-
for (const f of readdirSync
|
|
13763
|
+
for (const f of readdirSync(conceptsDir)) {
|
|
11314
13764
|
if (!f.endsWith(".md")) continue;
|
|
11315
13765
|
const path2 = join(conceptsDir, f);
|
|
11316
13766
|
const content = safeReadFile(path2);
|
|
@@ -11718,7 +14168,7 @@ function listExistingConceptSlugs() {
|
|
|
11718
14168
|
const dir = join(getWikiRoot(), "concepts");
|
|
11719
14169
|
if (!existsSync(dir)) return [];
|
|
11720
14170
|
try {
|
|
11721
|
-
return readdirSync
|
|
14171
|
+
return readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => f.replace(".md", ""));
|
|
11722
14172
|
} catch {
|
|
11723
14173
|
return [];
|
|
11724
14174
|
}
|
|
@@ -11899,7 +14349,7 @@ function createWikiAgent(config) {
|
|
|
11899
14349
|
}
|
|
11900
14350
|
function emitStatus(pending = 0) {
|
|
11901
14351
|
if (!config.onStatus) return;
|
|
11902
|
-
const totalInWiki = existsSync(join(getWikiRoot(), "papers")) ? readdirSync
|
|
14352
|
+
const totalInWiki = existsSync(join(getWikiRoot(), "papers")) ? readdirSync(join(getWikiRoot(), "papers")).filter((f) => f.endsWith(".md")).length : 0;
|
|
11903
14353
|
config.onStatus({
|
|
11904
14354
|
state: state === "processing" ? "processing" : state === "paused" ? "paused" : "idle",
|
|
11905
14355
|
processed: processedThisSession,
|
|
@@ -12356,7 +14806,7 @@ function listWikiPaperMeta() {
|
|
|
12356
14806
|
if (!existsSync(papersDir)) return [];
|
|
12357
14807
|
const seen = /* @__PURE__ */ new Set();
|
|
12358
14808
|
const results = [];
|
|
12359
|
-
for (const file of readdirSync
|
|
14809
|
+
for (const file of readdirSync(papersDir)) {
|
|
12360
14810
|
if (!file.endsWith(".md")) continue;
|
|
12361
14811
|
const slug = file.slice(0, -3);
|
|
12362
14812
|
const filePath = join(papersDir, file);
|
|
@@ -12423,7 +14873,7 @@ function findDriftGroups() {
|
|
|
12423
14873
|
}
|
|
12424
14874
|
}
|
|
12425
14875
|
const byTitle = /* @__PURE__ */ new Map();
|
|
12426
|
-
for (const f of readdirSync
|
|
14876
|
+
for (const f of readdirSync(papersDir)) {
|
|
12427
14877
|
if (!f.endsWith(".md")) continue;
|
|
12428
14878
|
const slug = f.slice(0, -3);
|
|
12429
14879
|
const content = safeReadFile(join(papersDir, f));
|
|
@@ -12521,7 +14971,7 @@ async function reconcileIdentityDrift(opts = { dryRun: true }) {
|
|
|
12521
14971
|
const papersDir = join(getWikiRoot(), "papers");
|
|
12522
14972
|
const liveSlugs = /* @__PURE__ */ new Set();
|
|
12523
14973
|
if (existsSync(papersDir)) {
|
|
12524
|
-
for (const f of readdirSync
|
|
14974
|
+
for (const f of readdirSync(papersDir)) {
|
|
12525
14975
|
if (f.endsWith(".md")) liveSlugs.add(f.slice(0, -3));
|
|
12526
14976
|
}
|
|
12527
14977
|
}
|
|
@@ -12539,6 +14989,61 @@ async function reconcileIdentityDrift(opts = { dryRun: true }) {
|
|
|
12539
14989
|
};
|
|
12540
14990
|
});
|
|
12541
14991
|
}
|
|
14992
|
+
const DEFAULT_WIDTH = 1200;
|
|
14993
|
+
const DEFAULT_HEIGHT = 900;
|
|
14994
|
+
const SETTLE_MS = 200;
|
|
14995
|
+
function viewBoxDimensions(svg) {
|
|
14996
|
+
const match = svg.match(/viewBox\s*=\s*"([^"]+)"/i);
|
|
14997
|
+
if (!match) return null;
|
|
14998
|
+
const parts = match[1].trim().split(/[\s,]+/).map(Number);
|
|
14999
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return null;
|
|
15000
|
+
const [, , w, h] = parts;
|
|
15001
|
+
if (w <= 0 || h <= 0) return null;
|
|
15002
|
+
return { width: Math.round(w), height: Math.round(h) };
|
|
15003
|
+
}
|
|
15004
|
+
function wrapHtml(svg, width, height) {
|
|
15005
|
+
return [
|
|
15006
|
+
'<!doctype html><html><head><meta charset="utf-8"><style>',
|
|
15007
|
+
`html, body { margin: 0; padding: 0; background: #ffffff; width: ${width}px; height: ${height}px; overflow: hidden; }`,
|
|
15008
|
+
"svg { display: block; width: 100%; height: 100%; }",
|
|
15009
|
+
"</style></head><body>",
|
|
15010
|
+
svg,
|
|
15011
|
+
"</body></html>"
|
|
15012
|
+
].join("");
|
|
15013
|
+
}
|
|
15014
|
+
async function rasterizeSvg(svg, options = {}) {
|
|
15015
|
+
const svgStr = svg.toString("utf-8");
|
|
15016
|
+
const vb = viewBoxDimensions(svgStr);
|
|
15017
|
+
const width = options.width ?? vb?.width ?? DEFAULT_WIDTH;
|
|
15018
|
+
const height = options.height ?? vb?.height ?? DEFAULT_HEIGHT;
|
|
15019
|
+
const win = new BrowserWindow({
|
|
15020
|
+
width,
|
|
15021
|
+
height,
|
|
15022
|
+
show: false,
|
|
15023
|
+
frame: false,
|
|
15024
|
+
transparent: false,
|
|
15025
|
+
webPreferences: {
|
|
15026
|
+
offscreen: true,
|
|
15027
|
+
backgroundThrottling: false,
|
|
15028
|
+
nodeIntegration: false,
|
|
15029
|
+
contextIsolation: true,
|
|
15030
|
+
sandbox: true,
|
|
15031
|
+
// Sanity: SVG content is produced by our own prompt; nothing is user-
|
|
15032
|
+
// supplied from a network. Still, disable remote resources to be safe.
|
|
15033
|
+
webSecurity: true
|
|
15034
|
+
}
|
|
15035
|
+
});
|
|
15036
|
+
try {
|
|
15037
|
+
const html = wrapHtml(svgStr, width, height);
|
|
15038
|
+
const dataUrl = "data:text/html;charset=utf-8," + encodeURIComponent(html);
|
|
15039
|
+
await win.loadURL(dataUrl);
|
|
15040
|
+
await new Promise((resolve2) => setTimeout(resolve2, SETTLE_MS));
|
|
15041
|
+
const image = await win.webContents.capturePage();
|
|
15042
|
+
return image.toPNG();
|
|
15043
|
+
} finally {
|
|
15044
|
+
if (!win.isDestroyed()) win.destroy();
|
|
15045
|
+
}
|
|
15046
|
+
}
|
|
12542
15047
|
const EMPTY = {
|
|
12543
15048
|
version: 1,
|
|
12544
15049
|
updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
@@ -12847,7 +15352,7 @@ async function ensureCoordinator(state, win, model, options) {
|
|
|
12847
15352
|
if (creds.expires < Date.now() + 6e4) {
|
|
12848
15353
|
try {
|
|
12849
15354
|
const { refreshOpenAICodexToken } = await import("@mariozechner/pi-ai/oauth");
|
|
12850
|
-
const newCreds = await refreshOpenAICodexToken(creds);
|
|
15355
|
+
const newCreds = await refreshOpenAICodexToken(creds.refresh);
|
|
12851
15356
|
saveCodexCredentials(newCreds);
|
|
12852
15357
|
return newCreds.access;
|
|
12853
15358
|
} catch {
|
|
@@ -12860,12 +15365,46 @@ async function ensureCoordinator(state, win, model, options) {
|
|
|
12860
15365
|
const initEvent = { type: "system", summary: "Initializing agent (first run may take 1-2 minutes for document processing setup)..." };
|
|
12861
15366
|
state.realtimeBuffer.pushActivity(initEvent);
|
|
12862
15367
|
safeSend(win, "agent:activity", initEvent);
|
|
15368
|
+
const getDiagramAuth = () => {
|
|
15369
|
+
const openaiKey = (process.env.OPENAI_API_KEY || "").trim() || null;
|
|
15370
|
+
const envAnthropic = (process.env.ANTHROPIC_API_KEY || "").trim();
|
|
15371
|
+
if (envAnthropic) {
|
|
15372
|
+
return { openaiKey, anthropic: { token: envAnthropic, isOAuth: false } };
|
|
15373
|
+
}
|
|
15374
|
+
const creds = loadAnthropicSubCredentials();
|
|
15375
|
+
if (creds?.access) {
|
|
15376
|
+
const refreshTokenSymbol = creds.refresh;
|
|
15377
|
+
return {
|
|
15378
|
+
openaiKey,
|
|
15379
|
+
anthropic: {
|
|
15380
|
+
token: creds.access,
|
|
15381
|
+
isOAuth: true,
|
|
15382
|
+
refresh: async () => {
|
|
15383
|
+
const { refreshAnthropicToken } = await import("@mariozechner/pi-ai/oauth");
|
|
15384
|
+
const fresh = await refreshAnthropicToken(refreshTokenSymbol);
|
|
15385
|
+
saveAnthropicSubCredentials(fresh);
|
|
15386
|
+
return fresh.access;
|
|
15387
|
+
}
|
|
15388
|
+
}
|
|
15389
|
+
};
|
|
15390
|
+
}
|
|
15391
|
+
return { openaiKey, anthropic: null };
|
|
15392
|
+
};
|
|
12863
15393
|
state.coordinator = await createCoordinator({
|
|
12864
15394
|
apiKey,
|
|
12865
15395
|
getApiKeyOverride,
|
|
12866
15396
|
model: state.currentModel,
|
|
12867
15397
|
reasoningEffort: state.currentReasoningEffort,
|
|
12868
15398
|
resolvedSettings: resolveSettings(loadSettingsFromConfig()),
|
|
15399
|
+
// Live settings reader: re-reads ~/.research-copilot/config.json per
|
|
15400
|
+
// tool call so diagram review-provider choice (and similar
|
|
15401
|
+
// presentation-layer settings) take effect without restart.
|
|
15402
|
+
getResolvedSettings: () => resolveSettings(loadSettingsFromConfig()),
|
|
15403
|
+
getDiagramAuth,
|
|
15404
|
+
// Only wired in Electron's main process; pure-Node contexts (tests)
|
|
15405
|
+
// will leave this undefined and the tool will degrade to source-
|
|
15406
|
+
// level SVG review.
|
|
15407
|
+
rasterizeSvg,
|
|
12869
15408
|
projectPath: state.projectPath,
|
|
12870
15409
|
sessionId: state.sessionId,
|
|
12871
15410
|
debug: !!process.env.RESEARCH_COPILOT_DEBUG,
|
|
@@ -12923,16 +15462,14 @@ async function ensureCoordinator(state, win, model, options) {
|
|
|
12923
15462
|
const r2 = result;
|
|
12924
15463
|
if (r2.success) {
|
|
12925
15464
|
invalidateEntityCache(runProjectPath);
|
|
12926
|
-
|
|
12927
|
-
|
|
12928
|
-
|
|
12929
|
-
|
|
12930
|
-
|
|
12931
|
-
|
|
12932
|
-
|
|
12933
|
-
|
|
12934
|
-
safeSend(win, "agent:file-created", absPath);
|
|
12935
|
-
}
|
|
15465
|
+
safeSend(win, "agent:entity-created", {
|
|
15466
|
+
type: r2.data?.type || "artifact",
|
|
15467
|
+
id: r2.data?.id,
|
|
15468
|
+
title: r2.data?.title
|
|
15469
|
+
});
|
|
15470
|
+
if (tool === "artifact-create" && r2.data?.filePath) {
|
|
15471
|
+
const absPath = isAbsolute(r2.data.filePath) ? r2.data.filePath : resolve(runProjectPath, r2.data.filePath);
|
|
15472
|
+
safeSend(win, "agent:file-created", absPath);
|
|
12936
15473
|
}
|
|
12937
15474
|
}
|
|
12938
15475
|
}
|
|
@@ -13195,7 +15732,7 @@ function registerIpcHandlers() {
|
|
|
13195
15732
|
if (creds.expires < Date.now() + 6e4) {
|
|
13196
15733
|
try {
|
|
13197
15734
|
const { refreshOpenAICodexToken } = await import("@mariozechner/pi-ai/oauth");
|
|
13198
|
-
const newCreds = await refreshOpenAICodexToken(creds);
|
|
15735
|
+
const newCreds = await refreshOpenAICodexToken(creds.refresh);
|
|
13199
15736
|
saveCodexCredentials(newCreds);
|
|
13200
15737
|
return newCreds.access;
|
|
13201
15738
|
} catch {
|