From c0e7cd40e7783803cc16792b8a24de04d77c2213 Mon Sep 17 00:00:00 2001 From: antianqi <75944423+antianqi@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:43:29 +0800 Subject: [PATCH 1/2] ai-node-canvas 0.83.0: the review is answered, and the progress panel lets go The review of 0.82.0 left thirteen things to fix. They are fixed here; the one that was approved as it is stays as it is. A download could be sent to a machine of its own: the address was judged once, and the connection could go somewhere else. The address that was judged is now the address that is used - the name is resolved once, and the connection is pinned to that address, on every hop of a redirect. The private addresses are judged by the rules of Node's own BlockList, and an IPv6 address that carries an IPv4 inside it is judged by that IPv4. A file could be written through a link that points at nothing. A name that is already taken is refused instead of written through, and the name of a template is the name of the file that holds it: a template of the user cannot be overwritten, and a template that could not be applied is not saved. A run the runtime refused left the node busy for ever. The refusal now names the run that is going, the chain waits for that run, and the node is free the moment the run is refused. The progress panel could not be put away, and a run that had failed stayed on the screen for ever. It is a progress panel, not a history: a run that is over is kept thirty seconds so its reason can still be read, and then it goes. It has a button that puts it away, and a run that starts brings it back. The READMEs and the MCP description now say what the tools do: that a template can be listed and put on a canvas, that putting one there does not run it, and that a download can only go to an address the runtime is willing to reach. 246 tests, 246 mutations, 0 errors. --- .../.minimax-plugin/plugin.json | 2 +- plugins/antianqi/ai-node-canvas/README.md | 11 +- .../antianqi/ai-node-canvas/README.zh-CN.md | 8 +- .../ai-node-canvas/miniapp/client/actions.mjs | 79 ++-- .../ai-node-canvas/miniapp/client/app.js | 82 +++- .../ai-node-canvas/miniapp/client/forms.mjs | 4 +- .../miniapp/client/run-watch.mjs | 93 ++++ .../ai-node-canvas/miniapp/client/styles.css | 21 + .../miniapp/client/task-panel.mjs | 37 ++ .../miniapp/node/agent-runner.mjs | 57 ++- .../miniapp/node/media-store.mjs | 304 ++++++++++--- .../ai-node-canvas/miniapp/node/server.mjs | 91 ++-- .../ai-node-canvas/miniapp/node/templates.mjs | 61 ++- .../miniapp/node/video-request.mjs | 28 ++ plugins/antianqi/ai-node-canvas/package.json | 2 +- .../antianqi/ai-node-canvas/servers.mcp.json | 2 +- .../ai-node-canvas/tests/smoke.test.mjs | 409 +++++++++++++++++- 17 files changed, 1112 insertions(+), 179 deletions(-) create mode 100644 plugins/antianqi/ai-node-canvas/miniapp/client/task-panel.mjs diff --git a/plugins/antianqi/ai-node-canvas/.minimax-plugin/plugin.json b/plugins/antianqi/ai-node-canvas/.minimax-plugin/plugin.json index 179fc9ef..f6a1de69 100644 --- a/plugins/antianqi/ai-node-canvas/.minimax-plugin/plugin.json +++ b/plugins/antianqi/ai-node-canvas/.minimax-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "ai-node-canvas", "displayName": "节点画布", - "version": "0.82.0", + "version": "0.83.0", "description": "无限画布节点编排:把 MiniMax 模型生成的文本、图片、视频串成工作流,连线即数据流,上游产物可直接被下游引用。", "author": "antianqi", "icon": "icon.png", diff --git a/plugins/antianqi/ai-node-canvas/README.md b/plugins/antianqi/ai-node-canvas/README.md index 5ba58303..81bee656 100644 --- a/plugins/antianqi/ai-node-canvas/README.md +++ b/plugins/antianqi/ai-node-canvas/README.md @@ -312,7 +312,8 @@ this package. - **A template of the user can be managed** — every template of the user carries a delete button next to it (the question is asked in the page, like every other question here). The ready-made ones are not the user's to take - away. + away. **Deleting a template of the user cannot be undone**: the file is + gone, and there is no copy of it anywhere. - **The canvas is a workbench, and it saves itself** — a change is written down about a second later, and the state beside the canvas name says whether it happened, so the toolbar has no **保存** button: a button that @@ -639,6 +640,14 @@ separate pictures. other request — image, video, audio, music, voice catalogue, video understanding — goes through the Host connector (`context.hostConnector`), and the Host owns the credentials and the egress. +- **The addresses a download may go to** — a link that points at this machine + or at the network it sits in is refused: the loopback, the private ranges, + the link-local range, the shared space of a carrier and the reserved ranges, + in IPv4 and in IPv6 alike — including the forms that carry an IPv4 address + inside an IPv6 one (`::ffff:7f00:1`, `::7f00:1`, `64:ff9b::7f00:1`). The + name is looked up **once**, and the address that was judged is the address + that is connected to, so a name cannot answer one thing for the check and + another for the connection. Every hop of a redirect is judged the same way. - **The two steps of an upload** — the provider's upload command does not take a local path, because the provider cannot read this machine's disk. It takes the file name and the media type and answers with a signed form; the runtime diff --git a/plugins/antianqi/ai-node-canvas/README.zh-CN.md b/plugins/antianqi/ai-node-canvas/README.zh-CN.md index 41420998..ed25a5f1 100644 --- a/plugins/antianqi/ai-node-canvas/README.zh-CN.md +++ b/plugins/antianqi/ai-node-canvas/README.zh-CN.md @@ -154,7 +154,8 @@ MiniMax Code 里的无限画布节点图。每个节点是一次模型运行, 文件里只有步骤,没有结果也没有素材,所以换台机器打开也是完整的; 一条链从哪张图起步,那个**空输入位**也跟着走,放的人自己往里放图。 - **自己的模板能管** — 「我的模板」里每一条旁边都有删除按钮(删除前也是在页面里问, - 跟这里别的问题一样)。内置的那些不是你的,删不了。 + 跟这里别的问题一样)。内置的那些不是你的,删不了。**删掉自己的模板找不回来**: + 文件没有了,机器上也不会留下另一份。 - **画布是工作台,它自己会存** — 改动后一秒左右自己落盘,页头画布名旁边那颗状态芯片说了算, 所以工具栏上**没有「保存」按钮**:手动再存一次只是把页面已经在做的事做第二遍。 窗口关掉时那份本地草稿也跟着走,所以「刚改完就关」也不会丢。 @@ -398,6 +399,11 @@ provider 有**几种不同的请求**把素材变成视频,不是同一个请 产物下载一份存本地(这样画布里的链接不会过期),本地文件要作为生成参考时会先 上传给 provider(provider 够不到回环地址)。其余请求(图片、视频、音频、音乐、 音色清单、视频理解)全部走宿主连接器(`context.hostConnector`),凭据与出网由宿主掌握。 +- **下载能去的地址** — 指向本机、指向本机所在网络的链接一律拒绝:回环、私有网段、 + 链路本地、运营商共享地址段和保留网段,IPv4 与 IPv6 都一样,也包括把 IPv4 + 藏在 IPv6 里的那几种写法(`::ffff:7f00:1`、`::7f00:1`、`64:ff9b::7f00:1`)。 + 主机名**只解析一次**,判定用的那个地址就是连接用的那个地址,所以同一个名字 + 没法「查的时候是一回事、连的时候是另一回事」。重定向的每一跳都按同一条规则判。 - **上传的两步** — provider 的上传命令不收本地路径(provider 读不到本机磁盘): 它收文件名与媒体类型,回一份预签名表单;运行时再把字节以 `multipart/form-data` POST 到那份表单指向的主机(表单字段原样带上,文件放在 `file_field_name` 指定的那一项)。 diff --git a/plugins/antianqi/ai-node-canvas/miniapp/client/actions.mjs b/plugins/antianqi/ai-node-canvas/miniapp/client/actions.mjs index ac576d83..d0349926 100644 --- a/plugins/antianqi/ai-node-canvas/miniapp/client/actions.mjs +++ b/plugins/antianqi/ai-node-canvas/miniapp/client/actions.mjs @@ -528,26 +528,15 @@ function templateRef(value, ids) { export function applyTemplate(canvas, template, context = {}) { const doc = canvas ?? { nodes: [], connections: [] }; const actions = Array.isArray(template?.actions) ? template.actions : []; - if (!actions.length) return { canvas: doc, error: '这个模板是空的' }; // A template is a skeleton: it may make nodes and draw the lines between // them. It may not take anything away — a template that could would tidy // up a canvas it was dropped on, and the steps of a chain are not a licence - // to remove things. - for (const action of actions) { - const type = String(action?.type ?? ''); - if (!TEMPLATE_ACTION_TYPES.includes(type)) { - return { - canvas: doc, - error: `模板里有一步做不了的事:${type || '(没有说是什么)'}(模板只能:${TEMPLATE_ACTION_TYPES.join('、')})`, - }; - } - if (type === 'connect') { - const from = String(action?.args?.from ?? ''); - const to = String(action?.args?.to ?? ''); - if (!/^@\d+$/.test(from) || !/^@\d+$/.test(to)) { - return { canvas: doc, error: '模板里的线要指向模板自己做的节点(@1 这样的名字)' }; - } - } + // to remove things. The steps are judged by the one rule that judges them + // everywhere: the file that is read, the canvas the template is put on and + // the place it is kept in. + const problem = templateStepsError(actions); + if (problem) { + return { canvas: doc, error: problem }; } const ctx = { idFor: fallbackId, ...context }; const view = doc.viewport ?? { x: 80, y: 60 }; @@ -661,6 +650,36 @@ export const TEMPLATE_FILE_VERSION = 1; */ export const TEMPLATE_ACTION_TYPES = ['add_node', 'connect']; +/** + * Whether the steps of a template are steps a template may carry. + * + * One rule, read by the three places that meet a template: the file that is + * read, the canvas a template is put on, and the place a template is kept + * in. The answer is empty when the steps are fine, and says what is wrong + * when they are not. + */ +export function templateStepsError(actions) { + const list = Array.isArray(actions) ? actions : []; + if (!list.length) return '这个模板里没有步骤'; + for (const action of list) { + if (!action || typeof action !== 'object' || !action.args || typeof action.args !== 'object') { + return '这个模板里有一步写坏了'; + } + const type = String(action.type ?? ''); + if (!TEMPLATE_ACTION_TYPES.includes(type)) { + return `这个模板里有一步不是节点也不是连线:${type || '空'}(模板只能:${TEMPLATE_ACTION_TYPES.join('、')})`; + } + if (type === 'connect') { + const from = String(action.args.from ?? ''); + const to = String(action.args.to ?? ''); + if (!/^@\d+$/.test(from) || !/^@\d+$/.test(to)) { + return '模板里的线要指向模板自己做的节点(@1 这样的名字)'; + } + } + } + return ''; +} + /** * The document a template is shared as. * @@ -705,16 +724,10 @@ export function readTemplateFile(text) { return { ok: false, error: '这个模板的版本比这个画布新,请先更新画布' }; } const actions = parsed.actions; - if (!Array.isArray(actions) || !actions.length) { - return { ok: false, error: '这个模板里没有步骤' }; - } - for (const action of actions) { - if (!action || typeof action !== 'object' || !action.args || typeof action.args !== 'object') { - return { ok: false, error: '这个模板里有一步写坏了' }; - } - if (action.type !== 'add_node' && action.type !== 'connect') { - return { ok: false, error: `这个模板里有一步不是节点也不是连线:${action.type ?? '空'}` }; - } + // The steps are judged by the one rule that judges them everywhere. + const problem = templateStepsError(actions); + if (problem) { + return { ok: false, error: problem }; } return { ok: true, @@ -727,6 +740,18 @@ export function readTemplateFile(text) { }; } +/** + * The name a canvas takes from a template. + * + * The name is given to the canvas only when the steps are really on it: a + * template that does not fit leaves the canvas as it was, so a canvas with a + * name and no steps is never stored. + */ +export function canvasTitleAfterTemplate(template, applied) { + if (!applied) return ''; + return String(template?.title ?? '').trim(); +} + /** * Apply one action to a canvas document. * diff --git a/plugins/antianqi/ai-node-canvas/miniapp/client/app.js b/plugins/antianqi/ai-node-canvas/miniapp/client/app.js index b520f9ac..0491cb12 100644 --- a/plugins/antianqi/ai-node-canvas/miniapp/client/app.js +++ b/plugins/antianqi/ai-node-canvas/miniapp/client/app.js @@ -12,8 +12,9 @@ import { detectMention, parseSegments, plainText, textIndexBefore } from './mention.mjs'; import { bandSelection, BATCH_COUNTS, BATCH_MODES, cropRectAfterDrag, dragPositions, effectiveOption, fanOutParams, fitMediaBox, fitParamsToCaps, inputKindsFor, isControlTarget, kindOf, mentionCandidates as mentionCandidatesOf, modelSummary, NODE_GAP, placeOverlay, planBatchPlacement, planGridSplit, planPaste, portOfConnection, portState, pushAside, resolveEventTarget, roleOf, slotsOf as slotsOfForm, TILE_CARD, toolPrompt, upstreamNodes } from './tools.mjs'; import { cardChrome, DATA_KINDS, hasTextBox, isTypingTarget, modeLabelOf, modesFor, NODE_FORMS, NODE_KINDS, modeOf, modeRecordOf, modesOf, portKinds, portKindsFor, portList, ratioLabel, ratioOptionsFor, redrawBlocker, runModeOf, stripInputs } from './forms.mjs'; -import { isBusyRefusal, runIsGone, waitForTheRun, withDeadline } from './run-watch.mjs'; -import { applyAction, applyModel, inheritedTextFor, lineKind, linesDroppedByMode, materializeResults, planIncoming, planPort, promptTemplateFor, readTemplateFile, runSpecFor, syncMentions, templateFile, templateFromCanvas } from './actions.mjs'; +import { isBusyRefusal, panelClosed, panelIsShown, panelTasks, runIsGone, taskIsExpired, theRunThatIsGoing, theRunToWaitFor, waitForTheRun, withDeadline } from './run-watch.mjs'; +import { panelTitleRow } from './task-panel.mjs'; +import { applyAction, applyModel, canvasTitleAfterTemplate, inheritedTextFor, lineKind, linesDroppedByMode, materializeResults, planIncoming, planPort, promptTemplateFor, readTemplateFile, runSpecFor, syncMentions, templateFile, templateFromCanvas } from './actions.mjs'; (() => { 'use strict'; @@ -101,6 +102,8 @@ import { applyAction, applyModel, inheritedTextFor, lineKind, linesDroppedByMode nodeTasks: new Map(), // nodeId -> taskId polling: new Set(), // taskIds whose poll is still on its way pollTimer: null, + // The user put the progress panel away: it comes back when a run starts. + taskPanelClosed: false, actionTimer: null, saveTimer: null, historyTimer: null, @@ -161,6 +164,9 @@ import { applyAction, applyModel, inheritedTextFor, lineKind, linesDroppedByMode const error = new Error(payload?.message || payload?.error || `HTTP ${response.status}`); error.code = payload?.error || 'http_error'; error.status = response.status; + // A refused run names the run that is going: the one that asked waits + // for that run instead of standing still. + if (payload?.runId) error.runId = payload.runId; if (response.status === 401) { // The Host rotates the loopback credential when the runtime restarts, // and that happens on every plugin update. The page still holds the @@ -3230,14 +3236,18 @@ import { applyAction, applyModel, inheritedTextFor, lineKind, linesDroppedByMode } state.pendingPanel = false; const now = Date.now(); - const tasks = [...state.tasks.values()].filter( - (task) => task.status === 'running' || task.status === 'waiting-question' || task.status === 'error' - // A run that just finished stays a moment. Its log says how long it - // waited for the machine and how long it ran, and that is the answer - // to "why was this one slower?". - || (task.status === 'done' && now - (task.finishedAt ?? 0) < 30_000), - ); - if (tasks.length === 0) { + // The panel shows what is going on and what just finished. A run that is + // over stays a moment — a failed one too, so the reason can be read — and + // then it goes: the panel is not a history of everything that ever ran. + const tasks = panelTasks(state.tasks.values(), { now }); + // A run that is over and out of the panel is let go: the table does not + // grow for ever. + for (const [id, task] of state.tasks) { + if (taskIsExpired(task, { now })) state.tasks.delete(id); + } + // The panel sits over the canvas, so the user can put it away. It comes + // back when a run starts, because that is what the panel is for. + if (!panelIsShown({ closed: state.taskPanelClosed, tasks })) { // The panel is hidden, but its old content is cleared as well: a hidden // panel that still holds buttons is a stale control in the page. panel.hidden = true; @@ -3246,7 +3256,13 @@ import { applyAction, applyModel, inheritedTextFor, lineKind, linesDroppedByMode } panel.hidden = false; panel.textContent = ''; - panel.appendChild(el('div', 'nxc-taskpanel__title', '运行进度')); + const title = panelTitleRow(document, { + onPutAway: () => { + state.taskPanelClosed = panelClosed({ closed: state.taskPanelClosed, event: 'put-away' }); + panel.hidden = true; + }, + }); + panel.appendChild(title); for (const task of tasks) { const item = el('div', 'nxc-taskpanel__item'); const head = el('div', 'nxc-taskpanel__head'); @@ -3617,7 +3633,7 @@ import { applyAction, applyModel, inheritedTextFor, lineKind, linesDroppedByMode ); if (applied.error) { showBanner(`模板没有铺上:${applied.error}`, 'error'); - return; + return false; } state.canvas.nodes = applied.canvas.nodes; state.canvas.connections = applied.canvas.connections; @@ -3625,6 +3641,7 @@ import { applyAction, applyModel, inheritedTextFor, lineKind, linesDroppedByMode markDirty(); render(); showBanner(`已铺上「${template.title}」,点节点上的「生成」开始`); + return true; } /** @@ -3662,7 +3679,8 @@ import { applyAction, applyModel, inheritedTextFor, lineKind, linesDroppedByMode * The question is asked in the page: a native confirm() is a separate OS * window that never reaches the screen inside the MiniApp host. */ - async function deleteTemplate(template) { const yes = await confirmInPage(`确定删掉模板「${template.title}」?画布本身不受影响。`); + async function deleteTemplate(template) { + const yes = await confirmInPage(`确定删掉模板「${template.title}」?画布本身不受影响,删掉就找不回来了。`); if (!yes) return; try { await api(`/api/templates/${encodeURIComponent(template.id)}`, { method: 'DELETE' }); @@ -3920,23 +3938,26 @@ import { applyAction, applyModel, inheritedTextFor, lineKind, linesDroppedByMode } /** Wait for the run that was started on this node to finish. */ - function waitForRun(nodeId, timeoutMs = 2_700_000) { + function waitForRun(nodeId, taskId = '', timeoutMs = 2_700_000) { // The run this wait is about is held on to. The page forgets which run // belongs to a node as soon as that run is over — the node is free to run // again — so a waiter that looked the run up again would never see it // finish, and the chain would stand still until its own timeout. - const taskId = state.nodeTasks.get(nodeId); + const id = theRunToWaitFor({ taskId, nodeId, known: state.nodeTasks }); return waitForTheRun({ - taskId, - readTask: (id) => state.tasks.get(id), + taskId: id, + readTask: (task) => state.tasks.get(task), timeoutMs, }); } /** Run a node and wait for its result. */ async function runAndWait(node) { - await startRun(node, { chain: false }); - const task = await waitForRun(node.id); + const started = await startRun(node, { chain: false }); + // The run that was started is the run that is waited for: the id is + // handed over, so a run that is over before the waiter looks it up is + // still waited for. + const task = await waitForRun(node.id, started?.id ?? ''); return task?.status === 'done'; } @@ -4009,6 +4030,9 @@ import { applyAction, applyModel, inheritedTextFor, lineKind, linesDroppedByMode const task = payload.run; state.tasks.set(task.id, task); state.nodeTasks.set(node.id, task.id); + // A run is starting, so the progress panel is worth showing again: the + // user put it away, but this is what it is for. + state.taskPanelClosed = panelClosed({ closed: state.taskPanelClosed, event: 'run-started' }); if (task.sessionId) node.metadata.sessionId = task.sessionId; ensurePolling(); render(); @@ -4024,6 +4048,14 @@ import { applyAction, applyModel, inheritedTextFor, lineKind, linesDroppedByMode showBanner(String(error?.message ?? error)); markDirty(); render(); + // The runtime names the run that is going, so the chain can wait for + // it even when this page is the one that did not start it. + const running = theRunThatIsGoing(error); + if (running) { + state.nodeTasks.set(node.id, running); + ensurePolling(); + return { id: running, status: 'running', busy: true }; + } return null; } node.metadata.output.status = 'error'; @@ -4991,9 +5023,15 @@ import { applyAction, applyModel, inheritedTextFor, lineKind, linesDroppedByMode const empty = Boolean(state.canvas?.id) && !(state.canvas?.nodes?.length); if (empty) { // The canvas that is open is empty: it becomes the canvas of this - // template, and it takes the name of it. - if (template.title) state.canvas.title = template.title; - applyTemplateOnCanvas(template); + // template. The name is given to it only when the steps are really on + // it, so a template that does not fit leaves no canvas with a name and + // no steps. + const applied = applyTemplateOnCanvas(template); + const title = canvasTitleAfterTemplate(template, applied); + if (!title) return; + state.canvas.title = title; + markDirty(); + render(); return; } await createCanvas(`${template.title || '新画布'}`, { askName: false }); diff --git a/plugins/antianqi/ai-node-canvas/miniapp/client/forms.mjs b/plugins/antianqi/ai-node-canvas/miniapp/client/forms.mjs index 79bac631..219a476f 100644 --- a/plugins/antianqi/ai-node-canvas/miniapp/client/forms.mjs +++ b/plugins/antianqi/ai-node-canvas/miniapp/client/forms.mjs @@ -191,7 +191,9 @@ export function modesOf(node) { * lands on the mode that is left. */ export function currentModeId(modeId) { - return LEGACY_MODES[modeId] ?? modeId; + // The lookup is exact: a name that is not in the table is not a mode of the + // old names, and it stays the name it is. + return Object.hasOwn(LEGACY_MODES, modeId) ? LEGACY_MODES[modeId] : modeId; } /** The mode a node is in. */ diff --git a/plugins/antianqi/ai-node-canvas/miniapp/client/run-watch.mjs b/plugins/antianqi/ai-node-canvas/miniapp/client/run-watch.mjs index 5767b8c3..ea5e8a9d 100644 --- a/plugins/antianqi/ai-node-canvas/miniapp/client/run-watch.mjs +++ b/plugins/antianqi/ai-node-canvas/miniapp/client/run-watch.mjs @@ -52,6 +52,99 @@ export function isBusyRefusal(error) { return String(error?.code ?? '') === 'BUSY'; } +/** + * The run a waiter has to hold on to. + * + * The run that was started is handed over, so a waiter does not have to look + * it up: the page forgets which run belongs to a node as soon as that run is + * over, and a waiter that looked the map up again would find nothing and + * stand still until its own timeout. + * + * A run that is handed over wins over the one the page happens to know: the + * handed-over one is the run this wait is about. + */ +export function theRunToWaitFor({ taskId = '', nodeId = '', known = null } = {}) { + const handed = String(taskId ?? '').trim(); + if (handed) return handed; + return String(known?.get?.(nodeId) ?? '').trim(); +} + +/** + * The run that is going, when the runtime refuses a second run. + * + * The refusal names the run that is on the node, so the chain can wait for + * that run even when this page is not the one that started it. + */ +export function theRunThatIsGoing(error) { + return String(error?.runId ?? '').trim(); +} + +/** + * How long a run that is over stays in the progress panel. + * + * A finished run is kept a moment, so the last lines of its log can still be + * read — and then it goes. A failed run is a finished run: it says what went + * wrong, the card says it too, and it does not stay on the screen for ever. + */ +export const FINISHED_KEEP_MS = 30_000; + +/** + * Whether a run has left the progress panel. + * + * This is the one rule for how long a finished run stays: the panel is about + * what is going on, so a run that is over is kept a moment — long enough for + * its last lines to be read — and then it goes. A run that is still going is + * never let go, and a run that was cancelled has nothing left to show. + */ +export function taskIsExpired(task, { now = Date.now() } = {}) { + const status = String(task?.status ?? ''); + if (status === 'running' || status === 'waiting-question') return false; + if (status === 'done' || status === 'error') { + return now - Number(task?.finishedAt ?? 0) >= FINISHED_KEEP_MS; + } + return true; +} + +/** + * The runs the progress panel shows. + * + * The panel is about what is happening now: the runs that are going, and the + * ones that are over for a moment so their last lines can be read. The list + * is never long — a burst of failures must not fill the screen — and the + * newest runs are the ones that are kept. + */ +export function panelTasks(tasks, { now = Date.now(), max = 8 } = {}) { + const list = [...(tasks ?? [])].filter((task) => !taskIsExpired(task, { now })); + return list.slice(-max); +} + +/** + * Whether the progress panel is shown. + * + * The panel is about what is going on: it shows itself when there is + * something to show, and it stays away when the user put it away — the panel + * sits over the canvas, so it has to be the user's choice. + */ +export function panelIsShown({ closed = false, tasks = [] } = {}) { + if (closed) return false; + return (tasks ?? []).length > 0; +} + +/** + * Whether the panel is put away, after what happened. + * + * The user can put the panel away, and it stays away until a run starts: a + * new run brings it back, because that is what the panel is for. + * + * The state of the panel is kept in one place, so the page and the rules + * cannot disagree about it. + */ +export function panelClosed({ closed = false, event = 'none' } = {}) { + if (event === 'run-started') return false; + if (event === 'put-away') return true; + return Boolean(closed); +} + /** * Wait for one run to be over. * diff --git a/plugins/antianqi/ai-node-canvas/miniapp/client/styles.css b/plugins/antianqi/ai-node-canvas/miniapp/client/styles.css index e31e946b..9f06d618 100644 --- a/plugins/antianqi/ai-node-canvas/miniapp/client/styles.css +++ b/plugins/antianqi/ai-node-canvas/miniapp/client/styles.css @@ -2024,6 +2024,10 @@ input { } .nxc-taskpanel__title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; font-size: 10.5px; color: var(--text-faint); text-transform: uppercase; @@ -2031,6 +2035,23 @@ input { margin-bottom: 6px; } +/* The panel sits over the canvas, so it can be put away. */ +.nxc-taskpanel__close { + border: 0; + background: none; + padding: 0; + font: inherit; + font-size: 10.5px; + letter-spacing: 0; + text-transform: none; + color: var(--text-faint); + cursor: pointer; +} + +.nxc-taskpanel__close:hover { + color: var(--text); +} + .nxc-taskpanel__item { border-top: 1px solid var(--line-soft); padding: 5px 0; diff --git a/plugins/antianqi/ai-node-canvas/miniapp/client/task-panel.mjs b/plugins/antianqi/ai-node-canvas/miniapp/client/task-panel.mjs new file mode 100644 index 00000000..45379da0 --- /dev/null +++ b/plugins/antianqi/ai-node-canvas/miniapp/client/task-panel.mjs @@ -0,0 +1,37 @@ +// The parts of the progress panel that are the panel's own. +// +// The panel sits over the canvas, so the user has to be able to put it away: +// the title row carries a button that says so. The row is built here — with +// the document handed to it — so what the button asks for is one thing in one +// place, and it can be tested without a browser. + +/** + * The title row of the progress panel. + * + * The row holds the name of the panel and the button that puts it away. The + * button only asks: what happens to the panel is the page's business. + * + * @param {Document} doc the document the row is built in + * @param {{label?: string, closeLabel?: string, onPutAway?: () => void}} options + * @returns {HTMLElement} the title row + */ +export function panelTitleRow(doc, { label = '运行进度', closeLabel = '收起', onPutAway = null } = {}) { + const row = doc.createElement('div'); + row.className = 'nxc-taskpanel__title'; + + const caption = doc.createElement('span'); + caption.textContent = label; + row.appendChild(caption); + + const close = doc.createElement('button'); + close.type = 'button'; + close.className = 'nxc-taskpanel__close'; + close.textContent = closeLabel; + close.addEventListener('click', (event) => { + event?.stopPropagation?.(); + onPutAway?.(); + }); + row.appendChild(close); + + return row; +} diff --git a/plugins/antianqi/ai-node-canvas/miniapp/node/agent-runner.mjs b/plugins/antianqi/ai-node-canvas/miniapp/node/agent-runner.mjs index 30a67395..5ba4dc7d 100644 --- a/plugins/antianqi/ai-node-canvas/miniapp/node/agent-runner.mjs +++ b/plugins/antianqi/ai-node-canvas/miniapp/node/agent-runner.mjs @@ -29,7 +29,7 @@ import { createProviderUploader } from './provider-upload.mjs'; import { createMediaStore } from './media-store.mjs'; import { createFfmpeg } from './ffmpeg.mjs'; import { buildMentionMap, compilePrompt, providerFamily, referencePattern, referenceSlots, tokenize } from './prompt.mjs'; -import { alignmentLine, COLLECTION_PATHS, frameRequestShape, lastShotIndex } from './video-request.mjs'; +import { alignmentLine, COLLECTION_PATHS, frameRequestShape, lastShotIndex, referenceSetError } from './video-request.mjs'; import { compressionArgs, isUnderstandTimeout, understandingPlan } from './understand-media.mjs'; import { materialToRead } from '../client/forms.mjs'; import { createConnectorRunner, queueWaitText } from './connector-runner.mjs'; @@ -130,19 +130,16 @@ function assertWithinLimits(capabilities, references, path = '') { { code: 'INVALID_ARGUMENTS' }, ); } - // The provider's own rules for a reference set: - // - an audio reference never comes alone: it needs a picture or a video - // - the collections hold twelve items in all - // The page cannot see either of them, so the run is refused here instead of - // being sent and coming back with a provider error. + // The provider's own rules for a reference set are judged on the request + // that is about to be sent, in the connector: the set is never empty, and + // an audio never comes alone. The page cannot see either of them, so the + // run is refused there instead of being sent and coming back with a + // provider error. + // + // What is left here is what the node holds: how many pictures, videos and + // audios a node may carry at all. if (COLLECTION_PATHS.has(path)) { const total = counts.image + counts.video + counts.audio; - if (total === 0) { - throw Object.assign( - new Error('参考生视频至少要一样参考素材:一张图、一条视频或一段音频。'), - { code: 'INVALID_ARGUMENTS' }, - ); - } const limit = capabilities.maxReferenceItems ?? 0; if (limit && total > limit) { throw Object.assign( @@ -151,12 +148,6 @@ function assertWithinLimits(capabilities, references, path = '') { ); } } - if (COLLECTION_PATHS.has(path) && counts.audio > 0 && counts.image + counts.video === 0) { - throw Object.assign( - new Error('音频参考要配一张图或一条视频:provider 只收成组的参考,音频不能单独来。'), - { code: 'INVALID_ARGUMENTS' }, - ); - } return counts; } @@ -1210,7 +1201,12 @@ export function createAgentRunner(context) { const runningId = nodeTasks.get(nodeId); const running = runningId ? tasks.get(runningId) : null; if (running && (running.status === 'running' || running.status === 'waiting-question')) { - throw Object.assign(new Error('这个节点正在生成中,请等它跑完'), { code: 'BUSY' }); + // The run that is going is named, so the one that asked can wait for + // it: a chain that meets a busy node waits for that run. + throw Object.assign( + new Error('这个节点正在生成中,请等它跑完'), + { code: 'BUSY', runId: running.id }, + ); } const modelId = modelIdFromKey(input.modelKey); @@ -1251,9 +1247,6 @@ export function createAgentRunner(context) { duration, }; - const task = createTask({ ...input, mode, nodeId }); - task.count = count; - task.log.push(...setupLog); const { agentPrompt, compiled, providerPrompt, assets } = buildPrompt({ ...input, // The references that were made readable for the provider: a local @@ -1269,6 +1262,26 @@ export function createAgentRunner(context) { materialContent: material.content, materialKind: material.kind, }); + // The provider's own rules for a reference set, judged on the material + // this run really sends: the rule follows the material, so it cannot + // be stepped around by naming the request something else. + const problem = referenceSetError( + { + images: (assets.images ?? []).filter(Boolean).length, + videos: (assets.videos ?? []).filter(Boolean).length, + audios: (assets.audios ?? []).filter(Boolean).length, + }, + { collection: COLLECTION_PATHS.has(input.path) }, + ); + if (problem) { + throw Object.assign(new Error(problem), { code: 'INVALID_ARGUMENTS' }); + } + // The task is made only when the run is really going to happen: a run + // that is refused leaves nothing behind, so the node is free again + // instead of staying busy for a run that never started. + const task = createTask({ ...input, mode, nodeId }); + task.count = count; + task.log.push(...setupLog); task.compiledPrompt = compiled.promptForApi; task.assets = assets; task.log.push(`开始生成(${mode})`); diff --git a/plugins/antianqi/ai-node-canvas/miniapp/node/media-store.mjs b/plugins/antianqi/ai-node-canvas/miniapp/node/media-store.mjs index 204fa978..ed5ea32e 100644 --- a/plugins/antianqi/ai-node-canvas/miniapp/node/media-store.mjs +++ b/plugins/antianqi/ai-node-canvas/miniapp/node/media-store.mjs @@ -10,11 +10,12 @@ import { randomUUID } from 'node:crypto'; import { lookup } from 'node:dns/promises'; import { createWriteStream, mkdirSync } from 'node:fs'; -import { copyFile, mkdir, readdir, readFile, rename, rm, stat, utimes, writeFile } from 'node:fs/promises'; -import { isIP } from 'node:net'; +import { copyFile, lstat, mkdir, readdir, readFile, rename, rm, stat, utimes, writeFile } from 'node:fs/promises'; +import { request as httpRequest } from 'node:http'; +import { request as httpsRequest } from 'node:https'; +import { BlockList, isIP } from 'node:net'; import { homedir } from 'node:os'; import { isAbsolute, join, relative, resolve } from 'node:path'; -import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { decodeHostConnectorValue } from './host-connector-value.mjs'; @@ -124,32 +125,161 @@ export function resolveInside(folder, name) { return target; } +/** + * The ranges a download must not reach. + * + * The loopback, the private ranges, the link-local range, the range that is + * reserved and the shared address space of a carrier are this machine or the + * network it sits in. A provider link never points there, so a request to one + * is refused. + * + * The ranges are written down once, as ranges and not as single addresses, so + * every address inside one of them is refused by the same rule. + */ +const PRIVATE_RANGES = new BlockList(); + +// IPv4: this network, the private ranges, the loopback, the link-local range, +// the shared space of a carrier, the benchmark range, and everything from +// multicast up (which also covers the reserved range). +PRIVATE_RANGES.addSubnet('0.0.0.0', 8, 'ipv4'); +PRIVATE_RANGES.addSubnet('10.0.0.0', 8, 'ipv4'); +PRIVATE_RANGES.addSubnet('100.64.0.0', 10, 'ipv4'); +PRIVATE_RANGES.addSubnet('127.0.0.0', 8, 'ipv4'); +PRIVATE_RANGES.addSubnet('169.254.0.0', 16, 'ipv4'); +PRIVATE_RANGES.addSubnet('172.16.0.0', 12, 'ipv4'); +PRIVATE_RANGES.addSubnet('192.0.0.0', 24, 'ipv4'); +PRIVATE_RANGES.addSubnet('192.168.0.0', 16, 'ipv4'); +PRIVATE_RANGES.addSubnet('198.18.0.0', 15, 'ipv4'); +PRIVATE_RANGES.addSubnet('224.0.0.0', 4, 'ipv4'); +PRIVATE_RANGES.addSubnet('240.0.0.0', 4, 'ipv4'); + +// IPv6: the unspecified address, the loopback, the discard range, the +// documentation range, the unique local range, the link-local range and the +// multicast range. +PRIVATE_RANGES.addSubnet('::', 128, 'ipv6'); +PRIVATE_RANGES.addSubnet('::1', 128, 'ipv6'); +PRIVATE_RANGES.addSubnet('100::', 64, 'ipv6'); +PRIVATE_RANGES.addSubnet('2001:db8::', 32, 'ipv6'); +PRIVATE_RANGES.addSubnet('fc00::', 7, 'ipv6'); +PRIVATE_RANGES.addSubnet('fe80::', 10, 'ipv6'); +PRIVATE_RANGES.addSubnet('ff00::', 8, 'ipv6'); + +/** + * An address in the one form it is written in. + * + * The URL parser rewrites an address into its own form, so the same address + * is always judged the same way: `::ffff:127.0.0.1` and `::ffff:7f00:1` are + * one address, not two. A zone id says which interface an address is on, not + * which address it is, so it is dropped. + */ +function normalizedAddress(address) { + let ip = String(address ?? '').trim().toLowerCase(); + if (!ip) return ''; + ip = ip.replace(/^\[|\]$/g, ''); + ip = ip.split('%')[0]; + if (!ip) return ''; + if (isIP(ip) === 4) return ip; + if (isIP(ip) !== 6) return ip; + try { + return new URL(`http://[${ip}]/`).hostname.replace(/^\[|\]$/g, ''); + } catch { + return ip; + } +} + +/** + * The eight numbers an IPv6 address is made of. + * + * The written form may leave out the zeroes in the middle, so they are put + * back: the address is read as the eight numbers it stands for. + */ +function expandIPv6(text) { + const parts = String(text ?? '').split('::'); + if (parts.length > 2) return null; + const head = parts[0] ? parts[0].split(':') : []; + const tail = parts.length === 2 && parts[1] ? parts[1].split(':') : []; + const missing = 8 - head.length - tail.length; + if (parts.length === 1 ? missing !== 0 : missing < 1) return null; + const groups = [ + ...head.map((part) => Number.parseInt(part, 16)), + ...(parts.length === 2 ? Array(missing).fill(0) : []), + ...tail.map((part) => Number.parseInt(part, 16)), + ]; + if (groups.length !== 8) return null; + if (groups.some((group) => !Number.isInteger(group) || group < 0 || group > 0xffff)) return null; + return groups; +} + +/** + * The IPv4 address an IPv6 address carries, if it carries one. + * + * Four forms carry an IPv4 address inside an IPv6 one: the mapped form + * (`::ffff:7f00:1`), the compatible form (`::7f00:1`), the NAT64 form + * (`64:ff9b::7f00:1`) and the 6to4 form (`2002:7f00:1::`). Each of them + * points at an IPv4 address, so each of them is judged as that address. + */ +export function embeddedIPv4(address) { + const ip = normalizedAddress(address); + if (isIP(ip) !== 6) return null; + const groups = expandIPv6(ip); + if (!groups) return null; + const zero = (from, to) => groups.slice(from, to).every((group) => group === 0); + const mapped = zero(0, 5) && groups[5] === 0xffff; + const compatible = zero(0, 6); + const nat64 = groups[0] === 0x64 && groups[1] === 0xff9b && zero(2, 6); + const sixToFour = groups[0] === 0x2002; + let hi = 0; + let lo = 0; + if (mapped || compatible || nat64) { + hi = groups[6]; + lo = groups[7]; + } else if (sixToFour) { + hi = groups[1]; + lo = groups[2]; + } else { + return null; + } + return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`; +} + /** * Whether an address is one a download must not reach. * - * The loopback, the private ranges, the link-local range and the reserved - * ranges are this machine or the network it sits in. A provider link never - * points there, so a request to one is refused. + * The ranges above are the rule. An IPv6 address that carries an IPv4 + * address is judged as that IPv4 address, so the forms that hide one cannot + * be used to get past the rule. */ export function isPrivateAddress(address) { - const ip = String(address ?? '').trim().toLowerCase(); + const ip = normalizedAddress(address); if (!ip) return true; - if (ip === '::' || ip === '::1' || ip === '0:0:0:0:0:0:0:1') return true; - // An IPv4 address written inside an IPv6 one (::ffff:127.0.0.1). - if (ip.startsWith('::ffff:')) return isPrivateAddress(ip.slice(7)); - if (/^fe[89ab]/.test(ip)) return true; - if (/^f[cd]/.test(ip)) return true; - const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip); - if (!v4) return false; - const first = Number(v4[1]); - const second = Number(v4[2]); - if (first === 0 || first === 127) return true; - if (first === 10) return true; - if (first === 172 && second >= 16 && second <= 31) return true; - if (first === 192 && second === 168) return true; - if (first === 169 && second === 254) return true; - if (first >= 224) return true; - return false; + const family = isIP(ip); + if (family === 4) return PRIVATE_RANGES.check(ip, 'ipv4'); + // An address that is neither IPv4 nor IPv6 cannot be reached at all, so it + // is refused rather than passed on. + if (family !== 6) return true; + const carried = embeddedIPv4(ip); + if (carried) return PRIVATE_RANGES.check(carried, 'ipv4'); + return PRIVATE_RANGES.check(ip, 'ipv6'); +} + +/** + * The address a download connects to. + * + * Every address a name points at is judged, so a name that points at one + * private address is refused even when it points at a public one as well. + * The answer is the address the download uses: the address that was judged + * is the address that is connected to. + */ +export function pinnedAddress(addresses) { + const list = (Array.isArray(addresses) ? addresses : []) + .map((entry) => ({ + address: String(entry?.address ?? entry ?? '').trim(), + family: Number(entry?.family ?? isIP(String(entry?.address ?? entry ?? ''))) || 4, + })) + .filter((entry) => entry.address); + if (!list.length) return null; + if (list.some((entry) => isPrivateAddress(entry.address))) return null; + return list[0]; } /** @@ -184,13 +314,21 @@ export function isPublicUrl(url) { */ export function isAllowedHop(url, addresses = []) { if (!isPublicUrl(url)) return false; - return !addresses.some((entry) => isPrivateAddress(entry?.address ?? entry)); + // The same judgment the download makes: every address is judged, and the + // one that is used is the one that was judged. + return Boolean(pinnedAddress(addresses)); } -/** Whether a path is taken. */ +/** + * Whether a path is taken. + * + * The link itself is looked at, not what it points at: a link that points at + * nothing is still a name that is taken, so a file is never written through + * it. + */ async function exists(path) { try { - await stat(path); + await lstat(path); return true; } catch { return false; @@ -252,59 +390,99 @@ export function createMediaStore(context) { } /** - * The address a download may go to. + * The address a download connects to. * - * A link that spells out a private address is refused straight away, and - * a name is looked up first: if any of the addresses it points at is - * this machine or the network it sits in, the download is refused too. + * The name is looked up once, and the answer is what the download uses: the + * address that was judged is the address that is connected to, so a name + * that answers one thing for the check and another for the connection cannot + * get through. */ - async function assertPublicUrl(url) { + async function resolvePublic(url) { const parsed = new URL(String(url)); - const host = parsed.hostname.replace(/^\[|\]$/g, ''); - if (isIP(host)) { - if (!isAllowedHop(url, [])) throw new Error('素材链接指向本机或内网,已拒绝'); - return parsed.toString(); + if (!isPublicUrl(parsed.toString())) { + throw new Error('素材链接指向本机或内网,已拒绝'); } + const host = parsed.hostname.replace(/^\[|\]$/g, ''); + // An address that is written out is the address that is used. + if (isIP(host)) return { address: host, family: isIP(host) }; let addresses = []; try { addresses = await lookup(host, { all: true }); } catch { throw new Error('素材链接的主机名解析失败'); } - if (!addresses.length) throw new Error('素材链接的主机名解析失败'); - if (!isAllowedHop(url, addresses)) throw new Error('素材链接指向本机或内网,已拒绝'); - return parsed.toString(); + const pinned = pinnedAddress(addresses); + if (!pinned) { + // No address at all, or one of them is this machine or the network it + // sits in. + if (!addresses.length) throw new Error('素材链接的主机名解析失败'); + throw new Error('素材链接指向本机或内网,已拒绝'); + } + return pinned; + } + + /** + * Open a connection to the address that was judged. + * + * The name is not looked up again: the connection goes to the address that + * was checked, so a name cannot answer differently the second time. + */ + function openPinned(target, pinned, signal) { + const send = target.protocol === 'https:' ? httpsRequest : httpRequest; + return new Promise((resolve, reject) => { + const request = send( + target, + { + lookup: (hostname, options, callback) => { + if (options?.all) callback(null, [{ address: pinned.address, family: pinned.family }]); + else callback(null, pinned.address, pinned.family); + }, + headers: { + // The bytes are wanted as they are: a compressed answer would be + // written down compressed. + 'accept-encoding': 'identity', + 'user-agent': 'ai-node-canvas', + }, + }, + (response) => resolve(response), + ); + request.on('error', reject); + if (signal) { + const stop = () => request.destroy(new Error('素材下载超时')); + if (signal.aborted) stop(); + else signal.addEventListener('abort', stop, { once: true }); + } + request.end(); + }); } async function downloadTo(url, path, hops = 0) { - const target = await assertPublicUrl(url); + const target = new URL(String(url)); + const pinned = await resolvePublic(target.toString()); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS); try { - const response = await fetch(target, { signal: controller.signal, redirect: 'manual' }); + const response = await openPinned(target, pinned, controller.signal); // The redirect is followed by hand, so every hop is judged too: a // public address that redirects into the loopback is still refused. - if (response.status >= 300 && response.status < 400) { - const location = response.headers.get('location'); - await response.body?.cancel().catch(() => {}); - if (!location) throw new Error(`素材下载失败(HTTP ${response.status})`); + if (response.statusCode >= 300 && response.statusCode < 400) { + const location = response.headers.location; + response.resume(); + if (!location) throw new Error(`素材下载失败(HTTP ${response.statusCode})`); if (hops >= 5) throw new Error('素材下载失败(重定向过多)'); return downloadTo(new URL(location, target).toString(), path, hops + 1); } - if (!response.ok) { - throw new Error(`素材下载失败(HTTP ${response.status})`); + if (response.statusCode < 200 || response.statusCode >= 300) { + response.resume(); + throw new Error(`素材下载失败(HTTP ${response.statusCode})`); } - const type = response.headers.get('content-type') ?? ''; - const bytes = Number(response.headers.get('content-length') ?? 0); + const type = response.headers['content-type'] ?? ''; + const bytes = Number(response.headers['content-length'] ?? 0); if (bytes > MAX_MEDIA_BYTES) { + response.resume(); throw new Error('素材超过本机处理上限'); } - if (!response.body) { - const buffer = Buffer.from(await response.arrayBuffer()); - await writeFile(path, buffer); - return { path, bytes: buffer.length }; - } - await pipeline(Readable.fromWeb(response.body), createWriteStream(path)); + await pipeline(response, createWriteStream(path)); const info = await stat(path); return { path, bytes: info.size, mimeType: type }; } finally { @@ -762,17 +940,27 @@ export function createMediaStore(context) { const plain = plainName(asked); if (!plain) return { ok: false, reason: 'bad_name' }; await mkdir(folder, { recursive: true }); + const body = Buffer.isBuffer(data) ? data : Buffer.from(String(data ?? ''), 'utf8'); let finalPath = join(folder, plain); let index = 1; - // A file that is already there is kept: the new one gets a number. - while (await exists(finalPath)) { + // The name has to be free: not a file, and not a link that points at + // nothing either — a link is a name that is taken, so nothing is ever + // written through one. + for (;;) { + if (!(await exists(finalPath))) { + try { + await writeFile(finalPath, body, { flag: 'wx' }); + break; + } catch (error) { + if (error?.code !== 'EEXIST') throw error; + } + } + // A name that is taken gets a number: the file that is there is kept. const ext = extensionOf(finalPath, ''); const stem = finalPath.slice(0, finalPath.length - ext.length); finalPath = `${stem} (${index})${ext}`; index += 1; } - const body = Buffer.isBuffer(data) ? data : Buffer.from(String(data ?? ''), 'utf8'); - await writeFile(finalPath, body); return { ok: true, path: finalPath, bytes: body.length, name: finalPath.split(/[\\/]/).pop() }; } diff --git a/plugins/antianqi/ai-node-canvas/miniapp/node/server.mjs b/plugins/antianqi/ai-node-canvas/miniapp/node/server.mjs index ae594218..aa5938be 100644 --- a/plugins/antianqi/ai-node-canvas/miniapp/node/server.mjs +++ b/plugins/antianqi/ai-node-canvas/miniapp/node/server.mjs @@ -11,7 +11,7 @@ import { createServer } from 'node:http'; import { basename, dirname, isAbsolute, join, sep } from 'node:path'; import { homedir } from 'node:os'; -import { templateFromCanvas } from '../client/actions.mjs'; +import { readTemplateFile, templateFromCanvas, templateStepsError } from '../client/actions.mjs'; import { createActionQueue } from './action-queue.mjs'; import { createAgentRunner } from './agent-runner.mjs'; import { palette } from './capabilities.mjs'; @@ -19,7 +19,7 @@ import { decodeHostConnectorValue } from './host-connector-value.mjs'; import { handleMcp, MCP_PATH } from './mcp.mjs'; import { createMediaRunner } from './media-runner.mjs'; import { createCanvasStore } from './store.mjs'; -import { readTemplates, removeTemplate, saveTemplate } from './templates.mjs'; +import { readTemplates, removeTemplate, saveTemplate, templateIdOf } from './templates.mjs'; // Keep in sync with `surface.path` in miniapp/miniapp.json. const SURFACE_PATH = '/canvas'; @@ -37,6 +37,7 @@ export const CLIENT_FILES = new Map([ ['/forms.mjs', { file: 'forms.mjs', type: 'text/javascript; charset=utf-8' }], ['/actions.mjs', { file: 'actions.mjs', type: 'text/javascript; charset=utf-8' }], ['/run-watch.mjs', { file: 'run-watch.mjs', type: 'text/javascript; charset=utf-8' }], + ['/task-panel.mjs', { file: 'task-panel.mjs', type: 'text/javascript; charset=utf-8' }], ['/styles.css', { file: 'styles.css', type: 'text/css; charset=utf-8' }], ]); @@ -81,6 +82,25 @@ async function folderOf(asked) { return { folder }; } +/** + * The text of a template file. + * + * What is written is a template: an object is written as one, and a text is + * written as it is when it is the text of a file that is one. Anything else + * is refused, so the endpoint writes templates and nothing else. + */ +function templateDocumentText(document) { + if (typeof document === 'string') { + const read = readTemplateFile(document); + if (!read.ok) return { ok: false, reason: 'bad_document', message: read.error }; + return { ok: true, text: document }; + } + if (!document || typeof document !== 'object' || Array.isArray(document)) { + return { ok: false, reason: 'bad_document', message: '这个文件不是一份模板' }; + } + return { ok: true, text: `${JSON.stringify(document, null, 2)}\n` }; +} + /** * Send a file, as an attachment when the caller asks for one. * @@ -312,13 +332,6 @@ export async function start(context) { }); } - // The template files that lie on this machine. - // - // The file the page shares is written into the Downloads folder, so the - // file somebody sends to this machine lands there too. Each one is read - // by the one rule that reads a template, so a file that is something else - // is not offered at all. - // The folders a file may be put in. // // The page asks where a file should go, and the answer is a path on this @@ -349,9 +362,6 @@ export async function start(context) { if (!asked) { return sendJson(res, 400, { error: 'bad_name', message: '这个文件还没有名字' }); } - if (body.document === undefined || body.document === null) { - return sendJson(res, 400, { error: 'empty', message: '这个文件是空的' }); - } // The path is the one the user named: where the file goes and what it is // called. The folder has to be a folder that is there, and the name has // to be one plain name — a name that walks out of the folder is refused, @@ -361,14 +371,20 @@ export async function start(context) { if (!name || name === '.' || name === '..') { return sendJson(res, 400, { error: 'bad_name', message: '这个文件名用不了' }); } + // What is written is a template file, so the name says so and what goes + // in is a template: an object, or the text of a file that is one. + if (!name.toLowerCase().endsWith('.json')) { + return sendJson(res, 400, { error: 'bad_name', message: '这个文件要叫 .json 才好用' }); + } + const text = templateDocumentText(body.document); + if (!text.ok) { + return sendJson(res, 400, { error: text.reason, message: text.message }); + } const where = await folderOf(folder); if (where.error) { return sendJson(res, 400, { error: 'bad_folder', message: where.error }); } - const text = typeof body.document === 'string' - ? body.document - : `${JSON.stringify(body.document, null, 2)}\n`; - const saved = await media.store.writeToFolder(name, text, where.folder); + const saved = await media.store.writeToFolder(name, text.text, where.folder); if (!saved.ok) { return sendJson(res, 400, { error: saved.reason, message: '这个文件名用不了' }); } @@ -396,9 +412,14 @@ export async function start(context) { // so the same place keeps both kinds of template by the same rule. if (path === '/api/templates' && req.method === 'POST') { const body = await readJsonBody(req); - const id = String(body.id ?? '').replace(/[^A-Za-z0-9._-]/g, '').slice(0, 64); + // The name is judged by the one rule that also judges taking a template + // away, so anything that can be kept can also be taken away. + const id = templateIdOf(body.id); if (!id) { - return sendJson(res, 400, { error: 'bad_id', message: '这个模板还没有名字' }); + return sendJson(res, 400, { + error: 'bad_id', + message: '这个模板的名字不能用(只能用字母、数字、点、下划线和短横线)', + }); } let template = null; if (Array.isArray(body.actions)) { @@ -408,23 +429,37 @@ export async function start(context) { description: String(body.description ?? ''), actions: body.actions, }; + // The steps are judged by the one rule that judges them everywhere, so + // a template that is kept cannot carry a step that would be refused + // the moment it is put on a canvas. + const problem = templateStepsError(template.actions); + if (problem) { + return sendJson(res, 400, { error: 'bad_actions', message: problem }); + } } else { const canvas = body.canvas ?? (body.canvasId ? await store.get(body.canvasId) : null); if (!canvas) { return sendJson(res, 404, { error: 'not_found', message: '没有这张画布' }); } template = templateFromCanvas(canvas, { id, title: String(body.title ?? id) }); + if (!template.actions.length) { + return sendJson(res, 400, { + error: 'empty', + message: '这张画布上没有能存成模板的步骤(结果和素材不算步骤)', + }); + } } - if (!template.actions.length) { - return sendJson(res, 400, { - error: 'empty', - message: '这张画布上没有能存成模板的步骤(结果和素材不算步骤)', + const saved = await saveTemplate(context.dataDir, template); + if (!saved.ok) { + const taken = saved.reason === 'taken'; + return sendJson(res, taken ? 409 : 400, { + error: saved.reason, + message: taken ? '这个名字已经有模板了,换一个' : '这个模板的名字不能用', }); } - await saveTemplate(context.dataDir, template); return sendJson(res, 200, { ok: true, - template: { id, title: template.title, steps: template.actions.length }, + template: { id: saved.id, title: template.title, steps: template.actions.length }, }); } @@ -792,10 +827,14 @@ export async function start(context) { : code === 'REQUEST_NOT_FOUND' ? 404 : code === 'FFMPEG_UNAVAILABLE' ? 503 : 500; - sendJson(res, status, { + const payload = { error: code, message: String(error?.message ?? error), - }); + }; + // A refused run says which run is going: the one that asked waits for + // that run instead of standing still. + if (error?.runId) payload.runId = error.runId; + sendJson(res, status, payload); }); }); diff --git a/plugins/antianqi/ai-node-canvas/miniapp/node/templates.mjs b/plugins/antianqi/ai-node-canvas/miniapp/node/templates.mjs index ee80fcc5..c7137872 100644 --- a/plugins/antianqi/ai-node-canvas/miniapp/node/templates.mjs +++ b/plugins/antianqi/ai-node-canvas/miniapp/node/templates.mjs @@ -21,7 +21,7 @@ // and keeping it — so the shipped ones are written the same way: a file says // the whole of it (the title, what it is for, the group it belongs to and // the steps), and nobody has to touch the code to change one. -import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { lstat, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { readdirSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -69,21 +69,53 @@ export function templatesDir(dataDir) { return join(dataDir, 'templates'); } +/** + * The name of a template of the user's own. + * + * One rule, shared by keeping a template and taking one away: a name is a + * plain name — letters, digits, dot, underscore and dash — and it is never a + * dot segment, so a name can only ever be a file inside the template folder. + */ +export function templateIdOf(value) { + const name = String(value ?? '').trim(); + if (!/^[A-Za-z0-9._-]{1,64}$/.test(name)) return null; + // A name that is only dots is a dot segment in another dress. + if (/^\.+$/.test(name)) return null; + return name; +} + /** * Keep a template of the user's own. * * It lives under the runtime's own data directory, not in the package: a * template is the user's own, and it survives a new version of the MiniApp. + * + * A template that is already there is not written over: the file is made + * once, so the one that is there is kept and the new one is refused. */ export async function saveTemplate(dataDir, template) { + const id = templateIdOf(template?.id); + if (!id) return { ok: false, reason: 'bad_id' }; const dir = templatesDir(dataDir); await mkdir(dir, { recursive: true }); - const file = join(dir, `${template.id}.json`); - await writeFile(file, `${JSON.stringify(template, null, 2)}\n`, 'utf8'); - return file; + const file = join(dir, `${id}.json`); + const body = `${JSON.stringify({ ...template, id }, null, 2)}\n`; + try { + // The file is written only into a name that is free. + await writeFile(file, body, { encoding: 'utf8', flag: 'wx' }); + } catch (error) { + if (error?.code === 'EEXIST') return { ok: false, reason: 'taken' }; + throw error; + } + return { ok: true, path: file, id }; } -/** The built-in templates and the user's own, one list. */ +/** + * The built-in templates and the user's own, one list. + * + * A template of the user's own is listed by the file it is in: that name is + * the name it is taken away by, and the two can never be different. + */ export async function readTemplates(dataDir) { const own = []; try { @@ -91,8 +123,8 @@ export async function readTemplates(dataDir) { if (!entry.endsWith('.json')) continue; try { const parsed = JSON.parse(await readFile(join(templatesDir(dataDir), entry), 'utf8')); - if (!parsed?.id || !Array.isArray(parsed.actions)) continue; - own.push({ ...parsed, own: true }); + if (!Array.isArray(parsed?.actions)) continue; + own.push({ ...parsed, id: entry.slice(0, -'.json'.length), own: true }); } catch { // A template that cannot be read is one that is not there. } @@ -106,16 +138,19 @@ export async function readTemplates(dataDir) { /** * Take a template of the user's own away. * - * The id is the name of the file under the template folder, so it is judged as - * a plain name: a request cannot walk out of the folder it belongs to, and it - * cannot take a built-in template away either — those are not in this folder. + * The name is the name of the file under the template folder, judged by the + * one rule that also judges keeping a template: so a request cannot walk out + * of the folder it belongs to, and it cannot take a built-in template away + * either — those are not in this folder. */ export async function removeTemplate(dataDir, id) { - const name = String(id ?? '').trim(); - if (!/^[A-Za-z0-9._-]{1,64}$/.test(name) || name === '.' || name === '..') return false; + const name = templateIdOf(id); + if (!name) return false; const file = join(templatesDir(dataDir), `${name}.json`); try { - await stat(file); + // The link itself is looked at: a link that points at nothing is still a + // file that is there, and it is taken away as one. + await lstat(file); } catch { return false; } diff --git a/plugins/antianqi/ai-node-canvas/miniapp/node/video-request.mjs b/plugins/antianqi/ai-node-canvas/miniapp/node/video-request.mjs index 455c240b..61931b18 100644 --- a/plugins/antianqi/ai-node-canvas/miniapp/node/video-request.mjs +++ b/plugins/antianqi/ai-node-canvas/miniapp/node/video-request.mjs @@ -25,6 +25,34 @@ export const FRAME_PATHS = new Set(['keyframe', 'firstLast', 'lastFrame']); */ export const COLLECTION_PATHS = new Set(['collection', 'collectionAudio']); +/** + * Whether a reference set is one the provider takes. + * + * The provider takes ONE set: pictures, videos and audios may sit in it + * together. The collection — the one request that is made of references — is + * never empty, and an audio reference never comes alone: it needs a picture + * or a video beside it. + * + * The rule is judged on the material the request really carries, not on what + * the request is called, so it cannot be stepped around by naming the + * request something else. + * + * The answer is empty when the set is one the provider takes, and says what + * is wrong when it is not. + */ +export function referenceSetError(counts = {}, { collection = false } = {}) { + const pictures = Number(counts.images) || 0; + const clips = Number(counts.videos) || 0; + const sounds = Number(counts.audios) || 0; + if (collection && pictures + clips + sounds === 0) { + return '参考生视频至少要一样参考素材:一张图、一条视频或一段音频。'; + } + if (sounds > 0 && pictures + clips === 0) { + return '音频参考要配一张图或一条视频:provider 只收成组的参考,音频不能单独来。'; + } + return ''; +} + /** * The picture on a port. * diff --git a/plugins/antianqi/ai-node-canvas/package.json b/plugins/antianqi/ai-node-canvas/package.json index 8831a949..6ee2e390 100644 --- a/plugins/antianqi/ai-node-canvas/package.json +++ b/plugins/antianqi/ai-node-canvas/package.json @@ -1,6 +1,6 @@ { "name": "ai-node-canvas", - "version": "0.82.0", + "version": "0.83.0", "private": true, "description": "Node-graph canvas MiniApp for MiniMax Code: wire MiniMax model runs into a visual workflow.", "mcode": { diff --git a/plugins/antianqi/ai-node-canvas/servers.mcp.json b/plugins/antianqi/ai-node-canvas/servers.mcp.json index 292bef83..2185cd8f 100644 --- a/plugins/antianqi/ai-node-canvas/servers.mcp.json +++ b/plugins/antianqi/ai-node-canvas/servers.mcp.json @@ -5,7 +5,7 @@ "type": "streamable-http", "url": "http://127.0.0.1:9/mcp/canvas", "headers": {}, - "description": "节点画布的托管 MCP:读画布(canvas_list / canvas_read),也在画布上加节点、连线、改节点、删节点、发起运行(canvas_add_node / canvas_connect / canvas_update_node / canvas_remove_node / canvas_run)。写操作改的是用户画布上看得见的东西;canvas_remove_node 删掉的节点没法恢复;canvas_run 会真的发起一次生成、花掉用户自己的额度(视频可能跑十几分钟,但这次调用很快就会返回「已经开始」,结果用 canvas_read 看)。", + "description": "节点画布的托管 MCP:读画布(canvas_list / canvas_read),也在画布上加节点、连线、改节点、删节点、发起运行(canvas_add_node / canvas_connect / canvas_update_node / canvas_remove_node / canvas_run),还有现成的工作流模板(canvas_list_templates / canvas_apply_template)。写操作改的是用户画布上看得见的东西;canvas_remove_node 删掉的节点没法恢复;canvas_run 会真的发起一次生成、花掉用户自己的额度(视频可能跑十几分钟,但这次调用很快就会返回「已经开始」,结果用 canvas_read 看);canvas_apply_template 只把模板的骨架铺到画布上(节点、连线、提示词、参数),它自己不跑、不花钱,要跑得再调 canvas_run。", "timeout": 30000 } } diff --git a/plugins/antianqi/ai-node-canvas/tests/smoke.test.mjs b/plugins/antianqi/ai-node-canvas/tests/smoke.test.mjs index 88880dba..55b3c274 100644 --- a/plugins/antianqi/ai-node-canvas/tests/smoke.test.mjs +++ b/plugins/antianqi/ai-node-canvas/tests/smoke.test.mjs @@ -9,7 +9,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { mkdtemp, mkdir, readdir, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'; +import { lstat, mkdtemp, mkdir, readdir, readFile, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises'; import { readdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -8108,10 +8108,129 @@ test('a busy node is not a failed one', async () => { test('the page waits for the run it was given, and a busy node keeps its card', async () => { // The rule is one rule: the page holds the run it waits for, and a node that - // is busy is not marked as failed. - const source = await readFile(fileURLToPath(new URL('../miniapp/client/app.js', import.meta.url)), 'utf8'); - assert.match(source, /const taskId = state\.nodeTasks\.get\(nodeId\);\s*\n\s*return waitForTheRun\(\{/); - assert.match(source, /if \(isBusyRefusal\(error\)\) \{/); + // is busy is not marked as failed — the run that is going is named, so the + // chain can wait for it. + const { theRunToWaitFor, theRunThatIsGoing } = await import('../miniapp/client/run-watch.mjs'); + // The run that was started is handed over, so the waiter does not look it + // up: the page may have forgotten it already. + assert.equal( + theRunToWaitFor({ taskId: 'r_new', nodeId: 'n1', known: new Map([['n1', 'r_old']]) }), + 'r_new', + '交到手里的那次运行就是这次要等的', + ); + // Without one, the run the page knows about is the one that is waited for. + assert.equal(theRunToWaitFor({ nodeId: 'n1', known: new Map([['n1', 'r_old']]) }), 'r_old'); + // And without that, there is no run to wait for. + assert.equal(theRunToWaitFor({ nodeId: 'n1', known: new Map() }), ''); + // A refusal names the run that is going. + assert.equal(theRunThatIsGoing({ code: 'BUSY', runId: 'r_busy' }), 'r_busy'); + assert.equal(theRunThatIsGoing({ code: 'BUSY' }), ''); + assert.equal(theRunThatIsGoing(null), ''); +}); + +test('a run that is over leaves the progress panel, and a failed one goes with it', async () => { + // The progress panel is about now: a run that is over is kept a moment so + // its last lines can still be read, and then it goes. A failed run is a + // finished run — it must not sit on the screen for ever. + const { FINISHED_KEEP_MS, panelTasks, taskIsExpired } = await import('../miniapp/client/run-watch.mjs'); + const now = 1_700_000_000_000; + + // A run that is going is never let go. + assert.equal(taskIsExpired({ status: 'running' }, { now }), false); + assert.equal(taskIsExpired({ status: 'waiting-question' }, { now }), false); + // A run that just ended is kept, so the reason can still be read. + assert.equal(taskIsExpired({ status: 'done', finishedAt: now - 1_000 }, { now }), false); + assert.equal(taskIsExpired({ status: 'error', finishedAt: now - 1_000 }, { now }), false); + // And then it goes: a failed run is not a history. + assert.equal(taskIsExpired({ status: 'done', finishedAt: now - FINISHED_KEEP_MS }, { now }), true); + assert.equal(taskIsExpired({ status: 'error', finishedAt: now - FINISHED_KEEP_MS - 1 }, { now }), true); + // A run that was cancelled has nothing left to show. + assert.equal(taskIsExpired({ status: 'aborted' }, { now }), true); + + // And the panel shows exactly those: what is going, and what just ended. + const shown = panelTasks([ + { id: 'r1', status: 'running' }, + { id: 'r2', status: 'waiting-question' }, + { id: 'r3', status: 'done', finishedAt: now - 1_000 }, + { id: 'r4', status: 'error', finishedAt: now - 1_000 }, + { id: 'r5', status: 'error', finishedAt: now - FINISHED_KEEP_MS - 1 }, + ], { now }); + assert.deepEqual(shown.map((task) => task.id), ['r1', 'r2', 'r3', 'r4']); +}); + +test('a burst of runs does not fill the screen', async () => { + // The panel is short: a burst of failures must not cover the canvas, and + // the newest runs are the ones that are kept. + const { panelTasks } = await import('../miniapp/client/run-watch.mjs'); + const now = 1_700_000_000_000; + const many = Array.from({ length: 12 }, (unused, index) => ({ id: `r${index}`, status: 'running' })); + const shown = panelTasks(many, { now }); + assert.equal(shown.length, 8); + assert.deepEqual(shown.map((task) => task.id), ['r4', 'r5', 'r6', 'r7', 'r8', 'r9', 'r10', 'r11']); +}); + +test('the panel is put away by the user, and a new run brings it back', async () => { + // The panel sits over the canvas, so it has to be the user's choice: it + // stays away when it is put away, and a run that starts brings it back. + const { panelClosed, panelIsShown } = await import('../miniapp/client/run-watch.mjs'); + const one = [{ id: 'r1', status: 'running' }]; + + // It shows itself when there is something to show. + assert.equal(panelIsShown({ closed: false, tasks: one }), true); + assert.equal(panelIsShown({ closed: false, tasks: [] }), false); + // And it stays away when the user put it away. + assert.equal(panelIsShown({ closed: true, tasks: one }), false); + + // The user puts it away... + assert.equal(panelClosed({ closed: false, event: 'put-away' }), true); + // ...and it stays away... + assert.equal(panelClosed({ closed: true }), true); + // ...until a run starts. + assert.equal(panelClosed({ closed: true, event: 'run-started' }), false); + assert.equal(panelClosed({ closed: false, event: 'run-started' }), false); +}); + +// A document that is just enough for the panel's own parts: an element with a +// name, some text, children and a click. +function fakeDocument() { + const create = (tag) => { + const node = { + tagName: tag.toUpperCase(), + className: '', + textContent: '', + children: [], + listeners: new Map(), + appendChild: (child) => { node.children.push(child); return child; }, + addEventListener: (type, handler) => { + const list = node.listeners.get(type) ?? []; + list.push(handler); + node.listeners.set(type, list); + }, + click: () => { + for (const handler of node.listeners.get('click') ?? []) { + handler({ stopPropagation() {} }); + } + }, + }; + return node; + }; + return { createElement: create }; +} + +test('the panel has a button that puts it away', async () => { + // The button is what the user reaches for, so it has to be there and it has + // to ask for the panel to be put away. + const { panelTitleRow } = await import('../miniapp/client/task-panel.mjs'); + const doc = fakeDocument(); + let asked = 0; + const row = panelTitleRow(doc, { onPutAway: () => { asked += 1; } }); + assert.equal(row.className, 'nxc-taskpanel__title'); + const button = row.children.find((child) => child.tagName === 'BUTTON'); + assert.ok(button, 'the title row has no button'); + assert.equal(button.className, 'nxc-taskpanel__close'); + assert.equal(button.textContent, '收起'); + button.click(); + assert.equal(asked, 1, 'the button did not ask for the panel to be put away'); }); test('a clip the provider cannot read in time is made smaller first', async () => { @@ -8368,3 +8487,283 @@ test('a clip on this machine can be measured', async (t) => { assert.match(source, /-show_entries/); assert.equal(source.includes("'-show_streams'"), false, 'probe 又去问整个流了'); }); + +// --------------------------------------------------------------------------- +// The review round: what the reviewer asked for, one test each. +// +// Every rule here is a plain function or a request the runtime answers, so +// each one is checked by what it does and not by how the source reads. +// --------------------------------------------------------------------------- + +test('a link to this machine is refused, however it is written', async () => { + // The IPv4 address can be written inside an IPv6 one in four ways, and + // Node's own parser rewrites them: `::ffff:127.0.0.1` becomes + // `::ffff:7f00:1` before the rule ever sees it. A rule that only knows + // the dotted form is a rule that can be stepped around. + const { embeddedIPv4, isPrivateAddress } = await import('../miniapp/node/media-store.mjs'); + assert.equal(embeddedIPv4('::ffff:7f00:1'), '127.0.0.1'); + assert.equal(embeddedIPv4('::7f00:1'), '127.0.0.1'); + assert.equal(embeddedIPv4('64:ff9b::7f00:1'), '127.0.0.1'); + assert.equal(embeddedIPv4('2002:7f00:1::'), '127.0.0.1'); + assert.equal(embeddedIPv4('2606:4700::1111'), null, '一个普通 IPv6 地址里没有 IPv4'); + + assert.equal(isPrivateAddress('::ffff:7f00:1'), true); + assert.equal(isPrivateAddress('::ffff:127.0.0.1'), true); + assert.equal(isPrivateAddress('::7f00:1'), true); + assert.equal(isPrivateAddress('64:ff9b::7f00:1'), true); + assert.equal(isPrivateAddress('2002:7f00:1::'), true); + // The shared space of a carrier and the reserved ranges are refused too. + assert.equal(isPrivateAddress('100.64.0.1'), true); + assert.equal(isPrivateAddress('100.128.0.1'), false); + assert.equal(isPrivateAddress('198.18.0.1'), true); + assert.equal(isPrivateAddress('240.0.0.1'), true); + // A public address is still a public address, in every form. + assert.equal(isPrivateAddress('::ffff:8.8.8.8'), false); + assert.equal(isPrivateAddress('64:ff9b::8.8.8.8'), false); + assert.equal(isPrivateAddress('2606:4700::1111'), false); + assert.equal(isPrivateAddress('8.8.8.8'), false); +}); + +test('the address that was judged is the address that is used', async () => { + // The name is looked up once for the check and again for the connection, + // and a name that answers one thing for the first and another for the + // second gets through. So the address that was judged is pinned: it is + // the address the download connects to. + const { pinnedAddress } = await import('../miniapp/node/media-store.mjs'); + assert.equal( + pinnedAddress([{ address: '93.184.216.34', family: 4 }])?.address, + '93.184.216.34', + '公网地址就用它自己', + ); + assert.equal( + pinnedAddress([{ address: '93.184.216.34', family: 4 }, { address: '127.0.0.1', family: 4 }]), + null, + '只要有一个地址指向本机,整个名字都拒绝', + ); + assert.equal(pinnedAddress([]), null, '没有地址就没有可连的地址'); +}); + +test('a file is never written through a link that points at nothing', async (t) => { + // A link that points at a file that is not there counts as a name that is + // taken, so the file lands beside it instead of going through it. + const { createMediaStore } = await import('../miniapp/node/media-store.mjs'); + const dir = await mkdtemp(join(tmpdir(), 'nxc-link-')); + const link = join(dir, '模板.json'); + try { + await symlink(join(dir, 'nothing.json'), link, 'file'); + } catch { + await rm(dir, { recursive: true, force: true }); + t.skip('这台机器不让测试建软链:这条合同要在能建软链的机器上验'); + return; + } + const store = createMediaStore({ dataDir: dir }); + const saved = await store.writeToFolder('模板.json', '{"a":1}', dir); + assert.equal(saved.ok, true); + assert.notEqual(saved.path, link, '文件不能写在链接本身上'); + assert.equal((await lstat(link)).isSymbolicLink(), true, '那个链接还是链接'); + const landed = JSON.parse(await readFile(saved.path, 'utf8')); + assert.equal(landed.a, 1); + await rm(dir, { recursive: true, force: true }); +}); + +test('a template of the user is kept and taken away by the same rule', async () => { + const { readTemplates, removeTemplate, saveTemplate, templateIdOf } = await import('../miniapp/node/templates.mjs'); + const dataDir = await mkdtemp(join(tmpdir(), 'nxc-templates-')); + // One rule for the name: a plain name, and never a dot segment. + assert.equal(templateIdOf('own-abc'), 'own-abc'); + assert.equal(templateIdOf('.'), null); + assert.equal(templateIdOf('..'), null); + assert.equal(templateIdOf('...'), null); + assert.equal(templateIdOf('a/b'), null); + assert.equal(templateIdOf(''), null); + + const kept = await saveTemplate(dataDir, { id: 'own-1', title: '我的链', actions: [{ type: 'add_node', args: { type: 'image' } }] }); + assert.equal(kept.ok, true); + // A template that is already there is not written over. + const again = await saveTemplate(dataDir, { id: 'own-1', title: '别的', actions: [] }); + assert.equal(again.ok, false); + assert.equal(again.reason, 'taken'); + + // It is listed by the file it is in, so the panel and the file cannot differ. + const mine = (await readTemplates(dataDir)).find((item) => item.own); + assert.equal(mine.id, 'own-1'); + // A file that was put there some other way is listed by its name too, so it + // can be taken away by that name. + await writeFile( + join(dataDir, 'templates', 'hand-made.json'), + JSON.stringify({ id: 'inner', title: '手放的', actions: [{ type: 'add_node', args: { type: 'image' } }] }), + 'utf8', + ); + const listed = (await readTemplates(dataDir)).find((item) => item.own && item.id === 'hand-made'); + assert.equal(listed.title, '手放的'); + assert.equal(await removeTemplate(dataDir, 'hand-made'), true); + assert.equal(await removeTemplate(dataDir, 'hand-made'), false, '删过就没有了'); + await rm(dataDir, { recursive: true, force: true }); +}); + +test('a template that is kept cannot carry a step that would be refused', async (t) => { + const h = await createHarness(); + t.after(() => h.cleanup()); + const post = (body) => fetch(`${h.base}/api/templates`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + + // A step that is not a node and not a line is refused before it is written. + const bad = await post({ id: 'own-bad', title: '坏的', actions: [{ type: 'rm_rf', args: { path: '/' } }] }); + assert.equal(bad.status, 400); + assert.equal((await bad.json()).error, 'bad_actions'); + + // A line that points at nothing is refused as well. + const dangling = await post({ id: 'own-dangling', title: '断的', actions: [{ type: 'connect', args: { from: 'n1', to: 'n2' } }] }); + assert.equal(dangling.status, 400); + + // The name is judged by the rule that also judges the deletion. + for (const id of ['.', '..', '...', '名字/带斜杠']) { + const refused = await post({ id, title: '名字', actions: [{ type: 'add_node', args: { type: 'image' } }] }); + assert.equal(refused.status, 400, `${id} 这个名字不能用`); + } + + // A good template is kept. + const good = await post({ id: 'own-good', title: '好的', actions: [{ type: 'add_node', args: { type: 'image' } }] }); + assert.equal(good.status, 200); + const listed = await getJson(h.base, '/api/templates'); + const mine = listed.body.templates.find((item) => item.id === 'own-good'); + assert.equal(mine.own, true); + // And a name that is taken is refused, so a template is never lost. + const taken = await post({ id: 'own-good', title: '另一条', actions: [{ type: 'add_node', args: { type: 'image' } }] }); + assert.equal(taken.status, 409); +}); + +test('the export writes a template file, and only a template file', async (t) => { + const h = await createHarness(); + t.after(() => h.cleanup()); + const folder = await mkdtemp(join(tmpdir(), 'nxc-export-')); + const post = (path, document) => fetch(`${h.base}/api/export`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path, document }), + }); + // A name that does not say what the file is. + const wrong = await post(join(folder, '模板.txt'), { id: 'x' }); + assert.equal(wrong.status, 400); + assert.equal((await wrong.json()).error, 'bad_name'); + // A document that is not a template. + const notATemplate = await post(join(folder, '模板.json'), [1, 2, 3]); + assert.equal(notATemplate.status, 400); + assert.equal((await notATemplate.json()).error, 'bad_document'); + // And a template file is written. + const good = await post(join(folder, '模板.json'), { + kind: 'ai-node-canvas-template', + version: 1, + id: 'own-x', + title: '导出的一条', + actions: [{ type: 'add_node', args: { type: 'image' } }], + }); + assert.equal(good.status, 200); + const written = JSON.parse(await readFile(join(folder, '模板.json'), 'utf8')); + assert.equal(written.title, '导出的一条'); + await rm(folder, { recursive: true, force: true }); +}); + +test('a busy node names the run that is going', async (t) => { + // The runtime refuses a second run on a node that is already running, and + // it says which run that is: the chain can wait for it instead of standing + // still. + const connector = createMockHostConnector(undefined, { videoState: 'running' }); + const h = await createHarness({ hostConnector: connector }); + t.after(() => h.cleanup()); + const start = () => fetch(`${h.base}/api/runs`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + nodeId: 'n_busy', + mode: 'video', + prompt: '一只猫在跑', + modelKey: 'MiniMax-H3', + path: 'collection', + references: [{ id: 'n_pic', title: '图', kind: 'image', text: '', media: [{ url: 'https://cdn.example.com/a.png', kind: 'image' }] }], + }), + }); + const first = await start(); + assert.equal(first.status, 202); + const started = await first.json(); + const second = await start(); + assert.equal(second.status, 409); + const refused = await second.json(); + assert.equal(refused.error, 'BUSY'); + assert.equal(refused.runId, started.run.id, '要说出正在跑的那一次'); +}); + +test('a refused run leaves the node free', async (t) => { + // A run that is refused must not leave a task behind: the node would be + // busy for ever, for a run that never started. + const connector = createMockHostConnector(undefined, { videoState: 'succeeded' }); + const h = await createHarness({ hostConnector: connector }); + t.after(() => h.cleanup()); + const refused = await fetch(`${h.base}/api/runs`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + nodeId: 'n_refused', + mode: 'video', + prompt: '只有音频', + modelKey: 'MiniMax-H3', + path: 'collection', + references: [{ id: 'n_music', title: '音乐', kind: 'audio', text: '', media: [{ url: 'https://cdn.example.com/m.mp3', kind: 'audio' }] }], + }), + }); + assert.equal(refused.status, 400); + // The node is free again: the next run is not refused as busy. + const again = await fetch(`${h.base}/api/runs`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + nodeId: 'n_refused', + mode: 'video', + prompt: '带图', + modelKey: 'MiniMax-H3', + path: 'collection', + references: [{ id: 'n_pic', title: '图', kind: 'image', text: '', media: [{ url: 'https://cdn.example.com/a.png', kind: 'image' }] }], + }), + }); + assert.equal(again.status, 202, '被拒绝的一次不能让节点一直占着'); +}); + +test('an old mode name is looked up exactly', async () => { + // A plain object answers `__proto__` with its prototype, so a lookup that + // is not exact hands back something that is not a mode. + const { currentModeId } = await import('../miniapp/client/forms.mjs'); + assert.equal(currentModeId('textimage2image'), 'image2image'); + assert.equal(currentModeId('textimage2video'), 'image2video'); + assert.equal(currentModeId('__proto__'), '__proto__'); + assert.equal(currentModeId('constructor'), 'constructor'); + assert.equal(currentModeId('toString'), 'toString'); +}); + +test('a canvas takes the name of a template only when the steps are on it', async () => { + const { canvasTitleAfterTemplate } = await import('../miniapp/client/actions.mjs'); + assert.equal(canvasTitleAfterTemplate({ title: '图片复刻' }, true), '图片复刻'); + assert.equal(canvasTitleAfterTemplate({ title: '图片复刻' }, false), '', '没有步骤就不改名'); + assert.equal(canvasTitleAfterTemplate({}, true), ''); +}); + +test('the MCP server says what it can do', async () => { + // The description of the server has to name the tools it really has, + // including the ones that delete something and the ones that spend money. + const raw = await readFile(fileURLToPath(new URL('../servers.mcp.json', import.meta.url)), 'utf8'); + const description = JSON.parse(raw).mcpServers.canvas.description; + for (const tool of ['canvas_list', 'canvas_read', 'canvas_add_node', 'canvas_connect', 'canvas_update_node', 'canvas_remove_node', 'canvas_run', 'canvas_list_templates', 'canvas_apply_template']) { + assert.ok(description.includes(tool), `描述里要写明 ${tool}`); + } + assert.match(description, /额度/, '要说明 canvas_run 会花掉用户自己的额度'); + assert.match(description, /没法恢复/, '要说明 canvas_remove_node 删掉的节点找不回来'); +}); + +test('the README says that a deleted template is gone for good', async () => { + const en = await readFile(fileURLToPath(new URL('../README.md', import.meta.url)), 'utf8'); + const zh = await readFile(fileURLToPath(new URL('../README.zh-CN.md', import.meta.url)), 'utf8'); + assert.match(en, /cannot be undone/, '英文 README 要写明删掉找不回来'); + assert.match(zh, /找不回来/, '中文 README 要写明删掉找不回来'); +}); From 9cd9379da2cdaafb9a9ab00eb4eb93300a1c2ce6 Mon Sep 17 00:00:00 2001 From: antianqi <75944423+antianqi@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:03:44 +0800 Subject: [PATCH 2/2] ai-node-canvas 0.84.0: a stopped call says where the limit is, and the MCP server gets a minute A call the Host stops is stopped by the Host, not by us. The connector API takes a signal and nothing else, and a picture is asked for in one synchronous call - there is no task to poll, the way a video has one. The message on the card now says where that limit is (about thirty seconds, and it is the Host's), and what the user can still change: a smaller request. A video is submitted and polled, which is why the same limit reads as a mystery for a picture and not for a video. The MCP server is given a minute to answer instead of half of one. A call that starts a run hands the work to the page and waits for the page's answer, and a page that is busy must not turn the call into a failure. 248 tests, 248 mutations, 0 errors. --- .../.minimax-plugin/plugin.json | 2 +- .../miniapp/node/connector-runner.mjs | 41 ++++++++++++++++--- plugins/antianqi/ai-node-canvas/package.json | 2 +- .../antianqi/ai-node-canvas/servers.mcp.json | 2 +- .../ai-node-canvas/tests/smoke.test.mjs | 27 ++++++++++++ 5 files changed, 66 insertions(+), 8 deletions(-) diff --git a/plugins/antianqi/ai-node-canvas/.minimax-plugin/plugin.json b/plugins/antianqi/ai-node-canvas/.minimax-plugin/plugin.json index f6a1de69..7890b6f6 100644 --- a/plugins/antianqi/ai-node-canvas/.minimax-plugin/plugin.json +++ b/plugins/antianqi/ai-node-canvas/.minimax-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "ai-node-canvas", "displayName": "节点画布", - "version": "0.83.0", + "version": "0.84.0", "description": "无限画布节点编排:把 MiniMax 模型生成的文本、图片、视频串成工作流,连线即数据流,上游产物可直接被下游引用。", "author": "antianqi", "icon": "icon.png", diff --git a/plugins/antianqi/ai-node-canvas/miniapp/node/connector-runner.mjs b/plugins/antianqi/ai-node-canvas/miniapp/node/connector-runner.mjs index 90128867..62df2fef 100644 --- a/plugins/antianqi/ai-node-canvas/miniapp/node/connector-runner.mjs +++ b/plugins/antianqi/ai-node-canvas/miniapp/node/connector-runner.mjs @@ -32,6 +32,41 @@ const TOOL = { assetUrl: 'get_asset_url', }; +/** + * What a connector call that the Host stopped says. + * + * The Host gives every connector call a window of its own — about thirty + * seconds — and a call that runs longer is stopped. That window is the Host's + * and not ours to widen, so the message has to say that it is there, and what + * the user can do about it. + * + * A picture is asked for in one synchronous call: there is no task number to + * poll, so a picture that takes too long is simply lost. The way out is a + * smaller request. A video is submitted and polled, which is why it can run + * for a quarter of an hour — and that difference is worth saying out loud, + * because the same limit reads as a mystery otherwise. + * + * @param {string} suffix the tool that was called + * @param {string} code the Host's error code + * @returns {string|null} the note, or null when the failure is not a stop + */ +export function connectorStopNote(suffix, code) { + const isImage = suffix === TOOL.image; + if (code === 'CONNECTOR_OUTCOME_UNKNOWN') { + return '调用已经发出但没收到回执,结果未知(可能已经扣费);宿主对单次连接器调用有约 30 秒的超时上限,这个上限在宿主那边,画布改不了。' + + (isImage + ? '出图是一次调用直接出结果,没有可轮询的任务号,所以只能把请求变小:把清晰度降到 1K、少给几张参考图,再点一次生成。' + : ''); + } + if (code === 'CONNECTOR_TIMEOUT') { + return '这次调用超过了宿主的超时上限(约 30 秒)被中止;结果未知(可能已经扣费),先核对业务状态再决定要不要重试。' + + (isImage + ? '出图是一次调用直接出结果,没有可轮询的任务号,所以只能把请求变小:把清晰度降到 1K、少给几张参考图,再点一次生成。' + : ''); + } + return null; +} + // The provider takes media URLs, never local paths or data URIs. // // The link comes under a few names, and the video task answers with its own: @@ -215,11 +250,7 @@ export function createConnectorRunner(context, options = {}) { // The time shown is the call's own time. The time it spent waiting for // the machine is said separately, because that is the difference // between "the provider was slow" and "it stood behind another call". - const note = error?.code === 'CONNECTOR_OUTCOME_UNKNOWN' - ? '调用已经发出但没收到回执,结果未知(可能已经扣费);宿主对连接器调用有超时上限,太慢的调用会被中止。' - : error?.code === 'CONNECTOR_TIMEOUT' - ? '这次调用超过了宿主的时限(默认 30 秒)被中止;结果未知,先核对业务状态再决定要不要重试。' - : String(error?.message ?? error); + const note = connectorStopNote(suffix, error?.code) ?? String(error?.message ?? error); throw Object.assign( new Error(`调用 ${suffix} 失败(耗时 ${(callMs / 1000).toFixed(1)}s${queueNote(waitedMs)}):${note}`), { code: error?.code ?? 'CONNECTOR_ERROR' }, diff --git a/plugins/antianqi/ai-node-canvas/package.json b/plugins/antianqi/ai-node-canvas/package.json index 6ee2e390..2aaa1902 100644 --- a/plugins/antianqi/ai-node-canvas/package.json +++ b/plugins/antianqi/ai-node-canvas/package.json @@ -1,6 +1,6 @@ { "name": "ai-node-canvas", - "version": "0.83.0", + "version": "0.84.0", "private": true, "description": "Node-graph canvas MiniApp for MiniMax Code: wire MiniMax model runs into a visual workflow.", "mcode": { diff --git a/plugins/antianqi/ai-node-canvas/servers.mcp.json b/plugins/antianqi/ai-node-canvas/servers.mcp.json index 2185cd8f..3cbe18fe 100644 --- a/plugins/antianqi/ai-node-canvas/servers.mcp.json +++ b/plugins/antianqi/ai-node-canvas/servers.mcp.json @@ -6,7 +6,7 @@ "url": "http://127.0.0.1:9/mcp/canvas", "headers": {}, "description": "节点画布的托管 MCP:读画布(canvas_list / canvas_read),也在画布上加节点、连线、改节点、删节点、发起运行(canvas_add_node / canvas_connect / canvas_update_node / canvas_remove_node / canvas_run),还有现成的工作流模板(canvas_list_templates / canvas_apply_template)。写操作改的是用户画布上看得见的东西;canvas_remove_node 删掉的节点没法恢复;canvas_run 会真的发起一次生成、花掉用户自己的额度(视频可能跑十几分钟,但这次调用很快就会返回「已经开始」,结果用 canvas_read 看);canvas_apply_template 只把模板的骨架铺到画布上(节点、连线、提示词、参数),它自己不跑、不花钱,要跑得再调 canvas_run。", - "timeout": 30000 + "timeout": 60000 } } } diff --git a/plugins/antianqi/ai-node-canvas/tests/smoke.test.mjs b/plugins/antianqi/ai-node-canvas/tests/smoke.test.mjs index 55b3c274..5c90a7f5 100644 --- a/plugins/antianqi/ai-node-canvas/tests/smoke.test.mjs +++ b/plugins/antianqi/ai-node-canvas/tests/smoke.test.mjs @@ -8767,3 +8767,30 @@ test('the README says that a deleted template is gone for good', async () => { assert.match(en, /cannot be undone/, '英文 README 要写明删掉找不回来'); assert.match(zh, /找不回来/, '中文 README 要写明删掉找不回来'); }); + +test('a call the Host stopped says where the limit is, and what to do', async () => { + // The Host gives every connector call a window of its own, and a picture is + // asked for in one call — there is no task to poll, so a picture that takes + // too long is lost. The message has to say where that limit is, and what the + // user can still change. + const { connectorStopNote } = await import('../miniapp/node/connector-runner.mjs'); + const image = connectorStopNote('generate_image', 'CONNECTOR_OUTCOME_UNKNOWN'); + assert.match(image, /30 秒/, '要说出这个窗口有多长'); + assert.match(image, /宿主/, '要说出这个上限在宿主那边'); + assert.match(image, /1K/, '出图要给出马上能用的办法'); + // A call that was not stopped is not one of these. + assert.equal(connectorStopNote('generate_image', 'CONNECTOR_PROVIDER_ERROR'), null); + // A video is submitted and polled, so it does not need a smaller request. + const video = connectorStopNote('submit_video_generation', 'CONNECTOR_TIMEOUT'); + assert.match(video, /30 秒/); + assert.equal(/1K/.test(video), false, '视频不是一次调用出结果,不用教人降清晰度'); +}); + +test('the MCP server is given a minute', async () => { + // The Host waits this long for the MCP server to answer. A call that starts + // a run has to hand the work to the page and wait for the page's answer, so + // the window is a minute: a page that is busy must not turn the call into a + // failure. + const raw = await readFile(fileURLToPath(new URL('../servers.mcp.json', import.meta.url)), 'utf8'); + assert.equal(JSON.parse(raw).mcpServers.canvas.timeout, 60000); +});