Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion plugins/antianqi/ai-node-canvas/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion plugins/antianqi/ai-node-canvas/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,8 @@ MiniMax Code 里的无限画布节点图。每个节点是一次模型运行,
文件里只有步骤,没有结果也没有素材,所以换台机器打开也是完整的;
一条链从哪张图起步,那个**空输入位**也跟着走,放的人自己往里放图。
- **自己的模板能管** — 「我的模板」里每一条旁边都有删除按钮(删除前也是在页面里问,
跟这里别的问题一样)。内置的那些不是你的,删不了。
跟这里别的问题一样)。内置的那些不是你的,删不了。**删掉自己的模板找不回来**:
文件没有了,机器上也不会留下另一份。
- **画布是工作台,它自己会存** — 改动后一秒左右自己落盘,页头画布名旁边那颗状态芯片说了算,
所以工具栏上**没有「保存」按钮**:手动再存一次只是把页面已经在做的事做第二遍。
窗口关掉时那份本地草稿也跟着走,所以「刚改完就关」也不会丢。
Expand Down Expand Up @@ -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` 指定的那一项)。
Expand Down
79 changes: 52 additions & 27 deletions plugins/antianqi/ai-node-canvas/miniapp/client/actions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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,
Expand All @@ -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.
*
Expand Down
82 changes: 60 additions & 22 deletions plugins/antianqi/ai-node-canvas/miniapp/client/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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');
Expand Down Expand Up @@ -3617,14 +3633,15 @@ 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;
state.selection = [];
markDirty();
render();
showBanner(`已铺上「${template.title}」,点节点上的「生成」开始`);
return true;
}

/**
Expand Down Expand Up @@ -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' });
Expand Down Expand Up @@ -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';
}

Expand Down Expand Up @@ -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();
Expand All @@ -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';
Expand Down Expand Up @@ -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 });
Expand Down
4 changes: 3 additions & 1 deletion plugins/antianqi/ai-node-canvas/miniapp/client/forms.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading
Loading