research-copilot 0.2.17 → 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 +7 -1
- package/app/out/main/index.mjs +2842 -188
- package/app/out/preload/index.js +2 -0
- package/app/out/renderer/assets/{MilkdownMarkdownEditor-tTNRIB2K.css → MilkdownMarkdownEditor-BW0Pt28W.css} +103 -15
- package/app/out/renderer/assets/{MilkdownMarkdownEditor-CuTa5j4S.js → MilkdownMarkdownEditor-OhCrq3X0.js} +99 -52
- package/app/out/renderer/assets/{arc-BAWS3N-F.js → arc-DLr0RP8F.js} +1 -1
- package/app/out/renderer/assets/{blockDiagram-c4efeb88-BHadwPqY.js → blockDiagram-c4efeb88-XhKChw2n.js} +8 -8
- package/app/out/renderer/assets/{c4Diagram-c83219d4-B3kOxRad.js → c4Diagram-c83219d4-DDoJmoIQ.js} +3 -3
- package/app/out/renderer/assets/{channel-Bll9CBqI.js → channel-CJCgJSqV.js} +1 -1
- package/app/out/renderer/assets/{classDiagram-beda092f-Dv7owGyx.js → classDiagram-beda092f-CAmimZpz.js} +6 -6
- package/app/out/renderer/assets/{classDiagram-v2-2358418a-cWrqk5tQ.js → classDiagram-v2-2358418a-Bma4E_Eg.js} +10 -10
- package/app/out/renderer/assets/{clone-D-DQ4nnY.js → clone-C338dmoI.js} +1 -1
- package/app/out/renderer/assets/{createText-1719965b-ciE8YuqI.js → createText-1719965b-_up4NJqB.js} +2 -2
- package/app/out/renderer/assets/{edges-96097737-DycnAYk_.js → edges-96097737-Bpp6hVLn.js} +3 -3
- package/app/out/renderer/assets/{erDiagram-0228fc6a-Sv78YNMY.js → erDiagram-0228fc6a-bjTh_7ap.js} +5 -5
- package/app/out/renderer/assets/{flowDb-c6c81e3f-BiOarg9b.js → flowDb-c6c81e3f-BjVV4DVk.js} +1 -1
- package/app/out/renderer/assets/{flowDiagram-50d868cf-19J80nxU.js → flowDiagram-50d868cf-gmeaaZ6z.js} +12 -12
- package/app/out/renderer/assets/{flowDiagram-v2-4f6560a1-c-kGsubV.js → flowDiagram-v2-4f6560a1-nem5zs2M.js} +12 -12
- package/app/out/renderer/assets/{flowchart-elk-definition-6af322e1-DRrYbiSC.js → flowchart-elk-definition-6af322e1-DPaGAYRw.js} +6 -6
- package/app/out/renderer/assets/{ganttDiagram-a2739b55-BadmpvMy.js → ganttDiagram-a2739b55-CnAti19E.js} +3 -3
- package/app/out/renderer/assets/{gitGraphDiagram-82fe8481-BdVoj60Q.js → gitGraphDiagram-82fe8481-DQWHD3SJ.js} +2 -2
- package/app/out/renderer/assets/{graph-jZhookGR.js → graph-DKiKgH8m.js} +1 -1
- package/app/out/renderer/assets/{index-B8fh500_.js → index-4s-c5d65.js} +3 -3
- package/app/out/renderer/assets/{index-5325376f-CbxmatXv.js → index-5325376f-G-0aO-2i.js} +6 -6
- package/app/out/renderer/assets/{index-CAlpJ3-o.js → index-9q_P5ULR.js} +4 -4
- package/app/out/renderer/assets/{index-cavFRVgM.js → index-B1A3JxQj.js} +3 -3
- package/app/out/renderer/assets/{index-B9kkJj3J.js → index-BBUrmGmY.js} +6 -6
- package/app/out/renderer/assets/{index-CMGDsC_t.js → index-BQho5LH-.js} +6 -6
- package/app/out/renderer/assets/{index-CirXkIv2.js → index-BUVlmsgO.js} +3 -3
- package/app/out/renderer/assets/{index-BWCwSkxb.js → index-BzEthrJ4.js} +3 -3
- package/app/out/renderer/assets/{index-B2UUF9y9.js → index-C1YzkB4z.js} +1289 -419
- package/app/out/renderer/assets/{index-D-ZMmLhv.js → index-CGo665vD.js} +3 -3
- package/app/out/renderer/assets/{index-DQwFQR1s.js → index-CPZaxR35.js} +3 -3
- package/app/out/renderer/assets/{index-DOUTte7i.js → index-CSyD1mbL.js} +3 -3
- package/app/out/renderer/assets/{index-BUcSHPha.js → index-Cf7vlFSn.js} +3 -3
- package/app/out/renderer/assets/{index-CZX0435B.js → index-CluH1o2q.js} +6 -6
- package/app/out/renderer/assets/{index-lAZsmnj1.css → index-CogwQwDN.css} +185 -32
- package/app/out/renderer/assets/{index-DEO9Jh2Y.js → index-Cw1n3klA.js} +5 -5
- package/app/out/renderer/assets/{index-BBUnWjLe.js → index-DFzvntIw.js} +3 -3
- package/app/out/renderer/assets/{index-g91Iwgxa.js → index-DHzyAhWM.js} +4 -4
- package/app/out/renderer/assets/{index-47oNNEnx.js → index-DhliHfCM.js} +6 -6
- package/app/out/renderer/assets/{index-DF_C6DjR.js → index-DkVFbCxC.js} +3 -3
- package/app/out/renderer/assets/{index-HCRA2-Q6.js → index-DpZJP5MT.js} +6 -6
- package/app/out/renderer/assets/{index-BXpNbFhG.js → index-Gfd_DiMG.js} +3 -3
- package/app/out/renderer/assets/{index-B110aKST.js → index-jOvNAYyP.js} +3 -3
- package/app/out/renderer/assets/{index-BTE0dEKO.js → index-rrJkk8KV.js} +6 -6
- package/app/out/renderer/assets/{index-DO5LsHlM.js → index-vfSerSmF.js} +1 -1
- package/app/out/renderer/assets/{infoDiagram-8eee0895-DpVt3Scv.js → infoDiagram-8eee0895-BCnBkXXS.js} +2 -2
- package/app/out/renderer/assets/{journeyDiagram-c64418c1-RYKX5mcV.js → journeyDiagram-c64418c1-Bq2wSX3k.js} +4 -4
- package/app/out/renderer/assets/{layout-BsbNXXgR.js → layout-BvkumzoT.js} +2 -2
- package/app/out/renderer/assets/{line-OzQTpJsh.js → line-eU4el-G4.js} +1 -1
- package/app/out/renderer/assets/{linear-DO5pdnqi.js → linear-DlBjMBEa.js} +1 -1
- package/app/out/renderer/assets/{mindmap-definition-8da855dc-D3zWs3h1.js → mindmap-definition-8da855dc-CzLBu7ao.js} +3 -3
- package/app/out/renderer/assets/{pieDiagram-a8764435-DDoNhSgQ.js → pieDiagram-a8764435--olrXFr_.js} +3 -3
- package/app/out/renderer/assets/{quadrantDiagram-1e28029f-ZO85SsRM.js → quadrantDiagram-1e28029f-BnpnBBgc.js} +3 -3
- package/app/out/renderer/assets/{requirementDiagram-08caed73-C-vKE6g8.js → requirementDiagram-08caed73-6O9WS7hn.js} +5 -5
- package/app/out/renderer/assets/{sankeyDiagram-a04cb91d-Cbqb2K-X.js → sankeyDiagram-a04cb91d-D-iJnK91.js} +2 -2
- package/app/out/renderer/assets/{sequenceDiagram-c5b8d532-BK4uvpEA.js → sequenceDiagram-c5b8d532-DBlK15cV.js} +3 -3
- package/app/out/renderer/assets/{stateDiagram-1ecb1508-DXa_YqNi.js → stateDiagram-1ecb1508-DKXKPYuk.js} +6 -6
- package/app/out/renderer/assets/{stateDiagram-v2-c2b004d7-Dm203Z8l.js → stateDiagram-v2-c2b004d7-DY288Eo5.js} +10 -10
- package/app/out/renderer/assets/{styles-b4e223ce-BV4b1eAh.js → styles-b4e223ce-CRJ_xgJ-.js} +1 -1
- package/app/out/renderer/assets/{styles-ca3715f6-CKhYSe7r.js → styles-ca3715f6-Bp_k5KLD.js} +1 -1
- package/app/out/renderer/assets/{styles-d45a18b0-DTCMfE-4.js → styles-d45a18b0-DLA8Gg6D.js} +4 -4
- package/app/out/renderer/assets/{svgDrawCommon-b86b1483-DK4i-dfJ.js → svgDrawCommon-b86b1483-Dm5CK2gQ.js} +1 -1
- package/app/out/renderer/assets/{timeline-definition-faaaa080-CE2LmuDH.js → timeline-definition-faaaa080-D-m9BHUg.js} +3 -3
- package/app/out/renderer/assets/{xychartDiagram-f5964ef8-Bd8KT9X9.js → xychartDiagram-f5964ef8-Drn4Rqev.js} +5 -5
- package/app/out/renderer/index.html +2 -2
- 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,9 +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
|
+
import fsp, { readFile } from "node:fs/promises";
|
|
4
5
|
import { execFile, spawn, execSync } from "node:child_process";
|
|
5
|
-
import path, { resolve, join, sep, isAbsolute, extname, dirname,
|
|
6
|
-
import { existsSync, statSync, readdirSync
|
|
6
|
+
import path, { resolve, join, sep, isAbsolute, basename, extname, dirname, relative } from "path";
|
|
7
|
+
import { existsSync, statSync, readdirSync, readFileSync, cpSync, writeFileSync, mkdirSync, appendFileSync, rmSync, unlinkSync, renameSync, openSync, constants, writeSync, closeSync, watch } from "fs";
|
|
7
8
|
import os$1, { homedir } from "os";
|
|
8
9
|
import { createHash, randomUUID } from "crypto";
|
|
9
10
|
import { Agent } from "@mariozechner/pi-agent-core";
|
|
@@ -12,9 +13,9 @@ import { createCodingTools, createGrepTool, createFindTool, createLsTool, DEFAUL
|
|
|
12
13
|
import { Type } from "@sinclair/typebox";
|
|
13
14
|
import { mkdir, writeFile } from "fs/promises";
|
|
14
15
|
import path$1 from "node:path";
|
|
15
|
-
import fsp from "node:fs/promises";
|
|
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;
|
|
@@ -496,6 +499,38 @@ function registerFileHandlers(handle, getCtx) {
|
|
|
496
499
|
return { success: false, error: err.message };
|
|
497
500
|
}
|
|
498
501
|
});
|
|
502
|
+
handle("file:reveal", (filePath) => {
|
|
503
|
+
const { projectPath } = getCtx();
|
|
504
|
+
const absPath = isAbsolute(filePath) ? filePath : resolve(projectPath, filePath);
|
|
505
|
+
if (!existsSync(absPath)) return { success: false, error: "Path not found." };
|
|
506
|
+
shell.showItemInFolder(absPath);
|
|
507
|
+
return { success: true };
|
|
508
|
+
});
|
|
509
|
+
handle("file:copy-item", (srcRelPath, destDirRelPath) => {
|
|
510
|
+
const { projectPath } = getCtx();
|
|
511
|
+
if (!projectPath) return { success: false, error: "No project folder selected." };
|
|
512
|
+
const srcAbs = isAbsolute(srcRelPath) ? srcRelPath : resolve(projectPath, srcRelPath);
|
|
513
|
+
const destDirAbs = isAbsolute(destDirRelPath) ? destDirRelPath : resolve(projectPath, destDirRelPath);
|
|
514
|
+
if (!isWithinRoot(projectPath, srcAbs)) return { success: false, error: "Source is outside workspace." };
|
|
515
|
+
if (!isWithinRoot(projectPath, destDirAbs)) return { success: false, error: "Destination is outside workspace." };
|
|
516
|
+
if (!existsSync(srcAbs)) return { success: false, error: "Source not found." };
|
|
517
|
+
const name = basename(srcAbs);
|
|
518
|
+
const ext = extname(name);
|
|
519
|
+
const stem = ext ? name.slice(0, -ext.length) : name;
|
|
520
|
+
let destAbs = join(destDirAbs, name);
|
|
521
|
+
let counter = 1;
|
|
522
|
+
while (existsSync(destAbs)) {
|
|
523
|
+
const suffix = counter === 1 ? " copy" : ` copy ${counter}`;
|
|
524
|
+
destAbs = join(destDirAbs, ext ? `${stem}${suffix}${ext}` : `${stem}${suffix}`);
|
|
525
|
+
counter++;
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
cpSync(srcAbs, destAbs, { recursive: true });
|
|
529
|
+
return { success: true, destPath: destAbs };
|
|
530
|
+
} catch (err) {
|
|
531
|
+
return { success: false, error: err.message };
|
|
532
|
+
}
|
|
533
|
+
});
|
|
499
534
|
handle("file:drop-to-dir", (fileName, base64Content, targetDirRelPath) => {
|
|
500
535
|
const { projectPath } = getCtx();
|
|
501
536
|
if (!projectPath) return { success: false, error: "No project folder selected." };
|
|
@@ -781,7 +816,7 @@ function registerAuthHandlers(handleRaw) {
|
|
|
781
816
|
if (!creds) return { success: false, error: "Not logged in" };
|
|
782
817
|
try {
|
|
783
818
|
const { refreshOpenAICodexToken } = await import("@mariozechner/pi-ai/oauth");
|
|
784
|
-
const newCreds = await refreshOpenAICodexToken(creds);
|
|
819
|
+
const newCreds = await refreshOpenAICodexToken(creds.refresh);
|
|
785
820
|
saveCodexCredentials(newCreds);
|
|
786
821
|
return { success: true };
|
|
787
822
|
} catch (err) {
|
|
@@ -939,7 +974,7 @@ const PATHS = {
|
|
|
939
974
|
const _warnedReaddirPaths = /* @__PURE__ */ new Set();
|
|
940
975
|
function safeReaddir(dir) {
|
|
941
976
|
try {
|
|
942
|
-
return readdirSync
|
|
977
|
+
return readdirSync(dir);
|
|
943
978
|
} catch (err) {
|
|
944
979
|
const code = err?.code;
|
|
945
980
|
if (code === "EPERM" || code === "ENOENT" || code === "ENOTDIR" || code === "EACCES") {
|
|
@@ -1688,7 +1723,7 @@ function listMemoryFiles(projectPath) {
|
|
|
1688
1723
|
const dir = memoryDir(projectPath);
|
|
1689
1724
|
if (!existsSync(dir)) return [];
|
|
1690
1725
|
try {
|
|
1691
|
-
const files = readdirSync
|
|
1726
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".md")).sort();
|
|
1692
1727
|
const entries = [];
|
|
1693
1728
|
for (const filename of files) {
|
|
1694
1729
|
const entry = readMemoryFile(projectPath, filename);
|
|
@@ -2463,6 +2498,7 @@ Hard rules:
|
|
|
2463
2498
|
- General web facts → brave_web_search or fetch.
|
|
2464
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.
|
|
2465
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.
|
|
2466
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.
|
|
2467
2503
|
- For repository/text inspection, use this order by default:
|
|
2468
2504
|
1) glob/grep to locate relevant files/sections;
|
|
@@ -4394,160 +4430,2555 @@ const DataAnalyzeSchema = Type.Object({
|
|
|
4394
4430
|
});
|
|
4395
4431
|
function createDataAnalyzeTool(ctx) {
|
|
4396
4432
|
return {
|
|
4397
|
-
name: "data_analyze",
|
|
4398
|
-
label: "Data Analysis",
|
|
4399
|
-
description: "Analyze a dataset using Python. Supports statistics, visualization (matplotlib/seaborn), data transformation, and modeling. Generated outputs (figures, tables) are saved to disk.\nUsage guidelines: (1) Use this tool for ANY analysis, visualization, statistics, or modeling — do not compute from raw data with read/grep. (2) Generate only the outputs the user requested; no extras.",
|
|
4400
|
-
parameters: DataAnalyzeSchema,
|
|
4433
|
+
name: "data_analyze",
|
|
4434
|
+
label: "Data Analysis",
|
|
4435
|
+
description: "Analyze a dataset using Python. Supports statistics, visualization (matplotlib/seaborn), data transformation, and modeling. Generated outputs (figures, tables) are saved to disk.\nUsage guidelines: (1) Use this tool for ANY analysis, visualization, statistics, or modeling — do not compute from raw data with read/grep. (2) Generate only the outputs the user requested; no extras.",
|
|
4436
|
+
parameters: DataAnalyzeSchema,
|
|
4437
|
+
execute: async (_toolCallId, rawParams) => {
|
|
4438
|
+
const params = rawParams;
|
|
4439
|
+
const filePath = typeof params.file_path === "string" ? params.file_path.trim() : "";
|
|
4440
|
+
const instructions = typeof params.instructions === "string" ? params.instructions.trim() : "";
|
|
4441
|
+
const taskType = typeof params.task_type === "string" ? params.task_type.trim().toLowerCase() : "analyze";
|
|
4442
|
+
if (!filePath) {
|
|
4443
|
+
return toAgentResult("data_analyze", toolError("MISSING_PARAMETER", "Missing file_path.", {
|
|
4444
|
+
suggestions: ["Provide a relative path to the data file (CSV, JSON, TSV, or XLSX)."]
|
|
4445
|
+
}));
|
|
4446
|
+
}
|
|
4447
|
+
if (!instructions) {
|
|
4448
|
+
return toAgentResult("data_analyze", toolError("MISSING_PARAMETER", "Missing instructions.", {
|
|
4449
|
+
suggestions: ["Describe what analysis to perform on the data."]
|
|
4450
|
+
}));
|
|
4451
|
+
}
|
|
4452
|
+
if (!["analyze", "visualize", "transform", "model"].includes(taskType)) {
|
|
4453
|
+
return toAgentResult("data_analyze", toolError("INVALID_PARAMETER", `Invalid task_type: ${taskType}. Use: analyze | visualize | transform | model.`, {
|
|
4454
|
+
suggestions: ["Valid task types: analyze, visualize, transform, model."]
|
|
4455
|
+
}));
|
|
4456
|
+
}
|
|
4457
|
+
const absDataFile = path$1.resolve(ctx.workspacePath, filePath);
|
|
4458
|
+
if (!fs.existsSync(absDataFile)) {
|
|
4459
|
+
return toAgentResult("data_analyze", toolError("FILE_NOT_FOUND", `File not found: ${filePath}`, {
|
|
4460
|
+
suggestions: [
|
|
4461
|
+
"Verify the file path is relative to the workspace root.",
|
|
4462
|
+
"Use the find or glob tool to locate the data file."
|
|
4463
|
+
],
|
|
4464
|
+
context: { workspacePath: ctx.workspacePath, resolvedPath: absDataFile }
|
|
4465
|
+
}));
|
|
4466
|
+
}
|
|
4467
|
+
const runId = Date.now().toString(36);
|
|
4468
|
+
const outputBase = path$1.join(ctx.workspacePath, ".research-pilot", "data-runs", runId);
|
|
4469
|
+
const figuresDir = path$1.join(outputBase, "figures");
|
|
4470
|
+
const tablesDir = path$1.join(outputBase, "tables");
|
|
4471
|
+
const dataDir = path$1.join(outputBase, "data");
|
|
4472
|
+
fs.mkdirSync(figuresDir, { recursive: true });
|
|
4473
|
+
fs.mkdirSync(tablesDir, { recursive: true });
|
|
4474
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
4475
|
+
const resultsFile = path$1.join(outputBase, "results.json");
|
|
4476
|
+
const rawPreview = fs.readFileSync(absDataFile, "utf-8").slice(0, 2e3);
|
|
4477
|
+
const ext = path$1.extname(absDataFile).toLowerCase();
|
|
4478
|
+
const formatHint = ext === ".csv" ? "CSV" : ext === ".tsv" ? "TSV" : ext === ".json" ? "JSON" : ext === ".xlsx" ? "XLSX" : "unknown";
|
|
4479
|
+
if (!ctx.callLlm) {
|
|
4480
|
+
return toAgentResult("data_analyze", toolError("LLM_UNAVAILABLE", "LLM not available for code generation.", {
|
|
4481
|
+
suggestions: ["Ensure the agent runtime has an LLM provider configured (callLlm in ResearchToolContext)."]
|
|
4482
|
+
}));
|
|
4483
|
+
}
|
|
4484
|
+
const taskSection = DATA_ANALYSIS_TASKS.split(/^## /m).find((s) => s.startsWith(taskType));
|
|
4485
|
+
const taskDesc = taskSection ? `## ${taskSection}` : "";
|
|
4486
|
+
const userPrompt = [
|
|
4487
|
+
`Task type: ${taskType} (${TASK_DESCRIPTIONS[taskType] ?? "Analysis"})`,
|
|
4488
|
+
"",
|
|
4489
|
+
taskDesc,
|
|
4490
|
+
"",
|
|
4491
|
+
`Data file format: ${formatHint}`,
|
|
4492
|
+
`Data file preview (first 2000 chars):`,
|
|
4493
|
+
"```",
|
|
4494
|
+
rawPreview,
|
|
4495
|
+
"```",
|
|
4496
|
+
"",
|
|
4497
|
+
`Instructions: ${instructions}`,
|
|
4498
|
+
"",
|
|
4499
|
+
"IMPORTANT: Use the pre-defined path variables (DATA_FILE, FIGURES_DIR, TABLES_DIR, DATA_DIR, RESULTS_FILE).",
|
|
4500
|
+
"Call write_results() at the end with your outputs list and summary dict.",
|
|
4501
|
+
"Output ONLY the Python code in a ```python code block."
|
|
4502
|
+
].join("\n");
|
|
4503
|
+
let generatedCode;
|
|
4504
|
+
try {
|
|
4505
|
+
const llmResponse = await ctx.callLlm(DATA_ANALYSIS_SYSTEM, userPrompt);
|
|
4506
|
+
const codeMatch = llmResponse.match(/```python\n([\s\S]*?)```/) || llmResponse.match(/```\n([\s\S]*?)```/);
|
|
4507
|
+
generatedCode = codeMatch ? codeMatch[1] : llmResponse;
|
|
4508
|
+
} catch (err) {
|
|
4509
|
+
return toAgentResult("data_analyze", toolError("EXECUTION_FAILED", `Code generation failed: ${err.message}`, {
|
|
4510
|
+
retryable: true,
|
|
4511
|
+
suggestions: ["Retry — the LLM may produce valid code on a subsequent attempt.", "Try simplifying the instructions."]
|
|
4512
|
+
}));
|
|
4513
|
+
}
|
|
4514
|
+
const pathDefinitions = [
|
|
4515
|
+
"",
|
|
4516
|
+
"# Pre-defined paths (set by the tool runtime)",
|
|
4517
|
+
`DATA_FILE = ${JSON.stringify(absDataFile)}`,
|
|
4518
|
+
`FIGURES_DIR = ${JSON.stringify(figuresDir)}`,
|
|
4519
|
+
`TABLES_DIR = ${JSON.stringify(tablesDir)}`,
|
|
4520
|
+
`DATA_DIR = ${JSON.stringify(dataDir)}`,
|
|
4521
|
+
`RESULTS_FILE = ${JSON.stringify(resultsFile)}`,
|
|
4522
|
+
""
|
|
4523
|
+
].join("\n");
|
|
4524
|
+
const fullScript = DATA_CODE_TEMPLATE + pathDefinitions + "\n" + generatedCode;
|
|
4525
|
+
const scriptPath = path$1.join(outputBase, "script.py");
|
|
4526
|
+
fs.writeFileSync(scriptPath, fullScript, "utf-8");
|
|
4527
|
+
try {
|
|
4528
|
+
const { stdout, stderr } = await execFileAsync$2("python3", [scriptPath], {
|
|
4529
|
+
cwd: ctx.workspacePath,
|
|
4530
|
+
timeout: ctx.settings?.dataAnalysis?.timeoutMs ?? 12e4,
|
|
4531
|
+
maxBuffer: 10 * 1024 * 1024
|
|
4532
|
+
});
|
|
4533
|
+
let manifest = null;
|
|
4534
|
+
if (fs.existsSync(resultsFile)) {
|
|
4535
|
+
try {
|
|
4536
|
+
manifest = JSON.parse(fs.readFileSync(resultsFile, "utf-8"));
|
|
4537
|
+
} catch {
|
|
4538
|
+
}
|
|
4539
|
+
}
|
|
4540
|
+
const outputs = [];
|
|
4541
|
+
for (const [dir, type] of [
|
|
4542
|
+
[figuresDir, "figure"],
|
|
4543
|
+
[tablesDir, "table"],
|
|
4544
|
+
[dataDir, "data"]
|
|
4545
|
+
]) {
|
|
4546
|
+
if (fs.existsSync(dir)) {
|
|
4547
|
+
for (const f of fs.readdirSync(dir)) {
|
|
4548
|
+
outputs.push({
|
|
4549
|
+
name: f,
|
|
4550
|
+
type,
|
|
4551
|
+
path: path$1.relative(ctx.workspacePath, path$1.join(dir, f))
|
|
4552
|
+
});
|
|
4553
|
+
}
|
|
4554
|
+
}
|
|
4555
|
+
}
|
|
4556
|
+
const payload = {
|
|
4557
|
+
stdout: stdout.slice(0, 4e3),
|
|
4558
|
+
stderr: stderr ? stderr.slice(0, 1e3) : void 0,
|
|
4559
|
+
outputs,
|
|
4560
|
+
manifest: manifest ? {
|
|
4561
|
+
summary: manifest.summary,
|
|
4562
|
+
warnings: manifest.warnings
|
|
4563
|
+
} : void 0,
|
|
4564
|
+
scriptPath: path$1.relative(ctx.workspacePath, scriptPath),
|
|
4565
|
+
runId
|
|
4566
|
+
};
|
|
4567
|
+
return toAgentResult("data_analyze", { success: true, data: payload });
|
|
4568
|
+
} catch (err) {
|
|
4569
|
+
const errorDetail = err.stderr?.slice(0, 2e3) || err.message;
|
|
4570
|
+
return toAgentResult("data_analyze", toolError("EXECUTION_FAILED", `Python execution failed: ${errorDetail}`, {
|
|
4571
|
+
retryable: true,
|
|
4572
|
+
suggestions: [
|
|
4573
|
+
`Review the generated script at ${path$1.relative(ctx.workspacePath, scriptPath)} for errors.`,
|
|
4574
|
+
"Check that required Python packages (pandas, matplotlib, seaborn, etc.) are installed.",
|
|
4575
|
+
"Try simplifying the analysis instructions."
|
|
4576
|
+
],
|
|
4577
|
+
context: {
|
|
4578
|
+
scriptPath: path$1.relative(ctx.workspacePath, scriptPath),
|
|
4579
|
+
runId
|
|
4580
|
+
},
|
|
4581
|
+
data: {
|
|
4582
|
+
scriptPath: path$1.relative(ctx.workspacePath, scriptPath),
|
|
4583
|
+
runId
|
|
4584
|
+
}
|
|
4585
|
+
}));
|
|
4586
|
+
}
|
|
4587
|
+
}
|
|
4588
|
+
};
|
|
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,
|
|
4401
6655
|
execute: async (_toolCallId, rawParams) => {
|
|
4402
6656
|
const params = rawParams;
|
|
4403
|
-
const
|
|
4404
|
-
const
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
suggestions: ["Provide a relative path to the data file (CSV, JSON, TSV, or XLSX)."]
|
|
4409
|
-
}));
|
|
4410
|
-
}
|
|
4411
|
-
if (!instructions) {
|
|
4412
|
-
return toAgentResult("data_analyze", toolError("MISSING_PARAMETER", "Missing instructions.", {
|
|
4413
|
-
suggestions: ["Describe what analysis to perform on the data."]
|
|
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."]
|
|
4414
6662
|
}));
|
|
4415
6663
|
}
|
|
4416
|
-
if (!
|
|
4417
|
-
return toAgentResult("
|
|
4418
|
-
suggestions: ["
|
|
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."]
|
|
4419
6667
|
}));
|
|
4420
6668
|
}
|
|
4421
|
-
const
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
|
|
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;
|
|
4430
6690
|
}
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
fs.mkdirSync(tablesDir, { recursive: true });
|
|
4438
|
-
fs.mkdirSync(dataDir, { recursive: true });
|
|
4439
|
-
const resultsFile = path$1.join(outputBase, "results.json");
|
|
4440
|
-
const rawPreview = fs.readFileSync(absDataFile, "utf-8").slice(0, 2e3);
|
|
4441
|
-
const ext = path$1.extname(absDataFile).toLowerCase();
|
|
4442
|
-
const formatHint = ext === ".csv" ? "CSV" : ext === ".tsv" ? "TSV" : ext === ".json" ? "JSON" : ext === ".xlsx" ? "XLSX" : "unknown";
|
|
4443
|
-
if (!ctx.callLlm) {
|
|
4444
|
-
return toAgentResult("data_analyze", toolError("LLM_UNAVAILABLE", "LLM not available for code generation.", {
|
|
4445
|
-
suggestions: ["Ensure the agent runtime has an LLM provider configured (callLlm in ResearchToolContext)."]
|
|
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."]
|
|
4446
6697
|
}));
|
|
4447
6698
|
}
|
|
4448
|
-
const
|
|
4449
|
-
const
|
|
4450
|
-
const
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
|
|
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;
|
|
4468
6727
|
try {
|
|
4469
|
-
|
|
4470
|
-
const codeMatch = llmResponse.match(/```python\n([\s\S]*?)```/) || llmResponse.match(/```\n([\s\S]*?)```/);
|
|
4471
|
-
generatedCode = codeMatch ? codeMatch[1] : llmResponse;
|
|
6728
|
+
providers = resolveProviders(prefs, auth, fallback);
|
|
4472
6729
|
} catch (err) {
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
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
|
+
]
|
|
4476
6744
|
}));
|
|
4477
6745
|
}
|
|
4478
|
-
const
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
const { stdout, stderr } = await execFileAsync$2("python3", [scriptPath], {
|
|
4493
|
-
cwd: ctx.workspacePath,
|
|
4494
|
-
timeout: ctx.settings?.dataAnalysis?.timeoutMs ?? 12e4,
|
|
4495
|
-
maxBuffer: 10 * 1024 * 1024
|
|
4496
|
-
});
|
|
4497
|
-
let manifest = null;
|
|
4498
|
-
if (fs.existsSync(resultsFile)) {
|
|
4499
|
-
try {
|
|
4500
|
-
manifest = JSON.parse(fs.readFileSync(resultsFile, "utf-8"));
|
|
4501
|
-
} catch {
|
|
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
|
+
}));
|
|
4502
6760
|
}
|
|
6761
|
+
referenceBytes = fs.readFileSync(refAbs);
|
|
6762
|
+
} catch (err) {
|
|
6763
|
+
return toAgentResult("generate_diagram", toolError("PATH_OUTSIDE_WORKSPACE", err.message));
|
|
4503
6764
|
}
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
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 });
|
|
4518
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 });
|
|
4519
6814
|
}
|
|
4520
|
-
const
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
|
|
4527
|
-
|
|
4528
|
-
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
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
|
|
4549
6870
|
}));
|
|
4550
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 });
|
|
4551
6982
|
}
|
|
4552
6983
|
};
|
|
4553
6984
|
}
|
|
@@ -6815,7 +9246,7 @@ function rebuildIndex() {
|
|
|
6815
9246
|
const lines = ["# Paper Wiki Index\n"];
|
|
6816
9247
|
lines.push("## Papers\n");
|
|
6817
9248
|
if (existsSync(papersDir)) {
|
|
6818
|
-
const files = readdirSync
|
|
9249
|
+
const files = readdirSync(papersDir).filter((f) => f.endsWith(".md")).sort();
|
|
6819
9250
|
for (const file of files) {
|
|
6820
9251
|
const content = safeReadFile(join(papersDir, file));
|
|
6821
9252
|
if (!content) continue;
|
|
@@ -6827,7 +9258,7 @@ function rebuildIndex() {
|
|
|
6827
9258
|
if (lines[lines.length - 1] === "## Papers\n") lines.push("_(empty)_");
|
|
6828
9259
|
lines.push("\n## Concepts\n");
|
|
6829
9260
|
if (existsSync(conceptsDir)) {
|
|
6830
|
-
const files = readdirSync
|
|
9261
|
+
const files = readdirSync(conceptsDir).filter((f) => f.endsWith(".md")).sort();
|
|
6831
9262
|
for (const file of files) {
|
|
6832
9263
|
const content = safeReadFile(join(conceptsDir, file));
|
|
6833
9264
|
if (!content) continue;
|
|
@@ -6865,19 +9296,19 @@ function readRecentLog(n = 20) {
|
|
|
6865
9296
|
function countPaperPages() {
|
|
6866
9297
|
const dir = join(getWikiRoot(), "papers");
|
|
6867
9298
|
if (!existsSync(dir)) return 0;
|
|
6868
|
-
return readdirSync
|
|
9299
|
+
return readdirSync(dir).filter((f) => f.endsWith(".md")).length;
|
|
6869
9300
|
}
|
|
6870
9301
|
function countConceptPages() {
|
|
6871
9302
|
const dir = join(getWikiRoot(), "concepts");
|
|
6872
9303
|
if (!existsSync(dir)) return 0;
|
|
6873
|
-
return readdirSync
|
|
9304
|
+
return readdirSync(dir).filter((f) => f.endsWith(".md")).length;
|
|
6874
9305
|
}
|
|
6875
9306
|
function listWikiPages() {
|
|
6876
9307
|
const root = getWikiRoot();
|
|
6877
9308
|
const readEntries = (subdir, kind) => {
|
|
6878
9309
|
const dir = join(root, subdir);
|
|
6879
9310
|
if (!existsSync(dir)) return [];
|
|
6880
|
-
return readdirSync
|
|
9311
|
+
return readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => {
|
|
6881
9312
|
const slug = f.replace(".md", "");
|
|
6882
9313
|
const content = safeReadFile(join(dir, f));
|
|
6883
9314
|
const titleMatch = content?.match(/^#\s+(.+)$/m);
|
|
@@ -7399,7 +9830,7 @@ function rebuildMemoryIndex() {
|
|
|
7399
9830
|
if (!existsSync(papersDir)) {
|
|
7400
9831
|
return { numPapers: 0, numTokens: 0, numAliases: 0, numEdges: 0 };
|
|
7401
9832
|
}
|
|
7402
|
-
const files = readdirSync
|
|
9833
|
+
const files = readdirSync(papersDir).filter((f) => f.endsWith(".md"));
|
|
7403
9834
|
const papers = [];
|
|
7404
9835
|
for (const file of files) {
|
|
7405
9836
|
const slug = file.replace(/\.md$/, "");
|
|
@@ -7874,7 +10305,7 @@ function listAllPaperSlugs() {
|
|
|
7874
10305
|
const dir = join(getWikiRoot(), "papers");
|
|
7875
10306
|
if (!existsSync(dir)) return [];
|
|
7876
10307
|
try {
|
|
7877
|
-
return readdirSync
|
|
10308
|
+
return readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""));
|
|
7878
10309
|
} catch {
|
|
7879
10310
|
return [];
|
|
7880
10311
|
}
|
|
@@ -8316,6 +10747,7 @@ function createResearchTools(ctx) {
|
|
|
8316
10747
|
tools.push(createLiteratureSearchTool(ctx));
|
|
8317
10748
|
tools.push(createConvertDocumentTool(ctx));
|
|
8318
10749
|
tools.push(createDataAnalyzeTool(ctx));
|
|
10750
|
+
tools.push(createGenerateDiagramTool(ctx));
|
|
8319
10751
|
const artifactTools = createResearchMemoryTools({
|
|
8320
10752
|
sessionId: ctx.sessionId,
|
|
8321
10753
|
projectPath: ctx.projectPath
|
|
@@ -8375,7 +10807,7 @@ function simplifyMessages(messages, maxMessages) {
|
|
|
8375
10807
|
const text = block.text;
|
|
8376
10808
|
content += text.length > 500 ? text.slice(0, 500) + "...[truncated]" : text;
|
|
8377
10809
|
content += "\n";
|
|
8378
|
-
} else if (block.type === "
|
|
10810
|
+
} else if (block.type === "toolCall" && "name" in block) {
|
|
8379
10811
|
content += `[Called ${block.name}]
|
|
8380
10812
|
`;
|
|
8381
10813
|
}
|
|
@@ -8399,7 +10831,7 @@ function agentCalledSaveMemoryThisTurn(messages) {
|
|
|
8399
10831
|
if (msg.role === "user") break;
|
|
8400
10832
|
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
|
8401
10833
|
for (const block of msg.content) {
|
|
8402
|
-
if (block && typeof block === "object" && "type" in block && block.type === "
|
|
10834
|
+
if (block && typeof block === "object" && "type" in block && block.type === "toolCall") {
|
|
8403
10835
|
if (block.name === "save-memory") return true;
|
|
8404
10836
|
}
|
|
8405
10837
|
}
|
|
@@ -8424,6 +10856,8 @@ async function maybeExtractMemories(config, messages, turnCount, extractEveryN =
|
|
|
8424
10856
|
});
|
|
8425
10857
|
const result = await completeSimple(config.model, {
|
|
8426
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.
|
|
8427
10861
|
messages: simplified
|
|
8428
10862
|
}, {
|
|
8429
10863
|
maxTokens: 1024,
|
|
@@ -8797,11 +11231,11 @@ function detectIntentsByRules(message) {
|
|
|
8797
11231
|
return intents;
|
|
8798
11232
|
}
|
|
8799
11233
|
const MAX_SKILL_PRELOAD = 5;
|
|
8800
|
-
async function matchSkillsWithLLM(model, apiKey, message, skills) {
|
|
11234
|
+
async function matchSkillsWithLLM(model, apiKey, message, skills, priorTurns = []) {
|
|
8801
11235
|
if (!model || skills.length === 0) return [];
|
|
8802
11236
|
const skillList = skills.map((s) => `- ${s.name}: ${s.description}`).join("\n");
|
|
8803
11237
|
const systemPrompt = [
|
|
8804
|
-
"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.",
|
|
8805
11239
|
"Return ONLY a JSON array of skill names. Return [] if none are relevant.",
|
|
8806
11240
|
"",
|
|
8807
11241
|
"Rules:",
|
|
@@ -8809,14 +11243,22 @@ async function matchSkillsWithLLM(model, apiKey, message, skills) {
|
|
|
8809
11243
|
"- Do not select skills speculatively",
|
|
8810
11244
|
`- Maximum ${MAX_SKILL_PRELOAD} skills`,
|
|
8811
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',
|
|
8812
11247
|
"",
|
|
8813
11248
|
"Available skills:",
|
|
8814
11249
|
skillList
|
|
8815
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;
|
|
8816
11258
|
try {
|
|
8817
11259
|
const result = await completeSimple(model, {
|
|
8818
11260
|
systemPrompt,
|
|
8819
|
-
messages: [{ role: "user", content:
|
|
11261
|
+
messages: [{ role: "user", content: userContent, timestamp: Date.now() }]
|
|
8820
11262
|
}, {
|
|
8821
11263
|
maxTokens: 100,
|
|
8822
11264
|
apiKey
|
|
@@ -9003,9 +11445,41 @@ async function createCoordinator(config) {
|
|
|
9003
11445
|
const textContent = result.content.find((c) => c.type === "text");
|
|
9004
11446
|
return textContent?.text ?? "";
|
|
9005
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"),
|
|
9006
11477
|
onToolCall,
|
|
9007
11478
|
onToolResult: wrappedOnToolResult,
|
|
9008
|
-
settings: config.resolvedSettings
|
|
11479
|
+
settings: config.resolvedSettings,
|
|
11480
|
+
getSettings: config.getResolvedSettings,
|
|
11481
|
+
getDiagramAuth: config.getDiagramAuth,
|
|
11482
|
+
rasterizeSvg: config.rasterizeSvg
|
|
9009
11483
|
};
|
|
9010
11484
|
const { tools: researchAgentTools, destroy: destroyResearchTools } = createResearchTools(toolCtx);
|
|
9011
11485
|
const codingTools = createCodingTools(projectPath);
|
|
@@ -9086,6 +11560,7 @@ async function createCoordinator(config) {
|
|
|
9086
11560
|
piModel,
|
|
9087
11561
|
settings.reserveTokens,
|
|
9088
11562
|
currentKey,
|
|
11563
|
+
void 0,
|
|
9089
11564
|
signal,
|
|
9090
11565
|
void 0,
|
|
9091
11566
|
compactionSummary
|
|
@@ -9224,8 +11699,12 @@ ${historyText}`,
|
|
|
9224
11699
|
async chat(message, mentions, images) {
|
|
9225
11700
|
try {
|
|
9226
11701
|
const intents = detectIntentsByRules(message);
|
|
11702
|
+
const priorTurns = turnHistory.slice(-2).map((t) => ({
|
|
11703
|
+
userMessage: t.userMessage,
|
|
11704
|
+
response: t.response
|
|
11705
|
+
}));
|
|
9227
11706
|
const currentKey = await resolveApiKey();
|
|
9228
|
-
const matchedSkillNames = await matchSkillsWithLLM(intentRouterModel, currentKey, message, skills);
|
|
11707
|
+
const matchedSkillNames = await matchSkillsWithLLM(intentRouterModel, currentKey, message, skills, priorTurns);
|
|
9229
11708
|
const matchedSkills = matchedSkillNames.map((name) => skills.find((s) => s.name === name)).filter((s) => s !== void 0);
|
|
9230
11709
|
for (const s of matchedSkills) {
|
|
9231
11710
|
onSkillLoaded?.(s.name);
|
|
@@ -9399,7 +11878,7 @@ ${message}` : message;
|
|
|
9399
11878
|
function readLatestExplainTurn(projectPath) {
|
|
9400
11879
|
const dir = join(projectPath, PATHS.explainDir);
|
|
9401
11880
|
if (!existsSync(dir)) return null;
|
|
9402
|
-
const files = readdirSync
|
|
11881
|
+
const files = readdirSync(dir).filter((name) => name.endsWith(".turn.json")).sort();
|
|
9403
11882
|
if (files.length === 0) return null;
|
|
9404
11883
|
const latest = files[files.length - 1];
|
|
9405
11884
|
return JSON.parse(readFileSync(join(dir, latest), "utf-8"));
|
|
@@ -9862,7 +12341,9 @@ function toPaperInput(paper) {
|
|
|
9862
12341
|
};
|
|
9863
12342
|
}
|
|
9864
12343
|
function hasCoreMetadataDelta(before, after) {
|
|
9865
|
-
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
|
+
);
|
|
9866
12347
|
}
|
|
9867
12348
|
async function enrichPaperArtifacts(options) {
|
|
9868
12349
|
const { projectPath, sessionId, debug, paperIds, onProgress } = options;
|
|
@@ -10121,7 +12602,7 @@ function resolveEntity(ref, dir, entityType, projectPath) {
|
|
|
10121
12602
|
if (!existsSync(dir)) {
|
|
10122
12603
|
return { ref, label: ref.raw, content: "", error: `No ${entityType} directory found` };
|
|
10123
12604
|
}
|
|
10124
|
-
const files = readdirSync
|
|
12605
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
10125
12606
|
const key = ref.key.toLowerCase();
|
|
10126
12607
|
for (const file of files) {
|
|
10127
12608
|
try {
|
|
@@ -10335,7 +12816,7 @@ function walk(root, rel, depth, out) {
|
|
|
10335
12816
|
const dir = rel ? join(root, rel) : root;
|
|
10336
12817
|
let entries;
|
|
10337
12818
|
try {
|
|
10338
|
-
entries = readdirSync
|
|
12819
|
+
entries = readdirSync(dir);
|
|
10339
12820
|
} catch {
|
|
10340
12821
|
return;
|
|
10341
12822
|
}
|
|
@@ -11077,7 +13558,8 @@ function resolveSettings(settings) {
|
|
|
11077
13558
|
researchIntensity: resolveResearchIntensity(settings.research.researchIntensity),
|
|
11078
13559
|
webSearch: resolveWebSearchDepth(settings.research.webSearchDepth),
|
|
11079
13560
|
dataAnalysis: { timeoutMs: resolveDataAnalysisTimeout(settings.dataAnalysis.executionTimeLimit) },
|
|
11080
|
-
autoSaveThreshold: resolveAutoSaveThreshold(settings.research.autoSaveSensitivity)
|
|
13561
|
+
autoSaveThreshold: resolveAutoSaveThreshold(settings.research.autoSaveSensitivity),
|
|
13562
|
+
diagram: { reviewProvider: settings.diagram?.reviewProvider ?? "auto" }
|
|
11081
13563
|
};
|
|
11082
13564
|
}
|
|
11083
13565
|
let _wikiLock = Promise.resolve();
|
|
@@ -11278,7 +13760,7 @@ function applyIdentityMigration(change) {
|
|
|
11278
13760
|
if (oldSlug !== newSlug) {
|
|
11279
13761
|
const conceptsDir = join(root, "concepts");
|
|
11280
13762
|
if (existsSync(conceptsDir)) {
|
|
11281
|
-
for (const f of readdirSync
|
|
13763
|
+
for (const f of readdirSync(conceptsDir)) {
|
|
11282
13764
|
if (!f.endsWith(".md")) continue;
|
|
11283
13765
|
const path2 = join(conceptsDir, f);
|
|
11284
13766
|
const content = safeReadFile(path2);
|
|
@@ -11346,6 +13828,46 @@ function scanForNewContent(projectPaths) {
|
|
|
11346
13828
|
byCanonicalKey.set(identity.canonicalKey, existing);
|
|
11347
13829
|
}
|
|
11348
13830
|
}
|
|
13831
|
+
{
|
|
13832
|
+
const byArxivId = /* @__PURE__ */ new Map();
|
|
13833
|
+
for (const [key, entries] of byCanonicalKey) {
|
|
13834
|
+
for (const { artifact } of entries) {
|
|
13835
|
+
if (artifact.arxivId && isValidArxivId(artifact.arxivId)) {
|
|
13836
|
+
const bareId = artifact.arxivId.replace(/^https?:\/\/arxiv\.org\/abs\//, "").replace(/v\d+$/, "");
|
|
13837
|
+
const keys = byArxivId.get(bareId);
|
|
13838
|
+
if (keys) {
|
|
13839
|
+
if (!keys.includes(key)) keys.push(key);
|
|
13840
|
+
} else {
|
|
13841
|
+
byArxivId.set(bareId, [key]);
|
|
13842
|
+
}
|
|
13843
|
+
}
|
|
13844
|
+
}
|
|
13845
|
+
}
|
|
13846
|
+
for (const [, keys] of byArxivId) {
|
|
13847
|
+
if (keys.length < 2) continue;
|
|
13848
|
+
let winnerKey = null;
|
|
13849
|
+
const keyPriority = (k) => k.startsWith("doi:") ? 3 : k.startsWith("arxiv:") ? 2 : 1;
|
|
13850
|
+
for (const k of keys) {
|
|
13851
|
+
for (const entry of byCanonicalKey.get(k) || []) {
|
|
13852
|
+
const ident = computeCanonicalKey(entry.artifact);
|
|
13853
|
+
if (!winnerKey || keyPriority(ident.canonicalKey) > keyPriority(winnerKey)) {
|
|
13854
|
+
winnerKey = ident.canonicalKey;
|
|
13855
|
+
}
|
|
13856
|
+
}
|
|
13857
|
+
}
|
|
13858
|
+
if (!winnerKey || !keys.includes(winnerKey)) {
|
|
13859
|
+
winnerKey = keys.reduce((a, b) => keyPriority(a) > keyPriority(b) ? a : b);
|
|
13860
|
+
}
|
|
13861
|
+
for (const loserKey of keys) {
|
|
13862
|
+
if (loserKey === winnerKey) continue;
|
|
13863
|
+
const loserEntries = byCanonicalKey.get(loserKey);
|
|
13864
|
+
if (!loserEntries) continue;
|
|
13865
|
+
const winnerEntries = byCanonicalKey.get(winnerKey);
|
|
13866
|
+
winnerEntries.push(...loserEntries);
|
|
13867
|
+
byCanonicalKey.delete(loserKey);
|
|
13868
|
+
}
|
|
13869
|
+
}
|
|
13870
|
+
}
|
|
11349
13871
|
for (const [canonicalKey, entries] of byCanonicalKey) {
|
|
11350
13872
|
const best = entries.reduce((a, b) => {
|
|
11351
13873
|
if (a.artifact.fulltextPath && !b.artifact.fulltextPath) return a;
|
|
@@ -11646,7 +14168,7 @@ function listExistingConceptSlugs() {
|
|
|
11646
14168
|
const dir = join(getWikiRoot(), "concepts");
|
|
11647
14169
|
if (!existsSync(dir)) return [];
|
|
11648
14170
|
try {
|
|
11649
|
-
return readdirSync
|
|
14171
|
+
return readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => f.replace(".md", ""));
|
|
11650
14172
|
} catch {
|
|
11651
14173
|
return [];
|
|
11652
14174
|
}
|
|
@@ -11827,7 +14349,7 @@ function createWikiAgent(config) {
|
|
|
11827
14349
|
}
|
|
11828
14350
|
function emitStatus(pending = 0) {
|
|
11829
14351
|
if (!config.onStatus) return;
|
|
11830
|
-
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;
|
|
11831
14353
|
config.onStatus({
|
|
11832
14354
|
state: state === "processing" ? "processing" : state === "paused" ? "paused" : "idle",
|
|
11833
14355
|
processed: processedThisSession,
|
|
@@ -12284,7 +14806,7 @@ function listWikiPaperMeta() {
|
|
|
12284
14806
|
if (!existsSync(papersDir)) return [];
|
|
12285
14807
|
const seen = /* @__PURE__ */ new Set();
|
|
12286
14808
|
const results = [];
|
|
12287
|
-
for (const file of readdirSync
|
|
14809
|
+
for (const file of readdirSync(papersDir)) {
|
|
12288
14810
|
if (!file.endsWith(".md")) continue;
|
|
12289
14811
|
const slug = file.slice(0, -3);
|
|
12290
14812
|
const filePath = join(papersDir, file);
|
|
@@ -12321,9 +14843,9 @@ function classifyKey(canonicalKey) {
|
|
|
12321
14843
|
return "title";
|
|
12322
14844
|
}
|
|
12323
14845
|
const TIER_RANK = {
|
|
12324
|
-
|
|
12325
|
-
//
|
|
12326
|
-
|
|
14846
|
+
doi: 3,
|
|
14847
|
+
// DOI > arXiv > title — matches computeCanonicalKey priority
|
|
14848
|
+
arxiv: 2,
|
|
12327
14849
|
title: 1,
|
|
12328
14850
|
bogus: 0
|
|
12329
14851
|
};
|
|
@@ -12351,7 +14873,7 @@ function findDriftGroups() {
|
|
|
12351
14873
|
}
|
|
12352
14874
|
}
|
|
12353
14875
|
const byTitle = /* @__PURE__ */ new Map();
|
|
12354
|
-
for (const f of readdirSync
|
|
14876
|
+
for (const f of readdirSync(papersDir)) {
|
|
12355
14877
|
if (!f.endsWith(".md")) continue;
|
|
12356
14878
|
const slug = f.slice(0, -3);
|
|
12357
14879
|
const content = safeReadFile(join(papersDir, f));
|
|
@@ -12449,7 +14971,7 @@ async function reconcileIdentityDrift(opts = { dryRun: true }) {
|
|
|
12449
14971
|
const papersDir = join(getWikiRoot(), "papers");
|
|
12450
14972
|
const liveSlugs = /* @__PURE__ */ new Set();
|
|
12451
14973
|
if (existsSync(papersDir)) {
|
|
12452
|
-
for (const f of readdirSync
|
|
14974
|
+
for (const f of readdirSync(papersDir)) {
|
|
12453
14975
|
if (f.endsWith(".md")) liveSlugs.add(f.slice(0, -3));
|
|
12454
14976
|
}
|
|
12455
14977
|
}
|
|
@@ -12467,6 +14989,61 @@ async function reconcileIdentityDrift(opts = { dryRun: true }) {
|
|
|
12467
14989
|
};
|
|
12468
14990
|
});
|
|
12469
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
|
+
}
|
|
12470
15047
|
const EMPTY = {
|
|
12471
15048
|
version: 1,
|
|
12472
15049
|
updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
@@ -12775,7 +15352,7 @@ async function ensureCoordinator(state, win, model, options) {
|
|
|
12775
15352
|
if (creds.expires < Date.now() + 6e4) {
|
|
12776
15353
|
try {
|
|
12777
15354
|
const { refreshOpenAICodexToken } = await import("@mariozechner/pi-ai/oauth");
|
|
12778
|
-
const newCreds = await refreshOpenAICodexToken(creds);
|
|
15355
|
+
const newCreds = await refreshOpenAICodexToken(creds.refresh);
|
|
12779
15356
|
saveCodexCredentials(newCreds);
|
|
12780
15357
|
return newCreds.access;
|
|
12781
15358
|
} catch {
|
|
@@ -12788,12 +15365,46 @@ async function ensureCoordinator(state, win, model, options) {
|
|
|
12788
15365
|
const initEvent = { type: "system", summary: "Initializing agent (first run may take 1-2 minutes for document processing setup)..." };
|
|
12789
15366
|
state.realtimeBuffer.pushActivity(initEvent);
|
|
12790
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
|
+
};
|
|
12791
15393
|
state.coordinator = await createCoordinator({
|
|
12792
15394
|
apiKey,
|
|
12793
15395
|
getApiKeyOverride,
|
|
12794
15396
|
model: state.currentModel,
|
|
12795
15397
|
reasoningEffort: state.currentReasoningEffort,
|
|
12796
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,
|
|
12797
15408
|
projectPath: state.projectPath,
|
|
12798
15409
|
sessionId: state.sessionId,
|
|
12799
15410
|
debug: !!process.env.RESEARCH_COPILOT_DEBUG,
|
|
@@ -12851,16 +15462,14 @@ async function ensureCoordinator(state, win, model, options) {
|
|
|
12851
15462
|
const r2 = result;
|
|
12852
15463
|
if (r2.success) {
|
|
12853
15464
|
invalidateEntityCache(runProjectPath);
|
|
12854
|
-
|
|
12855
|
-
|
|
12856
|
-
|
|
12857
|
-
|
|
12858
|
-
|
|
12859
|
-
|
|
12860
|
-
|
|
12861
|
-
|
|
12862
|
-
safeSend(win, "agent:file-created", absPath);
|
|
12863
|
-
}
|
|
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);
|
|
12864
15473
|
}
|
|
12865
15474
|
}
|
|
12866
15475
|
}
|
|
@@ -13123,7 +15732,7 @@ function registerIpcHandlers() {
|
|
|
13123
15732
|
if (creds.expires < Date.now() + 6e4) {
|
|
13124
15733
|
try {
|
|
13125
15734
|
const { refreshOpenAICodexToken } = await import("@mariozechner/pi-ai/oauth");
|
|
13126
|
-
const newCreds = await refreshOpenAICodexToken(creds);
|
|
15735
|
+
const newCreds = await refreshOpenAICodexToken(creds.refresh);
|
|
13127
15736
|
saveCodexCredentials(newCreds);
|
|
13128
15737
|
return newCreds.access;
|
|
13129
15738
|
} catch {
|
|
@@ -13957,6 +16566,35 @@ function destroyAllTerminals() {
|
|
|
13957
16566
|
terminals.delete(id);
|
|
13958
16567
|
}
|
|
13959
16568
|
}
|
|
16569
|
+
protocol.registerSchemesAsPrivileged([
|
|
16570
|
+
{
|
|
16571
|
+
scheme: "workspace-asset",
|
|
16572
|
+
privileges: {
|
|
16573
|
+
standard: true,
|
|
16574
|
+
secure: true,
|
|
16575
|
+
supportFetchAPI: true,
|
|
16576
|
+
stream: true,
|
|
16577
|
+
corsEnabled: true
|
|
16578
|
+
}
|
|
16579
|
+
}
|
|
16580
|
+
]);
|
|
16581
|
+
function mimeForExtension(p) {
|
|
16582
|
+
const ext = (p.split(".").pop() || "").toLowerCase();
|
|
16583
|
+
const table = {
|
|
16584
|
+
png: "image/png",
|
|
16585
|
+
jpg: "image/jpeg",
|
|
16586
|
+
jpeg: "image/jpeg",
|
|
16587
|
+
gif: "image/gif",
|
|
16588
|
+
webp: "image/webp",
|
|
16589
|
+
svg: "image/svg+xml",
|
|
16590
|
+
bmp: "image/bmp",
|
|
16591
|
+
avif: "image/avif",
|
|
16592
|
+
ico: "image/x-icon",
|
|
16593
|
+
heic: "image/heic",
|
|
16594
|
+
heif: "image/heif"
|
|
16595
|
+
};
|
|
16596
|
+
return table[ext] || "application/octet-stream";
|
|
16597
|
+
}
|
|
13960
16598
|
setMaxListeners(20);
|
|
13961
16599
|
loadApiKeysFromConfig();
|
|
13962
16600
|
if (!process.env.PI_CACHE_RETENTION) {
|
|
@@ -14026,6 +16664,22 @@ app.whenReady().then(() => {
|
|
|
14026
16664
|
if (iconPath && process.platform === "darwin") {
|
|
14027
16665
|
app.dock?.setIcon(iconPath);
|
|
14028
16666
|
}
|
|
16667
|
+
protocol.handle("workspace-asset", async (request) => {
|
|
16668
|
+
try {
|
|
16669
|
+
const url = new URL(request.url);
|
|
16670
|
+
const absPath = decodeURIComponent(url.pathname);
|
|
16671
|
+
const data = await readFile(absPath);
|
|
16672
|
+
return new Response(data, {
|
|
16673
|
+
status: 200,
|
|
16674
|
+
headers: {
|
|
16675
|
+
"Content-Type": mimeForExtension(absPath),
|
|
16676
|
+
"Cache-Control": "no-cache"
|
|
16677
|
+
}
|
|
16678
|
+
});
|
|
16679
|
+
} catch {
|
|
16680
|
+
return new Response("", { status: 404 });
|
|
16681
|
+
}
|
|
16682
|
+
});
|
|
14029
16683
|
registerIpcHandlers();
|
|
14030
16684
|
registerTerminalHandlers();
|
|
14031
16685
|
registerWindow(createWindow());
|