diff --git a/plugins/antianqi/ai-node-canvas/.minimax-plugin/plugin.json b/plugins/antianqi/ai-node-canvas/.minimax-plugin/plugin.json index 179fc9ef..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.82.0", + "version": "0.84.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/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/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..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.82.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 292bef83..3cbe18fe 100644 --- a/plugins/antianqi/ai-node-canvas/servers.mcp.json +++ b/plugins/antianqi/ai-node-canvas/servers.mcp.json @@ -5,8 +5,8 @@ "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 看)。", - "timeout": 30000 + "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": 60000 } } } diff --git a/plugins/antianqi/ai-node-canvas/tests/smoke.test.mjs b/plugins/antianqi/ai-node-canvas/tests/smoke.test.mjs index 88880dba..5c90a7f5 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,310 @@ 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 要写明删掉找不回来'); +}); + +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); +});