field.key === "preset")
+ ?.options?.find(
+ (option) => option.value === task.parameters.preset,
+ )?.label
+ : undefined;
const title = translateToolText(
locale,
- CANVAS_TOOLS.find((tool) => tool.id === task.tool)?.title ?? task.tool,
+ presetLabel ??
+ CANVAS_TOOLS.find((tool) => tool.id === task.tool)?.title ??
+ task.tool,
);
const outputKind = toolOutputKind(task.tool, origin.kind);
const targetNodeId = outputKind ? crypto.randomUUID() : undefined;
diff --git a/src/i18n/messages/en-US/canvasShell.ts b/src/i18n/messages/en-US/canvasShell.ts
index f2b609d7..7b98d885 100644
--- a/src/i18n/messages/en-US/canvasShell.ts
+++ b/src/i18n/messages/en-US/canvasShell.ts
@@ -112,7 +112,7 @@ export const canvasShell = {
"canvas.selection.productReplacement": "Product replacement",
"canvas.selection.dynamicPoster": "Motion poster",
"canvas.selection.tools": "Tools",
- "canvas.selection.inpaintRemove": "Remove object",
+ "canvas.selection.inpaintRemove": "Eraser",
"canvas.selection.cropCompose": "Crop and compose",
"canvas.selection.annotate": "Annotate",
"canvas.selection.captureFrame": "Capture frame",
diff --git a/src/i18n/messages/en-US/errors.ts b/src/i18n/messages/en-US/errors.ts
index 7a257290..7c8ce33b 100644
--- a/src/i18n/messages/en-US/errors.ts
+++ b/src/i18n/messages/en-US/errors.ts
@@ -304,6 +304,10 @@ export const errors = {
"errors.tools.error.cancelUnconfirmed":
"The provider cancellation is unconfirmed.",
"errors.tools.error.generic": "The tool task did not complete. Try again.",
+ "errors.tools.error.conceptTranslation":
+ 'Could not translate "{label}" into an English object name. Enter a short English description or check the chat model used for translation.',
+ "errors.tools.error.noVideoMatch":
+ 'Could not find "{label}" in the video. Try a different description.',
"errors.tools.error.localStopped": "Local processing stopped.",
"errors.tools.error.serviceInterrupted":
"The service was interrupted. Execution records are kept; the task will not be regenerated automatically.",
diff --git a/src/i18n/messages/en-US/tools.ts b/src/i18n/messages/en-US/tools.ts
index 3d3e6395..e8d50dc2 100644
--- a/src/i18n/messages/en-US/tools.ts
+++ b/src/i18n/messages/en-US/tools.ts
@@ -1,7 +1,6 @@
export const tools = {
- "tools.nodeTask.presetTitle": "Generate preset images",
"tools.nodeTask.presetNote":
- "The selected model service will process this. Results will connect to this node.",
+ "The result connects to this node.",
"tools.nodeTask.panelAria": "{title} panel",
"tools.nodeTask.close": "Exit {title}",
"tools.nodeTask.productReference": "Product reference",
diff --git a/src/i18n/messages/zh-CN/canvasShell.ts b/src/i18n/messages/zh-CN/canvasShell.ts
index 934f5e06..f31b8b18 100644
--- a/src/i18n/messages/zh-CN/canvasShell.ts
+++ b/src/i18n/messages/zh-CN/canvasShell.ts
@@ -110,7 +110,7 @@ export const canvasShell = {
"canvas.selection.productReplacement": "商品替换",
"canvas.selection.dynamicPoster": "动态海报",
"canvas.selection.tools": "工具",
- "canvas.selection.inpaintRemove": "消除",
+ "canvas.selection.inpaintRemove": "消除笔",
"canvas.selection.cropCompose": "裁剪与构图",
"canvas.selection.annotate": "标注",
"canvas.selection.captureFrame": "截取帧",
diff --git a/src/i18n/messages/zh-CN/errors.ts b/src/i18n/messages/zh-CN/errors.ts
index 96aae9e4..9594f54c 100644
--- a/src/i18n/messages/zh-CN/errors.ts
+++ b/src/i18n/messages/zh-CN/errors.ts
@@ -221,6 +221,10 @@ export const errors = {
"已接受工具任务的模型配置已变化,请新建尝试。",
"errors.tools.error.cancelUnconfirmed": "供应商取消未确认。",
"errors.tools.error.generic": "工具任务未完成,请重试。",
+ "errors.tools.error.conceptTranslation":
+ "无法将「{label}」转换为英文对象名称。请改用简短的英文描述,或检查用于自动翻译的对话模型。",
+ "errors.tools.error.noVideoMatch":
+ "视频中没有识别到「{label}」,请换一种描述后重试。",
"errors.tools.error.localStopped": "已停止本地处理。",
"errors.tools.error.serviceInterrupted":
"服务中断,原执行记录已保留;不会自动重新生成。",
diff --git a/src/i18n/messages/zh-CN/tools.ts b/src/i18n/messages/zh-CN/tools.ts
index 5d7643bd..818b6553 100644
--- a/src/i18n/messages/zh-CN/tools.ts
+++ b/src/i18n/messages/zh-CN/tools.ts
@@ -1,7 +1,6 @@
/** Tool catalog, tool editors and local media tools. */
export const tools = {
- "tools.nodeTask.presetTitle": "生成预设图片",
- "tools.nodeTask.presetNote": "由已选的模型服务处理,结果会连接到当前节点。",
+ "tools.nodeTask.presetNote": "生成结果会连接到当前节点。",
"tools.nodeTask.panelAria": "{title}面板",
"tools.nodeTask.close": "退出{title}",
"tools.nodeTask.productReference": "商品参考图",
diff --git a/tests/test_creation_tool_drafts.py b/tests/test_creation_tool_drafts.py
index 2fe598d5..8eb79d4b 100644
--- a/tests/test_creation_tool_drafts.py
+++ b/tests/test_creation_tool_drafts.py
@@ -33,6 +33,29 @@ def test_media_card_size_matches_pixels_and_compacts_audio():
assert media_card_size('audio', 1080, 1920, 300, 520) == (300, 112)
+def test_result_titles_stay_named_after_their_preset_without_collisions():
+ from types import SimpleNamespace
+
+ from opendirector.application.creation_tool_drafts import result_title
+
+ def snapshot(*titles):
+ return SimpleNamespace(canvasItems=[SimpleNamespace(id=uuid4(), title=title, visible=True)
+ for title in titles])
+
+ assert result_title(snapshot(), '面部三视图') == '面部三视图'
+ assert result_title(snapshot('面部三视图'), '面部三视图') == '面部三视图 2'
+ assert result_title(snapshot('面部三视图', '面部三视图 2'), '面部三视图') == '面部三视图 3'
+ # The empty placeholder that is about to receive the result is not a conflict.
+ placeholder = snapshot('面部三视图')
+ assert result_title(placeholder, '面部三视图', exclude=placeholder.canvasItems[0].id) == '面部三视图'
+ # A later run of a numbered preset continues the same series.
+ assert result_title(snapshot('连续分镜图 1', '连续分镜图 2'), '连续分镜图 1') == '连续分镜图 3'
+ # Outputs of one run are numbered in publication order.
+ assert result_title(snapshot(), '连续分镜图 1', ['连续分镜图 1']) == '连续分镜图 2'
+ # Unrelated names keep their own family.
+ assert result_title(snapshot('超清 4K'), '超清 4K') == '超清 4K 2'
+
+
@pytest.fixture
def prepared(project, monkeypatch):
store, pid, session = project
diff --git a/tests/test_tool_tasks.py b/tests/test_tool_tasks.py
index 0f066ab2..0a81efc6 100644
--- a/tests/test_tool_tasks.py
+++ b/tests/test_tool_tasks.py
@@ -120,7 +120,7 @@ def test_catalog_reports_availability_by_capability(project):
assert {'image.edit', 'video.first_frame', 'audio.speech', 'text.chat'} <= set(configured['capabilities'])
assert any(field['key'] == 'preset' and field['options'] for field in tools['image-presets']['fields'])
preset_field = next(field for field in tools['image-presets']['fields'] if field['key'] == 'preset')
- assert [option['label'] for option in preset_field['options']] == ['人物三视图', '面部三视图', '产品三视图', '分镜', '多机位']
+ assert [option['label'] for option in preset_field['options']] == ['人物三视图', '面部三视图', '产品三视图', '连续分镜图', '多机位']
def test_outpaint_keeps_original_pixels_and_returns_requested_size(project):
@@ -204,6 +204,43 @@ def test_inpainting_only_changes_the_masked_region(project):
assert image.getpixel((5, 5)) == (0, 0, 255) and image.getpixel((60, 60))[0] > 200
+@pytest.mark.parametrize('cdn_result', ['image', 'timeout', 'invalid_url'])
+def test_smart_edit_accepts_cdn_results_without_forwarding_the_service_key(project, cdn_result):
+ store, project_id, session = project
+ configs = ModelConfigs(store.local.repository)
+ configs.save('image', 'https://images.test/v1', 'seedream-5.0-pro', 'service-secret', protocol='openai')
+ source = upload(store, project_id, session, 'blue.png', png())
+ mask = upload(store, project_id, session, 'mask.png', base64.b64decode(box_mask((64, 48), (10, 8, 30, 32))))
+ calls = []
+
+ def handle(request):
+ calls.append(request.method)
+ if request.method == 'POST':
+ assert request.headers['authorization'] == 'Bearer service-secret'
+ assert b'b64_json' in request.read()
+ url = 'file:///private.png' if cdn_result == 'invalid_url' else 'https://cdn.test/result.png'
+ return httpx.Response(200, json={'data': [{'url': url}]})
+ assert request.url.host == 'cdn.test'
+ assert 'authorization' not in request.headers
+ if cdn_result == 'timeout':
+ raise httpx.ReadTimeout('download interrupted', request=request)
+ return httpx.Response(200, content=png(color='red'))
+
+ task = run(store, configs, httpx.MockTransport(handle), project_id, session,
+ {'tool': 'smart-edit', 'sourceAssetIds': [source], 'maskAssetId': mask,
+ 'parameters': {'editMode': 'selection', 'prompt': '改成红色'}})
+ assert calls.count('POST') == 1
+ if cdn_result == 'image':
+ assert task['status'] == 'succeeded', task['error']
+ result = output_image(store, project_id, task)
+ assert result.getpixel((20, 20)) == (255, 0, 0)
+ assert result.getpixel((5, 5)) == (0, 0, 255)
+ else:
+ assert task['status'] == 'failed'
+ assert '提交结果不明' not in task['error']
+ assert ('连接超时' if cdn_result == 'timeout' else '接口协议') in task['error']
+
+
def test_presets_return_one_output_per_shot_and_local_hd_needs_no_model(project):
store, project_id, session = project
configs = configure(store, ZIMAGE)
@@ -219,7 +256,7 @@ def test_presets_return_one_output_per_shot_and_local_hd_needs_no_model(project)
@pytest.mark.parametrize('preset,count,size', [
('three-view', 1, (2048, 1152)),
- ('face-three-view', 1, (2688, 1152)),
+ ('face-three-view', 1, (3456, 1152)),
('product-three-view', 1, (2048, 1152)),
('storyboard', 4, (2048, 1152)),
('multi-camera', 4, (2048, 1152)),
@@ -242,6 +279,11 @@ def test_image_presets_reframe_portrait_reference_and_register_each_result(proje
assert width / height == pytest.approx(size[0] / size[1], abs=.04)
if preset == 'face-three-view':
assert '左侧90度'.encode() in calls[0] and '右侧90度'.encode() in calls[0]
+ # The three heads must stay apart; touching profiles are the reported defect.
+ assert '不接触、不重叠、不粘连'.encode() in calls[0]
+ # The last two heads must face opposite ways; two matching profiles were the second defect.
+ assert '鼻尖朝向画面右方'.encode() in calls[0] and '鼻尖朝向画面左方'.encode() in calls[0]
+ assert '朝向相反、左右镜像对称'.encode() in calls[0]
elif preset == 'product-three-view':
assert '近正交投影'.encode() in calls[0]
@@ -669,6 +711,7 @@ def test_smart_edit_uses_the_active_mode_and_retains_legacy_combined_edits(proje
assert task['status'] == 'succeeded', task['error']
assert calls[0].count(b'name="image"') == (1 if mode == 'selection' else 2)
assert ('画笔说明'.encode() in calls[0]) == (mode != 'selection')
+ assert ('逐像素对齐'.encode() in calls[0]) == (mode != 'annotation')
result = output_rgba(store, project_id, task)
assert result.getpixel((20, 20)) == (255, 0, 0, 255)
outside = (255, 0, 0, 255) if mode == 'annotation' else (0, 0, 255, 255)
@@ -754,9 +797,9 @@ def test_video_matting_keeps_the_tracked_subject_over_the_chosen_background(proj
red, green, blue = frame.getpixel((58, 18))
assert green > 200 and red < 60 and blue < 60 # The untracked right half is green screen.
task = run(store, configs, httpx.MockTransport(tracking_server(log)), project_id, session,
- {'tool': 'video-matting', 'sourceAssetIds': [source], 'parameters': {'background': 'transparent', 'prompt': '狗'}})
+ {'tool': 'video-matting', 'sourceAssetIds': [source], 'parameters': {'background': 'transparent', 'prompt': '猫咪'}})
assert task['status'] == 'succeeded', task['error']
- assert log[-1] == 'dog' and task['outputs'][0]['type'] == 'video/webm'
+ assert log[-1] == 'cat' and task['outputs'][0]['type'] == 'video/webm'
path = tmp_path / ('output.' + task['outputs'][0]['type'].split('/')[-1])
path.write_bytes(CreationToolAssets(store).read_bytes(project_id, task['outputs'][0]['id']))
assert probe(path, 'stream_tags=alpha_mode').get('tags', {}).get('alpha_mode') == '1'
diff --git a/tests/toolTaskText.test.ts b/tests/toolTaskText.test.ts
index 672737b1..b00e6e6d 100644
--- a/tests/toolTaskText.test.ts
+++ b/tests/toolTaskText.test.ts
@@ -48,3 +48,18 @@ test("tool task errors never leak persisted Chinese", () => {
assert.equal(toolTaskError(null), undefined);
setActiveLocale("zh-CN");
});
+
+test("matting errors distinguish prompt translation from an empty tracking result in both locales", () => {
+ const translation = "无法识别「三花猫」,请改用英文描述,或在“模型接入”中添加对话模型以自动翻译。";
+ const missing = "视频中没有识别到「cat」,请换一种描述后重试。";
+ try {
+ setActiveLocale("zh-CN");
+ assert.match(toolTaskError(translation)!, /「三花猫」.*改用简短的英文描述/);
+ assert.equal(toolTaskError(missing), missing);
+ setActiveLocale("en-US");
+ assert.match(toolTaskError(translation)!, /translate "三花猫".*short English description/);
+ assert.match(toolTaskError(missing)!, /Could not find "cat" in the video/);
+ } finally {
+ setActiveLocale("zh-CN");
+ }
+});
From 2ee533ff89c343a0a373c1e8c09a5ee73d802451 Mon Sep 17 00:00:00 2001
From: XianglongZeng <1114802486@qq.com>
Date: Sat, 7 Nov 2026 21:26:08 +0800
Subject: [PATCH 2/4] [feat] canvas: clarify reference ports and tool controls
(#209)
- Show inward, outward or bidirectional reference arrows from the node's
usable content and role, with unit and browser regression coverage.
- Add shared filled tool glyphs and distinct canvas import/export icons.
- Compact canvas controls and clarify direct connection copy in both
interface languages and READMEs.
Fixes: #209
---
README.md | 2 +-
README.zh-CN.md | 2 +-
src/canvas/CanvasIcon.tsx | 41 +++++++
src/canvas/NodeSelectionToolbar.tsx | 139 ++++++++++++++---------
src/canvas/ToolIcon.tsx | 33 ++++++
src/canvas/connectionPorts.ts | 45 ++++++--
src/canvas/toolGlyphs.ts | 107 +++++++++++++++++
src/canvas/workspace.css | 24 ++--
src/canvas/workspace/WorkspaceView.tsx | 16 ++-
src/canvas/workspace/workspaceChrome.tsx | 4 +-
src/i18n/messages/en-US/models.ts | 2 +-
src/i18n/messages/zh-CN/models.ts | 2 +-
tests/browser/node_connections.mjs | 42 +++++++
tests/nodeReferencePorts.test.ts | 61 ++++++++++
14 files changed, 439 insertions(+), 81 deletions(-)
create mode 100644 src/canvas/ToolIcon.tsx
create mode 100644 src/canvas/toolGlyphs.ts
diff --git a/README.md b/README.md
index a6f6560f..9a43f262 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@ Use it to explore visual ideas, refine assets, and make short films. Projects st
- **Timeline editing**: Arrange assets, adjust start and end points, speed, and volume, then preview and render an MP4. You can also export JSON, FCPXML, and asset bundles.
- **Local projects, your choice of models**: The canvas saves automatically to your machine. Configure multiple models for a service and switch between them as needed.
-If image generation repeatedly times out or disconnects through a system proxy, open **Settings → Model connections → Edit service → Advanced connection** and set **Network connection mode** to **Direct connection**. This affects that service's model requests and result downloads without changing the system proxy. Check the provider's records before starting another attempt when request acceptance is unknown.
+If image generation repeatedly times out or disconnects through a system proxy, open **Settings → Model connections → Edit service → Advanced connection** and set **Network connection mode** to **Direct (no proxy)**. This affects that service's model requests and result downloads without changing the system proxy. Check the provider's records before starting another attempt when request acceptance is unknown.
## Example film: We Can Still Win
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 0240e7ca..c45b4ce0 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -34,7 +34,7 @@ OpenDirector 是一个在本地运行、通过浏览器使用的 AI 视频创作
- **时间线编排**:安排素材顺序、起止点、速度和音量,预览并合成 MP4,也可导出 JSON、FCPXML 和素材包。
- **本地项目,自选模型**:画布自动保存到本机,同一服务可配置多个模型,按用途切换。
-若图片生成经系统代理反复超时或连接中断,可在「设置 → 模型接入 → 编辑服务 → 高级连接」将「网络连接方式」设为「直连」。此设置仅影响该服务的模型请求和结果下载,不修改系统代理。提交结果不明的任务应先核对供应商记录,再决定是否新建尝试。
+若图片生成经系统代理反复超时或连接中断,可在「设置 → 模型接入 → 编辑服务 → 高级连接」将「网络连接方式」设为「直连(不使用代理)」。此设置仅影响该服务的模型请求和结果下载,不修改系统代理。提交结果不明的任务应先核对供应商记录,再决定是否新建尝试。
## 短片示例:《我们还能赢》
diff --git a/src/canvas/CanvasIcon.tsx b/src/canvas/CanvasIcon.tsx
index f58fcafe..fd98bb2e 100644
--- a/src/canvas/CanvasIcon.tsx
+++ b/src/canvas/CanvasIcon.tsx
@@ -45,6 +45,23 @@ const icons: Record
= {
>
),
+ /** A round smiling face: the identification-driven face preset. */
+ face: (
+ <>
+
+
+
+
+ >
+ ),
+ /** A box seen from the corner: three faces, like a product three-view. */
+ product: (
+ <>
+
+
+
+ >
+ ),
canvas: (
<>
@@ -140,6 +157,30 @@ const icons: Record = {
>
),
+ /** A framed canvas on its stand; the arrow leaves the frame. */
+ canvasExport: (
+ <>
+
+
+
+
+
+
+
+ >
+ ),
+ /** The same canvas with the arrow coming back into the frame. */
+ canvasImport: (
+ <>
+
+
+
+
+
+
+
+ >
+ ),
download: (
<>
diff --git a/src/canvas/NodeSelectionToolbar.tsx b/src/canvas/NodeSelectionToolbar.tsx
index e85be7e1..84cb0b87 100644
--- a/src/canvas/NodeSelectionToolbar.tsx
+++ b/src/canvas/NodeSelectionToolbar.tsx
@@ -7,6 +7,8 @@ import {
type KeyboardEvent,
} from "react";
import { CanvasIcon } from "./CanvasIcon";
+import { ToolIcon } from "./ToolIcon";
+import type { ToolGlyphName } from "./toolGlyphs";
import { TooltipButton } from "./TooltipButton";
import { GridSplitOptions, SelectionToolbarMenu } from "./SelectionToolbarMenu";
import type { CanvasNode } from "./model";
@@ -48,6 +50,8 @@ type Action = {
id: string;
label: string;
icon: string;
+ /** Solid tool glyph; tool actions outweigh the line icons used for chrome. */
+ glyph?: ToolGlyphName;
run?: () => void;
options?: MenuOption[];
disabled?: boolean;
@@ -71,13 +75,14 @@ type OpenMenu = {
grid?: boolean;
};
-const imageTools: readonly [CanvasToolId, MessageKey, string][] = [
- ["smart-edit", "canvas.selection.tool.smartEdit", "sparkle"],
- ["panorama", "canvas.selection.tool.panorama", "expand"],
- ["outpaint", "canvas.selection.tool.outpaint", "expand"],
- ["image-hd", "canvas.selection.tool.upscale", "image"],
- ["cutout", "canvas.selection.tool.cutout", "subject"],
-];
+const imageTools: readonly [CanvasToolId, MessageKey, string, ToolGlyphName][] =
+ [
+ ["smart-edit", "canvas.selection.tool.smartEdit", "sparkle", "smart-edit"],
+ ["panorama", "canvas.selection.tool.panorama", "expand", "panorama"],
+ ["outpaint", "canvas.selection.tool.outpaint", "expand", "outpaint"],
+ ["image-hd", "canvas.selection.tool.upscale", "image", "image-hd"],
+ ["cutout", "canvas.selection.tool.cutout", "subject", "cutout"],
+ ];
const videoTools: readonly [CanvasToolId, MessageKey, string][] = [
["video-partial-reshoot", "canvas.selection.tool.videoReshoot", "camera"],
["video-upscale", "canvas.selection.tool.upscale", "video"],
@@ -142,16 +147,23 @@ export function NodeSelectionToolbar({
label: string,
icon: string,
options: MenuOption[],
+ glyph?: ToolGlyphName,
) => {
if (options.some((option) => !option.disabled))
- editing.push({ id, label, icon, options, disabled: locked });
+ editing.push({ id, label, icon, glyph, options, disabled: locked });
};
- const addTool = (tool: CanvasToolId, label: string, icon: string) => {
+ const addTool = (
+ tool: CanvasToolId,
+ label: string,
+ icon: string,
+ glyph?: ToolGlyphName,
+ ) => {
if (onTool)
editing.push({
id: tool,
label,
icon,
+ glyph,
run: () => onTool(tool),
disabled: locked,
});
@@ -172,8 +184,8 @@ export function NodeSelectionToolbar({
});
};
if (node.type === "image") {
- for (const [tool, labelKey, icon] of imageTools)
- addTool(tool, t(labelKey), icon);
+ for (const [tool, labelKey, icon, glyph] of imageTools)
+ addTool(tool, t(labelKey), icon, glyph);
addMenu(
"image-presets",
t("canvas.selection.presets"),
@@ -186,49 +198,69 @@ export function NodeSelectionToolbar({
? "camera"
: value === "storyboard"
? "grid"
- : value === "product-three-view"
- ? "image"
- : "subject",
+ : value === "face-three-view"
+ ? "face"
+ : value === "product-three-view"
+ ? "product"
+ : "subject",
run: () => onPreset?.(String(value)),
disabled: !onPreset,
})),
+ "presets",
+ );
+ addTool(
+ "view-control",
+ t("canvas.selection.multiAngle"),
+ "rotate",
+ "view-control",
+ );
+ addTool("lighting", t("canvas.selection.lighting"), "palette", "lighting");
+ addMenu(
+ "marketing",
+ t("canvas.selection.marketing"),
+ "palette",
+ [
+ toolOption("multi-size", t("canvas.selection.multiSize"), "expand"),
+ toolOption(
+ "layer-decompose",
+ t("canvas.selection.layerDecompose"),
+ "stack",
+ ),
+ toolOption(
+ "product-replacement",
+ t("canvas.selection.productReplacement"),
+ "image",
+ ),
+ toolOption(
+ "dynamic-poster",
+ t("canvas.selection.dynamicPoster"),
+ "video",
+ ),
+ ],
+ "marketing",
+ );
+ addMenu(
+ "tools",
+ t("canvas.selection.tools"),
+ "tool",
+ [
+ toolOption(
+ "inpainting-remove",
+ t("canvas.selection.inpaintRemove"),
+ "edit",
+ ),
+ toolOption("composition", t("canvas.selection.cropCompose"), "crop"),
+ {
+ id: "grid-split",
+ label: t("canvas.selection.gridSplit"),
+ icon: "grid",
+ grid: true,
+ disabled: !onGridSplit,
+ },
+ toolOption("annotation", t("canvas.selection.annotate"), "pencil"),
+ ],
+ "tools",
);
- addTool("view-control", t("canvas.selection.multiAngle"), "rotate");
- addTool("lighting", t("canvas.selection.lighting"), "palette");
- addMenu("marketing", t("canvas.selection.marketing"), "palette", [
- toolOption("multi-size", t("canvas.selection.multiSize"), "expand"),
- toolOption(
- "layer-decompose",
- t("canvas.selection.layerDecompose"),
- "stack",
- ),
- toolOption(
- "product-replacement",
- t("canvas.selection.productReplacement"),
- "image",
- ),
- toolOption(
- "dynamic-poster",
- t("canvas.selection.dynamicPoster"),
- "video",
- ),
- ]);
- addMenu("tools", t("canvas.selection.tools"), "tool", [
- toolOption(
- "inpainting-remove",
- t("canvas.selection.inpaintRemove"),
- "edit",
- ),
- toolOption("composition", t("canvas.selection.cropCompose"), "crop"),
- {
- id: "grid-split",
- label: t("canvas.selection.gridSplit"),
- icon: "grid",
- grid: true,
- disabled: !onGridSplit,
- },
- toolOption("annotation", t("canvas.selection.annotate"), "pencil"),
- ]);
} else if (node.type === "video") {
for (const [tool, labelKey, icon] of videoTools)
addTool(tool, t(labelKey), icon);
@@ -316,6 +348,7 @@ export function NodeSelectionToolbar({
id: "assistant",
label: t("canvas.selection.assistant"),
icon: "sparkle",
+ glyph: "assistant",
run: onAssistant,
});
@@ -487,7 +520,11 @@ export function NodeSelectionToolbar({
);
}}
>
-
+ {action.glyph ? (
+
+ ) : (
+
+ )}
{!action.iconOnly && {action.label}}
{action.options && }
diff --git a/src/canvas/ToolIcon.tsx b/src/canvas/ToolIcon.tsx
new file mode 100644
index 00000000..8e65b311
--- /dev/null
+++ b/src/canvas/ToolIcon.tsx
@@ -0,0 +1,33 @@
+import type { SVGProps } from "react";
+
+import { TOOL_GLYPHS, type ToolGlyphName } from "./toolGlyphs";
+
+export interface ToolIconProps extends Omit<
+ SVGProps,
+ "name" | "size"
+> {
+ name: ToolGlyphName;
+ size?: number;
+}
+
+/** Filled tool glyphs for the selection toolbar, drawn on the same 24 grid as
+ * CanvasIcon but as solid shapes so tool buttons outweigh ordinary chrome. */
+export function ToolIcon({ name, size = 20, ...props }: ToolIconProps) {
+ const glyph = TOOL_GLYPHS[name] ?? TOOL_GLYPHS["smart-edit"];
+ return (
+
+ );
+}
+
+export default ToolIcon;
diff --git a/src/canvas/connectionPorts.ts b/src/canvas/connectionPorts.ts
index 97710b9e..0fed40ae 100644
--- a/src/canvas/connectionPorts.ts
+++ b/src/canvas/connectionPorts.ts
@@ -1,20 +1,45 @@
-import type { Point, Rect } from "./model.ts";
+import { canSourceReference } from "./connectionPolicy.ts";
+import type { CanvasNode, Point, Rect } from "./model.ts";
export type PortSide = "top" | "right" | "bottom" | "left";
+/** Which ways references can travel from the node these ports surround. */
+export type ReferencePortDirection = "in" | "out" | "both";
export const REFERENCE_PORT_SIZE = 22;
export const REFERENCE_PORT_MAX_SIZE = 30;
/** Drawn size of the direction icon inside the port circle. */
export const REFERENCE_PORT_ICON_SIZE = 15;
-/** Asset ports point outwards; editable nodes show the edge's two directions. */
-export function referencePortIcon(side: PortSide, sourceOnly: boolean) {
- if (sourceOnly)
- return {
- top: "arrowUp",
- right: "arrowRight",
- bottom: "arrowDown",
- left: "arrowLeft",
- }[side];
+/**
+ * Asset ports point outwards; editable nodes show both directions. A generation
+ * node without content can only receive references, so its ports point inwards
+ * until a result makes it a source as well.
+ */
+export function referencePortDirection(
+ node: CanvasNode | undefined,
+): ReferencePortDirection {
+ if (!node) return "both";
+ if (node.data.mediaRole === "asset") return "out";
+ return canSourceReference(node) ? "both" : "in";
+}
+
+const OUTWARD_ICONS: Record = {
+ top: "arrowUp",
+ right: "arrowRight",
+ bottom: "arrowDown",
+ left: "arrowLeft",
+};
+const INWARD_ICONS: Record = {
+ top: "arrowDown",
+ right: "arrowLeft",
+ bottom: "arrowUp",
+ left: "arrowRight",
+};
+export function referencePortIcon(
+ side: PortSide,
+ direction: ReferencePortDirection,
+) {
+ if (direction === "in") return INWARD_ICONS[side];
+ if (direction === "out") return OUTWARD_ICONS[side];
return side === "top" || side === "bottom"
? "referenceArrowsVertical"
: "referenceArrowsHorizontal";
diff --git a/src/canvas/toolGlyphs.ts b/src/canvas/toolGlyphs.ts
new file mode 100644
index 00000000..cc9eda7f
--- /dev/null
+++ b/src/canvas/toolGlyphs.ts
@@ -0,0 +1,107 @@
+/** Solid tool glyphs for the node selection toolbar.
+ *
+ * One filled family, 24x24, 2..22 safe area. A sparkle marks the actions that
+ * generate or rewrite pixels; plain tools stay unmarked so the marker keeps
+ * meaning. Paths are filled with currentColor, so every theme colour works.
+ * Labels live in the i18n catalog, not here.
+ */
+export const TOOL_GLYPHS = {
+ /** magic wand + sparkle. */
+ "smart-edit": {
+ d: [
+ "M6.13 20.13L14.13 12.13A1.6 1.6 0 0 1 11.87 9.87L3.87 17.87A1.6 1.6 0 0 1 6.13 20.13Z",
+ "M18 2.4C18.11 4.31 19.69 5.89 21.6 6C19.69 6.11 18.11 7.69 18 9.6C17.89 7.69 16.31 6.11 14.4 6C16.31 5.89 17.89 4.31 18 2.4Z",
+ ],
+ },
+ /** globe with a panorama band. */
+ panorama: {
+ d: [
+ "M12 2.8A9.2 9.2 0 1 1 12 21.2A9.2 9.2 0 1 1 12 2.8ZM12 4.6A7.4 7.4 0 1 1 12 19.4A7.4 7.4 0 1 1 12 4.6Z",
+ "M12 7.4A8.9 4.6 0 1 1 12 16.6A8.9 4.6 0 1 1 12 7.4ZM12 8.85A7.45 3.15 0 1 1 12 15.15A7.45 3.15 0 1 1 12 8.85Z",
+ ],
+ },
+ /** corner brackets + centre block. */
+ outpaint: {
+ d: [
+ "M3 8.2V4.2A1.2 1.2 0 0 1 4.2 3H8.2V4.9H4.9V8.2Z",
+ "M21 8.2V4.2A1.2 1.2 0 0 0 19.8 3H15.8V4.9H19.1V8.2Z",
+ "M21 15.8v4a1.2 1.2 0 0 1-1.2 1.2h-4v-1.9h3.3v-3.3Z",
+ "M3 15.8v4A1.2 1.2 0 0 0 4.2 21h4v-1.9H4.9v-3.3Z",
+ "M11.2 9.8H12.8A1.4 1.4 0 0 1 14.2 11.2V12.8A1.4 1.4 0 0 1 12.8 14.2H11.2A1.4 1.4 0 0 1 9.8 12.8V11.2A1.4 1.4 0 0 1 11.2 9.8Z",
+ ],
+ },
+ /** HD frame + sparkle. */
+ "image-hd": {
+ d: [
+ "M6.4 4.2H17.6A3.6 3.6 0 0 1 21.2 7.8V16.2A3.6 3.6 0 0 1 17.6 19.8H6.4A3.6 3.6 0 0 1 2.8 16.2V7.8A3.6 3.6 0 0 1 6.4 4.2ZM6.4 5.9H17.6A1.9 1.9 0 0 1 19.5 7.8V16.2A1.9 1.9 0 0 1 17.6 18.1H6.4A1.9 1.9 0 0 1 4.5 16.2V7.8A1.9 1.9 0 0 1 6.4 5.9Z",
+ "M6.5 8.4h1.45v2.85h1.85V8.4h1.45v7.2H9.8v-2.85H7.95V15.6H6.5Z",
+ "M12.35 8.4h1.95a3.6 3.6 0 0 1 0 7.2h-1.95Zm1.4 1.45v4.3h.55a2.15 2.15 0 0 0 0-4.3Z",
+ "M20.1 0.6C20.2 2.35 21.65 3.8 23.4 3.9C21.65 4 20.2 5.45 20.1 7.2C20 5.45 18.55 4 16.8 3.9C18.55 3.8 20 2.35 20.1 0.6Z",
+ ],
+ },
+ /** subject inside a frame that is cut open. */
+ cutout: {
+ d: [
+ "M7.2 2.8H16.8A4.4 4.4 0 0 1 21.2 7.2V16.8A4.4 4.4 0 0 1 16.8 21.2H7.2A4.4 4.4 0 0 1 2.8 16.8V7.2A4.4 4.4 0 0 1 7.2 2.8ZM7.2 4.7H16.8A2.5 2.5 0 0 1 19.3 7.2V16.8A2.5 2.5 0 0 1 16.8 19.3H7.2A2.5 2.5 0 0 1 4.7 16.8V7.2A2.5 2.5 0 0 1 7.2 4.7ZM17.2 9.6h5.4v3.4h-5.4Z",
+ "M12 5.4A3.2 3.2 0 1 1 12 11.8A3.2 3.2 0 1 1 12 5.4Z",
+ "M12 13.4c-3.5 0-6 2.3-6 5.5v.3h12v-.3c0-3.2-2.5-5.5-6-5.5Z",
+ ],
+ },
+ /** 2x2 tiles, sparkle in the fourth slot. */
+ presets: {
+ d: [
+ "M5.2 3H7.6A2.2 2.2 0 0 1 9.8 5.2V7.6A2.2 2.2 0 0 1 7.6 9.8H5.2A2.2 2.2 0 0 1 3 7.6V5.2A2.2 2.2 0 0 1 5.2 3Z",
+ "M16.4 3H18.8A2.2 2.2 0 0 1 21 5.2V7.6A2.2 2.2 0 0 1 18.8 9.8H16.4A2.2 2.2 0 0 1 14.2 7.6V5.2A2.2 2.2 0 0 1 16.4 3Z",
+ "M5.2 14.2H7.6A2.2 2.2 0 0 1 9.8 16.4V18.8A2.2 2.2 0 0 1 7.6 21H5.2A2.2 2.2 0 0 1 3 18.8V16.4A2.2 2.2 0 0 1 5.2 14.2Z",
+ "M17.6 13.8C17.71 15.81 19.39 17.49 21.4 17.6C19.39 17.71 17.71 19.39 17.6 21.4C17.49 19.39 15.81 17.71 13.8 17.6C15.81 17.49 17.49 15.81 17.6 13.8Z",
+ ],
+ },
+ /** isometric cube. */
+ "view-control": {
+ d: [
+ "M12 3.4 19.6 7.8 12 12.2 4.4 7.8Z",
+ "M4.4 9.4 11.2 13.3 11.2 20.4 4.4 16.5Z",
+ "M19.6 9.4 12.8 13.3 12.8 20.4 19.6 16.5Z",
+ ],
+ },
+ /** lamp bar casting three rays. */
+ lighting: {
+ d: [
+ "M6.6 4.4H17.4A1.8 1.8 0 0 1 19.2 6.2V6.2A1.8 1.8 0 0 1 17.4 8H6.6A1.8 1.8 0 0 1 4.8 6.2V6.2A1.8 1.8 0 0 1 6.6 4.4Z",
+ "M11 10.6L11 18.4A1 1 0 0 1 13 18.4L13 10.6A1 1 0 0 1 11 10.6Z",
+ "M6.71 10.27L5.91 16.47A1 1 0 0 1 7.89 16.73L8.69 10.53A1 1 0 0 1 6.71 10.27Z",
+ "M15.31 10.53L16.11 16.73A1 1 0 0 1 18.09 16.47L17.29 10.27A1 1 0 0 1 15.31 10.53Z",
+ ],
+ },
+ /** shopping bag + sparkle. */
+ marketing: {
+ d: [
+ "M7.2 8.6H16.8A2.6 2.6 0 0 1 19.4 11.2V17.8A2.6 2.6 0 0 1 16.8 20.4H7.2A2.6 2.6 0 0 1 4.6 17.8V11.2A2.6 2.6 0 0 1 7.2 8.6Z",
+ "M8.9 9V8a3.1 3.1 0 0 1 6.2 0v1h-1.8V8a1.3 1.3 0 0 0-2.6 0v1Z",
+ "M19.4 1.3C19.5 3.05 20.95 4.5 22.7 4.6C20.95 4.7 19.5 6.15 19.4 7.9C19.3 6.15 17.85 4.7 16.1 4.6C17.85 4.5 19.3 3.05 19.4 1.3Z",
+ ],
+ },
+ /** open-end wrench. */
+ tools: {
+ d: [
+ "M6.05 19.65L14.05 11.65A1.2 1.2 0 0 1 12.35 9.95L4.35 17.95A1.2 1.2 0 0 1 6.05 19.65Z",
+ "M16.4 2.9A4.7 4.7 0 1 1 16.4 12.3A4.7 4.7 0 1 1 16.4 2.9ZM16.4 4.9A2.7 2.7 0 1 1 16.4 10.3A2.7 2.7 0 1 1 16.4 4.9ZM15.34 6.54 19.58 2.3 21.64 4.36 17.46 8.66Z",
+ ],
+ },
+ /** speech bubble + knocked-out sparkle. */
+ assistant: {
+ d: [
+ "M6.6 5H13.8A3.6 3.6 0 0 1 17.4 8.6v5.6a3.6 3.6 0 0 1-3.6 3.6h-2.4L6.6 20.9v-3.1A3.6 3.6 0 0 1 3 14.2V8.6A3.6 3.6 0 0 1 6.6 5ZM18.8 0.2C18.95 2.85 21.15 5.05 23.8 5.2C21.15 5.35 18.95 7.55 18.8 10.2C18.65 7.55 16.45 5.35 13.8 5.2C16.45 5.05 18.65 2.85 18.8 0.2Z",
+ "M18.8 1.6C18.91 3.51 20.49 5.09 22.4 5.2C20.49 5.31 18.91 6.89 18.8 8.8C18.69 6.89 17.11 5.31 15.2 5.2C17.11 5.09 18.69 3.51 18.8 1.6Z",
+ ],
+ },
+ /** two frames, large and small. */
+ "multi-size": {
+ d: [
+ "M5.2 3H11.8A2.2 2.2 0 0 1 14 5.2V11.8A2.2 2.2 0 0 1 11.8 14H5.2A2.2 2.2 0 0 1 3 11.8V5.2A2.2 2.2 0 0 1 5.2 3ZM5.2 4.7H11.8A0.5 0.5 0 0 1 12.3 5.2V11.8A0.5 0.5 0 0 1 11.8 12.3H5.2A0.5 0.5 0 0 1 4.7 11.8V5.2A0.5 0.5 0 0 1 5.2 4.7Z",
+ "M16.2 14.4H19.2A1.8 1.8 0 0 1 21 16.2V19.2A1.8 1.8 0 0 1 19.2 21H16.2A1.8 1.8 0 0 1 14.4 19.2V16.2A1.8 1.8 0 0 1 16.2 14.4ZM16.3 15.9H19.1A0.4 0.4 0 0 1 19.5 16.3V19.1A0.4 0.4 0 0 1 19.1 19.5H16.3A0.4 0.4 0 0 1 15.9 19.1V16.3A0.4 0.4 0 0 1 16.3 15.9Z",
+ ],
+ },
+} as const;
+
+export type ToolGlyphName = keyof typeof TOOL_GLYPHS;
diff --git a/src/canvas/workspace.css b/src/canvas/workspace.css
index 7ad1cc28..844adee6 100644
--- a/src/canvas/workspace.css
+++ b/src/canvas/workspace.css
@@ -809,19 +809,23 @@
--cw-node-info-shadow: 0 18px 44px rgba(0, 0, 0, 0.5);
}
.cw-node-info-trigger {
- padding: 5px 10px;
+ padding: 4px 8px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--cw-node-info-fg);
font: inherit;
- font-size: 12px;
cursor: default;
white-space: nowrap;
transition:
background 0.15s,
color 0.15s;
}
+/* Matches the language toggle next to it; the workspace button reset is more
+ specific, so the size needs this selector to win. */
+.cw-topbar .cw-node-info-trigger {
+ font-size: 11px;
+}
.cw-node-info-trigger:hover {
background: var(--cw-node-info-hover-bg);
color: var(--cw-node-info-hover-fg);
@@ -950,14 +954,14 @@
}
.cw-rail {
position: relative;
- margin-left: 12px;
- width: 48px;
- padding: 6px;
+ margin-left: 10px;
+ width: 46px;
+ padding: 4px;
display: flex;
flex-direction: column;
- gap: 5px;
+ gap: 3px;
border: 1px solid #ffffff0b;
- border-radius: 12px;
+ border-radius: 10px;
background: #202022;
box-shadow: 0 8px 24px #0003;
pointer-events: auto;
@@ -970,7 +974,7 @@
display: flex;
align-items: center;
width: 36px;
- height: 38px;
+ height: 36px;
padding: 0;
border: 0;
border-radius: 8px;
@@ -982,7 +986,7 @@
align-items: center;
justify-content: center;
width: 36px;
- height: 38px;
+ height: 36px;
flex-shrink: 0;
border-radius: 8px;
}
@@ -1052,7 +1056,7 @@
}
.cw-rail .cw-divider {
display: block;
- margin: 5px 4px 10px;
+ margin: 4px 2px;
}
@media (prefers-reduced-motion: reduce) {
.cw-rail-mask,
diff --git a/src/canvas/workspace/WorkspaceView.tsx b/src/canvas/workspace/WorkspaceView.tsx
index 1823580a..0bc6ad78 100644
--- a/src/canvas/workspace/WorkspaceView.tsx
+++ b/src/canvas/workspace/WorkspaceView.tsx
@@ -28,7 +28,7 @@ import { CanvasNotifications } from "../CanvasNotifications";
import { CanvasToolRail } from "../CanvasToolRail";
import { canStartReference, connectedCreationKinds } from "../connectionPolicy";
-import { REFERENCE_PORT_ICON_SIZE, REFERENCE_PORT_SIZE, referencePortIcon } from "../connectionPorts";
+import { REFERENCE_PORT_ICON_SIZE, REFERENCE_PORT_SIZE, referencePortDirection, referencePortIcon } from "../connectionPorts";
import { batchCreationKinds, SELECTION_PADDING, SELECTION_PORT_SIZE } from "../selectionReferences";
import "../multi-selection.css";
@@ -689,6 +689,9 @@ export function WorkspaceView({ rt }: { rt: WorkspaceRuntime }) {
}
data-reference-source={port.nodeId}
data-side={port.side}
+ data-direction={referencePortDirection(
+ doc.nodes.find((node) => node.id === port.nodeId),
+ )}
data-active={port.active}
style={{
left: port.point.x,
@@ -707,8 +710,9 @@ export function WorkspaceView({ rt }: { rt: WorkspaceRuntime }) {
node.id === port.nodeId)?.data
- .mediaRole === "asset",
+ referencePortDirection(
+ doc.nodes.find((node) => node.id === port.nodeId),
+ ),
)}
size={REFERENCE_PORT_ICON_SIZE}
/>
@@ -861,15 +865,17 @@ export function WorkspaceView({ rt }: { rt: WorkspaceRuntime }) {
onClick={() => togglePanel("history")}
/>
importInput.current?.click()}
/>
-
+
);
}
diff --git a/src/i18n/messages/en-US/models.ts b/src/i18n/messages/en-US/models.ts
index 087ab08b..92d4a7c6 100644
--- a/src/i18n/messages/en-US/models.ts
+++ b/src/i18n/messages/en-US/models.ts
@@ -2,7 +2,7 @@
export const models = {
"models.service.proxyMode": "Network connection mode",
"models.service.proxySystem": "Use system proxy",
- "models.service.proxyDirect": "Direct connection",
+ "models.service.proxyDirect": "Direct (no proxy)",
"models.service.proxyNote": "Applies to this model service's requests and result downloads. Use a direct connection if a proxy causes timeouts or disconnections.",
"models.readFailed": "Could not read model settings.",
"models.reload": "Reload",
diff --git a/src/i18n/messages/zh-CN/models.ts b/src/i18n/messages/zh-CN/models.ts
index 3ad028e8..9415e962 100644
--- a/src/i18n/messages/zh-CN/models.ts
+++ b/src/i18n/messages/zh-CN/models.ts
@@ -2,7 +2,7 @@
export const models = {
"models.service.proxyMode": "网络连接方式",
"models.service.proxySystem": "跟随系统代理",
- "models.service.proxyDirect": "直连",
+ "models.service.proxyDirect": "直连(不使用代理)",
"models.service.proxyNote": "仅影响此模型服务的请求与结果下载。代理导致超时或连接中断时,可切换为直连。",
"models.readFailed": "无法读取模型配置。",
"models.reload": "重新读取",
diff --git a/tests/browser/node_connections.mjs b/tests/browser/node_connections.mjs
index 385636ed..966595e1 100644
--- a/tests/browser/node_connections.mjs
+++ b/tests/browser/node_connections.mjs
@@ -55,6 +55,10 @@ const browser = await chromium.launch({
args: ["--no-sandbox"],
});
const page = await browser.newPage({ locale: "zh-CN", viewport: { width: 1600, height: 1000 } });
+// Skip the first-run model setup welcome; it covers the canvas.
+await page.addInitScript(() =>
+ localStorage.setItem("opendirector.modelSetup.v1", "dismissed"),
+);
page.setDefaultTimeout(12000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
@@ -199,6 +203,20 @@ try {
return port;
};
if (kind === "image" && targetKind === "subject") {
+ // An empty generation node only takes references, so its ports point inwards.
+ await target.hover();
+ const emptyTargetPorts = page.locator(
+ `.cw-node-reference-port[data-reference-source="${f.target}"]`,
+ );
+ await emptyTargetPorts.first().waitFor();
+ assert.deepEqual(
+ await emptyTargetPorts.evaluateAll((ports) =>
+ ports
+ .map((port) => `${port.dataset.side}:${port.dataset.direction}`)
+ .sort(),
+ ),
+ ["bottom:in", "left:in", "right:in", "top:in"],
+ );
for (const theme of ["light", "dark"]) {
await page.evaluate((theme) => {
document.documentElement.dataset.theme = theme;
@@ -219,6 +237,15 @@ try {
await nodePorts.filter({ has: page.locator("svg") }).count(),
4,
);
+ // Uploaded material only gives references, so every port points outwards.
+ assert.deepEqual(
+ await nodePorts.evaluateAll((ports) =>
+ ports
+ .map((port) => `${port.dataset.side}:${port.dataset.direction}`)
+ .sort(),
+ ),
+ ["bottom:out", "left:out", "right:out", "top:out"],
+ );
assert.equal(
await page
.locator('.cw-node-reference-port[data-active="true"]')
@@ -358,6 +385,21 @@ try {
count,
);
if (kind === "image" && targetKind === "subject") {
+ // Assembling the source into the subject gives it content, so the node now
+ // both accepts and provides references.
+ await target.hover();
+ const targetPorts = page.locator(
+ `.cw-node-reference-port[data-reference-source="${f.target}"]`,
+ );
+ await targetPorts.first().waitFor();
+ assert.deepEqual(
+ await targetPorts.evaluateAll((ports) =>
+ ports
+ .map((port) => `${port.dataset.side}:${port.dataset.direction}`)
+ .sort(),
+ ),
+ ["bottom:both", "left:both", "right:both", "top:both"],
+ );
const inbound = await showPort("left", target);
const from = await inbound.boundingBox();
const to = await source.boundingBox();
diff --git a/tests/nodeReferencePorts.test.ts b/tests/nodeReferencePorts.test.ts
index fc522697..474c390c 100644
--- a/tests/nodeReferencePorts.test.ts
+++ b/tests/nodeReferencePorts.test.ts
@@ -2,12 +2,15 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
floatingReferencePort,
+ referencePortDirection,
+ referencePortIcon,
referencePortMetrics,
restingReferencePort,
REFERENCE_PORT_ICON_SIZE,
REFERENCE_PORT_MAX_SIZE,
REFERENCE_PORT_SIZE,
} from "../src/canvas/connectionPorts.ts";
+import { createNode } from "../src/canvas/model.ts";
const bounds = { x: 100, y: 100, width: 300, height: 300 };
@@ -74,3 +77,61 @@ test("enlarged ports keep their node gutter while following the nearby pointer",
);
}
});
+
+test("port arrows follow what a node can actually give and take", () => {
+ const sides = ["top", "right", "bottom", "left"] as const;
+ const glyphs = (direction: "in" | "out" | "both") =>
+ sides.map((side) => referencePortIcon(side, direction));
+
+ const asset = createNode("image", undefined, {
+ src: "data:image/fixture",
+ mediaRole: "asset",
+ });
+ assert.equal(referencePortDirection(asset), "out");
+ assert.deepEqual(glyphs("out"), [
+ "arrowUp",
+ "arrowRight",
+ "arrowDown",
+ "arrowLeft",
+ ]);
+
+ // An empty generation node cannot be a source, so its ports point inwards.
+ for (const node of [
+ createNode("image"),
+ createNode("video"),
+ createNode("audio"),
+ createNode("text"),
+ createNode("subject"),
+ ]) {
+ assert.equal(referencePortDirection(node), "in", node.type);
+ assert.deepEqual(glyphs("in"), [
+ "arrowDown",
+ "arrowLeft",
+ "arrowUp",
+ "arrowRight",
+ ]);
+ }
+
+ // Content makes the same node both a source and a target.
+ for (const node of [
+ createNode("image", undefined, { src: "data:image/fixture" }),
+ createNode("text", undefined, { text: "written" }),
+ ]) {
+ assert.equal(referencePortDirection(node), "both", node.type);
+ assert.deepEqual(glyphs("both"), [
+ "referenceArrowsVertical",
+ "referenceArrowsHorizontal",
+ "referenceArrowsVertical",
+ "referenceArrowsHorizontal",
+ ]);
+ }
+
+ const missing = createNode("image", undefined, {
+ src: "data:image/fixture",
+ mediaRole: "generation",
+ assetState: "missing",
+ });
+ assert.equal(referencePortDirection(missing), "in");
+ // A port whose owner is no longer rendered keeps the two-direction glyph.
+ assert.equal(referencePortDirection(undefined), "both");
+});
From a9ebb6d5b1a276101a0d9577c00566f724987f6a Mon Sep 17 00:00:00 2001
From: XianglongZeng <1114802486@qq.com>
Date: Mon, 9 Nov 2026 10:58:16 +0800
Subject: [PATCH 3/4] [bugfix] tools: fix adoption, CDN errors and ports (#208,
#209)
## summary
- Restore hidden placeholders before assigning collision-free titles,
and treat non-ASCII digit suffixes as ordinary user text.
- Classify malformed CDN result URLs as download protocol failures
after accepted submissions, without sending another generation request.
- Hide reference ports for assets without usable content while retaining
inward ports for empty generation nodes and outward ports on recovery.
- Cover adoption replay, Unicode titles, URL failure stages and actual
browser asset-read failure/recovery with regression checks.
## validation
- 155 related Python tests and 678 frontend tests passed.
- Production build, generated contracts, bilingual catalogs and browser
locale/manifest checks passed.
- Nine isolated browser connection scenarios passed, including missing
asset ports and recovery. Models used service fixtures.
Refs: #208, #209
---
.../application/creation_tool_drafts.py | 6 +--
.../integrations/model_adapters.py | 9 ++--
src/canvas/connectionPolicy.ts | 7 ++-
tests/browser/node_connections.mjs | 46 +++++++++++++++++++
tests/nodeReferencePorts.test.ts | 34 ++++++++++++--
tests/test_creation_tool_drafts.py | 27 ++++++++++-
tests/test_openai_images.py | 15 ++++++
tests/test_tool_tasks.py | 6 ++-
8 files changed, 134 insertions(+), 16 deletions(-)
diff --git a/packages/core/src/opendirector/application/creation_tool_drafts.py b/packages/core/src/opendirector/application/creation_tool_drafts.py
index bbf4f10d..a8fcc38c 100644
--- a/packages/core/src/opendirector/application/creation_tool_drafts.py
+++ b/packages/core/src/opendirector/application/creation_tool_drafts.py
@@ -87,7 +87,7 @@ def freeze_inputs(service, project_id, value):
def _numbered(title):
head, separator, tail = title.rpartition(' ')
- return (head, int(tail)) if separator and tail.isdigit() and head else (title, None)
+ return (head, int(tail)) if separator and head and tail.isascii() and tail.isdigit() else (title, None)
def result_title(snapshot, base, used=(), exclude=None):
@@ -211,10 +211,10 @@ def expect(kind, obj):
if item:
editable(snapshot, item.id)
expect('canvas_item', item)
- if item.title != title:
- operations.append(dict(type='canvas.update', payload=dict(itemId=item.id, title=title)))
if not item.visible:
operations.append(dict(type='canvas.show', payload=dict(item=item.model_dump(mode='json'))))
+ if item.title != title:
+ operations.append(dict(type='canvas.update', payload=dict(itemId=item.id, title=title)))
operations.append(dict(type='canvas.materialize', payload=dict(itemId=item.id, asset=asset_ref(asset).model_dump(mode='json'), discardGenerationDraft=True)))
else:
width, height = media_card_size(asset.kind, asset.metadata.width, asset.metadata.height,
diff --git a/packages/core/src/opendirector/integrations/model_adapters.py b/packages/core/src/opendirector/integrations/model_adapters.py
index 3022347a..aeb06a1c 100644
--- a/packages/core/src/opendirector/integrations/model_adapters.py
+++ b/packages/core/src/opendirector/integrations/model_adapters.py
@@ -288,12 +288,11 @@ async def work():
return base64.b64decode(result['b64_json'], validate=True)
# Compatible APIs can return a CDN URL even when base64 was requested.
# Reuse the generation downloader, which sends no API key to the CDN.
- target, base = urlsplit(result['url']), urlsplit(config['url'])
- if (target.scheme, target.netloc) != (base.scheme, base.netloc):
- _, data = await VolcengineGeneration(None, transport=self.transport).resume_result(config, 'png', result['url'])
- return data
-
async def download():
+ target, base = urlsplit(result['url']), urlsplit(config['url'])
+ if (target.scheme, target.netloc) != (base.scheme, base.netloc):
+ _, data = await VolcengineGeneration(None, transport=self.transport).resume_result(config, 'png', result['url'])
+ return data
async with client.stream('GET', result['url'], headers=auth_headers(config)) as response:
return await bounded(response, stage='download', secrets=secrets)
return await _call('download', download)
diff --git a/src/canvas/connectionPolicy.ts b/src/canvas/connectionPolicy.ts
index 2ba3f977..4a2373b2 100644
--- a/src/canvas/connectionPolicy.ts
+++ b/src/canvas/connectionPolicy.ts
@@ -63,9 +63,12 @@ export function referenceMaterial(node: CanvasNode): string | null {
return null;
}
-/** Ports and creation menus are discoverable before the source has content. */
+/** Empty generation nodes keep their ports; assets need readable material. */
export function canStartReference(node: CanvasNode): boolean {
- return Boolean(nodeDownstreamKinds(node).length);
+ return (
+ Boolean(nodeDownstreamKinds(node).length) &&
+ (node.data.mediaRole !== "asset" || Boolean(referenceMaterial(node)))
+ );
}
export function canSourceReference(node: CanvasNode): boolean {
diff --git a/tests/browser/node_connections.mjs b/tests/browser/node_connections.mjs
index 966595e1..34ffef5e 100644
--- a/tests/browser/node_connections.mjs
+++ b/tests/browser/node_connections.mjs
@@ -447,6 +447,52 @@ try {
await page.reload();
await page.getByText("已保存到本机", { exact: true }).waitFor();
assert.equal(await page.locator(".react-flow__edge").count(), count);
+ if (kind === "image" && targetKind === "subject") {
+ const sourceAssetId = saved.canvasItems.find(
+ (item) => item.id === f.source,
+ ).binding.objectRef.assetId;
+ const unavailable = (url) =>
+ url.pathname ===
+ `/api/creation/projects/${f.id}/assets/${sourceAssetId}/content`;
+ await page.route(unavailable, (route) =>
+ route.fulfill({
+ status: 409,
+ contentType: "application/json",
+ body: JSON.stringify({
+ code: "storage_unavailable",
+ message: "Fixture asset is unavailable",
+ }),
+ }),
+ );
+ await page.reload();
+ await page.getByText("已保存到本机", { exact: true }).waitFor();
+ await source.locator(".cw-node-placeholder").waitFor();
+ await source.hover();
+ await page.waitForTimeout(100);
+ assert.equal(
+ await page
+ .locator(
+ `.cw-node-reference-port[data-reference-source="${f.source}"]`,
+ )
+ .count(),
+ 0,
+ );
+ await page.unroute(unavailable);
+ await page.reload();
+ await page.getByText("已保存到本机", { exact: true }).waitFor();
+ await source.locator("img").waitFor();
+ await source.hover();
+ const recoveredPorts = page.locator(
+ `.cw-node-reference-port[data-reference-source="${f.source}"]`,
+ );
+ await recoveredPorts.first().waitFor();
+ assert.deepEqual(
+ await recoveredPorts.evaluateAll((ports) =>
+ ports.map((port) => port.dataset.direction),
+ ),
+ ["out", "out", "out", "out"],
+ );
+ }
rows.push({
mode: "unified",
kind,
diff --git a/tests/nodeReferencePorts.test.ts b/tests/nodeReferencePorts.test.ts
index 474c390c..cdea3cc0 100644
--- a/tests/nodeReferencePorts.test.ts
+++ b/tests/nodeReferencePorts.test.ts
@@ -11,9 +11,38 @@ import {
REFERENCE_PORT_SIZE,
} from "../src/canvas/connectionPorts.ts";
import { createNode } from "../src/canvas/model.ts";
+import { canStartReference } from "../src/canvas/connectionPolicy.ts";
const bounds = { x: 100, y: 100, width: 300, height: 300 };
+test("asset ports require usable content while empty generation ports stay discoverable", () => {
+ for (const kind of ["image", "video", "audio"] as const) {
+ const asset = createNode(kind, undefined, {
+ mediaRole: "asset",
+ src: `data:${kind}/fixture`,
+ assetState: "ready",
+ });
+ assert.equal(canStartReference(asset), true, kind);
+ assert.equal(referencePortDirection(asset), "out", kind);
+ assert.equal(
+ canStartReference({
+ ...asset,
+ data: { ...asset.data, assetState: "missing" },
+ }),
+ false,
+ kind,
+ );
+ assert.equal(
+ canStartReference(createNode(kind, undefined, { mediaRole: "asset" })),
+ false,
+ kind,
+ );
+ const emptyGeneration = createNode(kind);
+ assert.equal(canStartReference(emptyGeneration), true, kind);
+ assert.equal(referencePortDirection(emptyGeneration), "in", kind);
+ }
+});
+
test("resting port keeps its screen size cap and a six pixel gap to the node", () => {
for (const zoom of [0.08, 0.6, 1, 1.5, 4, 8]) {
const metrics = referencePortMetrics(zoom);
@@ -25,9 +54,8 @@ test("resting port keeps its screen size cap and a six pixel gap to the node", (
);
// The drawn plus stays proportional to the circle at every zoom.
assert.ok(
- Math.abs(
- metrics.iconSize - REFERENCE_PORT_ICON_SIZE * metrics.scale,
- ) < 1e-8,
+ Math.abs(metrics.iconSize - REFERENCE_PORT_ICON_SIZE * metrics.scale) <
+ 1e-8,
);
assert.ok(REFERENCE_PORT_ICON_SIZE < REFERENCE_PORT_SIZE);
const resting = restingReferencePort(bounds, "right", zoom);
diff --git a/tests/test_creation_tool_drafts.py b/tests/test_creation_tool_drafts.py
index 8eb79d4b..af41b6c9 100644
--- a/tests/test_creation_tool_drafts.py
+++ b/tests/test_creation_tool_drafts.py
@@ -56,6 +56,20 @@ def snapshot(*titles):
assert result_title(snapshot('超清 4K'), '超清 4K') == '超清 4K 2'
+@pytest.mark.parametrize('suffix', ['²', '①', '٢', '2'])
+def test_result_titles_treat_unicode_digit_suffixes_as_ordinary_text(suffix):
+ from types import SimpleNamespace
+ from opendirector.application.creation_tool_drafts import result_title
+
+ title = f'Area m {suffix}'
+ snapshot = SimpleNamespace(canvasItems=[
+ SimpleNamespace(id=uuid4(), title=name, visible=True)
+ for name in ['Preset', f'Preset {suffix}', title]
+ ])
+ assert result_title(snapshot, 'Preset') == 'Preset 2'
+ assert result_title(snapshot, title) == f'{title} 2'
+
+
@pytest.fixture
def prepared(project, monkeypatch):
store, pid, session = project
@@ -151,7 +165,8 @@ def test_stale_or_forged_draft_cannot_accept_task(prepared, change):
assert service.list(str(pid)) == [] and seen == []
-def test_source_change_keeps_candidate_then_explicit_adoption_is_independent(prepared):
+@pytest.mark.parametrize('title_collision', [False, True])
+def test_source_change_keeps_candidate_then_explicit_adoption_is_independent(prepared, title_collision):
store, pid, session, service, value, source, _, _ = prepared
async def run():
try:
@@ -165,11 +180,19 @@ async def run():
current = store.snapshot(pid)
target = current.canvasItems[1]
assert not target.visible
- assert service.placement(str(pid), value['id'], 'placed', session, policy='explicit')['placement'] == 'placed'
+ if title_collision:
+ owner = current.canvasItems[0]
+ assert send(store, pid, 'canvas.update', session=session, versions=[ver('canvas_item', owner)],
+ itemId=owner.id, title=target.title).status == 'applied'
+ adopted = service.placement(str(pid), value['id'], 'placed', session, policy='explicit')
+ assert adopted['placement'] == 'placed'
current = store.snapshot(pid)
assert current.canvasItems[0].binding.objectRef.assetId == asset.id
assert current.canvasItems[1].visible
assert current.canvasItems[1].binding.objectRef.assetId != source.id
+ assert current.canvasItems[1].title == ('Pending 2' if title_collision else 'Pending')
+ assert service.placement(str(pid), value['id'], 'placed', session, policy='explicit') == adopted
+ assert store.snapshot(pid) == current
finally: await service.close()
asyncio.run(run())
diff --git a/tests/test_openai_images.py b/tests/test_openai_images.py
index d91c42bf..5e269592 100644
--- a/tests/test_openai_images.py
+++ b/tests/test_openai_images.py
@@ -20,6 +20,7 @@
from opendirector.domain.input_rules import input_rules
from opendirector.infrastructure.model_configs import ModelConfigs
from opendirector.domain.capabilities import ImageRequest
+from opendirector.domain.generation import GenerationError
from opendirector.integrations.model_adapters import OpenAIImages, adapter_for
from opendirector.integrations.providers import GenerationProviders
@@ -151,6 +152,20 @@ def handle(request):
CONFIG = {'kind': 'image', 'url': GPT_IMAGE['url'], 'modelId': 'gpt-image-1', 'apiKey': KEY, 'protocol': 'openai'}
+def test_malformed_cdn_url_is_a_download_protocol_error_after_accepted_submit():
+ seen = []
+
+ def handle(request):
+ seen.append(request.method)
+ return httpx.Response(200, json={'data': [{'url': 'https://[broken/result.png'}]})
+
+ adapter = OpenAIImages(httpx.MockTransport(handle))
+ with pytest.raises(GenerationError) as error:
+ asyncio.run(adapter.image(CONFIG, ImageRequest(prompt='edit', width=64, height=48, image=png())))
+ assert (error.value.details.stage, error.value.details.category) == ('download', 'protocol')
+ assert seen == ['POST']
+
+
def test_capability_adapter_keeps_the_gpt_image_request_shape():
adapter, seen = adapter_calls(adapter_for(GPT_IMAGE))
result = asyncio.run(adapter.image(CONFIG, ImageRequest(prompt='灯塔', width=1536, height=1024)))
diff --git a/tests/test_tool_tasks.py b/tests/test_tool_tasks.py
index 0a81efc6..42dfe1eb 100644
--- a/tests/test_tool_tasks.py
+++ b/tests/test_tool_tasks.py
@@ -204,7 +204,7 @@ def test_inpainting_only_changes_the_masked_region(project):
assert image.getpixel((5, 5)) == (0, 0, 255) and image.getpixel((60, 60))[0] > 200
-@pytest.mark.parametrize('cdn_result', ['image', 'timeout', 'invalid_url'])
+@pytest.mark.parametrize('cdn_result', ['image', 'timeout', 'invalid_url', 'malformed_url'])
def test_smart_edit_accepts_cdn_results_without_forwarding_the_service_key(project, cdn_result):
store, project_id, session = project
configs = ModelConfigs(store.local.repository)
@@ -219,6 +219,8 @@ def handle(request):
assert request.headers['authorization'] == 'Bearer service-secret'
assert b'b64_json' in request.read()
url = 'file:///private.png' if cdn_result == 'invalid_url' else 'https://cdn.test/result.png'
+ if cdn_result == 'malformed_url':
+ url = 'https://[broken/result.png'
return httpx.Response(200, json={'data': [{'url': url}]})
assert request.url.host == 'cdn.test'
assert 'authorization' not in request.headers
@@ -239,6 +241,8 @@ def handle(request):
assert task['status'] == 'failed'
assert '提交结果不明' not in task['error']
assert ('连接超时' if cdn_result == 'timeout' else '接口协议') in task['error']
+ if cdn_result in {'invalid_url', 'malformed_url'}:
+ assert calls == ['POST']
def test_presets_return_one_output_per_shot_and_local_hd_needs_no_model(project):
From b6625d6e8f9b3bcb9c161068442904fe9441d59b Mon Sep 17 00:00:00 2001
From: XianglongZeng <1114802486@qq.com>
Date: Mon, 9 Nov 2026 15:21:25 +0800
Subject: [PATCH 4/4] [bugfix] tools: constrain CDN downloads and code errors
(#208)
## summary
- Restrict unconfigured compatible image result hosts to public IPv4
and IPv6 addresses. Check every DNS answer, pin the connection while
preserving Host and TLS identity, and validate each redirect hop.
- Apply the same download policy to compatible generation recovery,
retaining authentication only for the configured service origin.
- Persist stable matting error codes and subject parameters so Chinese
and English UI messages survive changes to backend wording.
- Add network destination and localization regressions, plus a lesson
about the separate trust boundary for provider result URLs.
## validation
- Relevant Python suites: 203 passed, including 33 network safety cases.
- npm test: 688 passed; npm run build passed.
- Bilingual catalogs, browser locale/manifest, and contracts passed.
- Browser history renders both failures and switches languages without
reloading, using simulated service receipts.
Refs: #208
---
apps/api/src/opendirector_api/localization.py | 2 +
docs/development/lessons.md | 6 +
.../opendirector/application/tool_context.py | 12 +-
.../opendirector/application/tool_tasks.py | 4 +-
.../application/tool_video_recipes.py | 6 +-
.../integrations/model_adapters.py | 3 +-
.../opendirector/integrations/providers.py | 10 +-
.../opendirector/integrations/volcengine.py | 59 ++++++-
src/backend/tools.ts | 9 +-
src/canvas/toolTaskText.ts | 13 ++
tests/conftest.py | 14 ++
tests/test_localization.py | 9 +
tests/test_result_download_security.py | 157 ++++++++++++++++++
tests/test_self_hosted.py | 2 +-
tests/test_self_hosted_proxy.py | 2 +-
tests/test_tool_tasks.py | 6 +-
tests/toolTasks.test.ts | 23 +++
17 files changed, 320 insertions(+), 17 deletions(-)
create mode 100644 tests/test_result_download_security.py
diff --git a/apps/api/src/opendirector_api/localization.py b/apps/api/src/opendirector_api/localization.py
index e4d1466e..d60daa19 100644
--- a/apps/api/src/opendirector_api/localization.py
+++ b/apps/api/src/opendirector_api/localization.py
@@ -9,6 +9,8 @@
from opendirector.domain.creation.commands import CommandReceipt
ENGLISH_BY_CODE = {
+ 'tool_concept_translation': 'Use a short English subject description, or add a chat model in Models to translate it automatically.',
+ 'tool_no_video_match': 'The requested subject was not found in the video. Try a different description.',
'voice_upload_type': 'Upload the voice recording using multipart/form-data.',
'voice_upload_form': 'Check the recording, form fields and text lengths, then try the upload again.',
'voice_model_unsupported': 'This checkpoint does not accept uploaded voices. Choose a Base model for voice cloning or a compatible built-in voice.',
diff --git a/docs/development/lessons.md b/docs/development/lessons.md
index 6b62f4f0..a89af92a 100644
--- a/docs/development/lessons.md
+++ b/docs/development/lessons.md
@@ -23,3 +23,9 @@ When work exposes a consequential risk or a recurring problem, add a lesson or u
- **Trigger:** A relevant test fails and comparison shows that it also fails on the baseline.
- **Lesson:** Commits `28d61dce` and `0a5630ac` each recorded the same generation test failing on the baseline. That historical record does not establish whether the test still fails today.
- **Check:** Record the versions and results used in the comparison. Link an existing issue or create one when appropriate. Report the check as failed; a baseline failure is not a pass.
+
+## Provider result URLs are an independent trust boundary
+
+- **Trigger:** Accepting a cross-origin result URL from a configured model service.
+- **Lesson:** [PR #210](https://github.com/ThinkFlowLab/OpenDirector/pull/210) added compatible image CDN downloads without provider credentials, but initially left arbitrary local and private hosts reachable. Credential isolation and bounded responses do not constrain the network destination.
+- **Check:** Preserve explicitly configured local service origins; validate all DNS answers for unconfigured hosts, connect to a checked public IP while retaining the original Host and TLS identity, and revalidate every redirect. Cover local metadata addresses, IPv6, mixed DNS answers, redirect loops, and recovery without another model submission.
diff --git a/packages/core/src/opendirector/application/tool_context.py b/packages/core/src/opendirector/application/tool_context.py
index 2bdab6a2..592c9da6 100644
--- a/packages/core/src/opendirector/application/tool_context.py
+++ b/packages/core/src/opendirector/application/tool_context.py
@@ -31,6 +31,14 @@ class Output:
suffix: str
title: str
+
+class ToolInputError(ProjectError):
+ """A tool rejection with stable localization identity and interpolation data."""
+
+ def __init__(self, message, *, code, label):
+ super().__init__(message, 422, code=code)
+ self.params = {'label': label}
+
class ToolContext:
"""What a recipe may use: inputs, model capabilities and progress reporting."""
@@ -121,7 +129,9 @@ async def concepts(self, text: str, default: tuple[str, ...]) -> list[tuple[str,
except ProjectError:
english = None
if not english or not english.isascii():
- raise ProjectError(f'无法识别「{part}」,请改用英文描述,或在“模型接入”中添加对话模型以自动翻译。', 422)
+ raise ToolInputError(
+ f'无法识别「{part}」,请改用英文描述,或在“模型接入”中添加对话模型以自动翻译。',
+ code='tool_concept_translation', label=part)
pairs.append((english, part))
return pairs
diff --git a/packages/core/src/opendirector/application/tool_tasks.py b/packages/core/src/opendirector/application/tool_tasks.py
index cdec8f7d..4194d41d 100644
--- a/packages/core/src/opendirector/application/tool_tasks.py
+++ b/packages/core/src/opendirector/application/tool_tasks.py
@@ -443,7 +443,9 @@ async def preserve():
if current.get('cancelRequested'):
await self._stopped(task['projectId'], task['id'])
else:
- await self._update(directory, task, status='failed', localState='stopped', phase=None, error=str(failure), finishedAt=_now())
+ await self._update(directory, task, status='failed', localState='stopped', phase=None,
+ error=str(failure), errorDetails={'code': failure.code, 'params': getattr(failure, 'params', {})},
+ finishedAt=_now())
except Exception: # noqa: BLE001 — a recipe bug must still end the task visibly.
current = self.get(task['projectId'], task['id'])
if current.get('cancelRequested') or self.closing:
diff --git a/packages/core/src/opendirector/application/tool_video_recipes.py b/packages/core/src/opendirector/application/tool_video_recipes.py
index 81bc21eb..1e97bd9d 100644
--- a/packages/core/src/opendirector/application/tool_video_recipes.py
+++ b/packages/core/src/opendirector/application/tool_video_recipes.py
@@ -3,7 +3,7 @@
from PIL import Image, ImageChops, ImageFilter
-from opendirector.application.tool_context import Output, Source, ToolContext
+from opendirector.application.tool_context import Output, Source, ToolContext, ToolInputError
from opendirector.domain.capabilities import EditMask, Tracks
from opendirector.domain.projects import ProjectError
from opendirector.media import processing as media
@@ -111,7 +111,9 @@ async def video_matting(ctx: ToolContext):
await ctx.progress(f"正在识别并跟踪「{'、'.join(label for _, label in pairs)}」")
tracks = await ctx.track(clip.read_bytes(), tuple(concept for concept, _ in pairs))
if not any(tracks.frames):
- raise ProjectError(f"视频中没有识别到「{'、'.join(label for _, label in pairs)}」,请换一种描述后重试。", 422)
+ label = '、'.join(label for _, label in pairs)
+ raise ToolInputError(f'视频中没有识别到「{label}」,请换一种描述后重试。',
+ code='tool_no_video_match', label=label)
await ctx.progress('正在合成抠像视频')
await blocking(_write_mattes, tracks, work.path / 'mattes')
data, suffix = await blocking(media.matte, clip, str(work.path / 'mattes' / '%05d.png'), info['fps'],
diff --git a/packages/core/src/opendirector/integrations/model_adapters.py b/packages/core/src/opendirector/integrations/model_adapters.py
index aeb06a1c..a95d4ba2 100644
--- a/packages/core/src/opendirector/integrations/model_adapters.py
+++ b/packages/core/src/opendirector/integrations/model_adapters.py
@@ -291,7 +291,8 @@ async def work():
async def download():
target, base = urlsplit(result['url']), urlsplit(config['url'])
if (target.scheme, target.netloc) != (base.scheme, base.netloc):
- _, data = await VolcengineGeneration(None, transport=self.transport).resume_result(config, 'png', result['url'])
+ _, data = await VolcengineGeneration(None, transport=self.transport).resume_result(
+ config, 'png', result['url'], public_only=True)
return data
async with client.stream('GET', result['url'], headers=auth_headers(config)) as response:
return await bounded(response, stage='download', secrets=secrets)
diff --git a/packages/core/src/opendirector/integrations/providers.py b/packages/core/src/opendirector/integrations/providers.py
index 993ed5ae..63e17af0 100644
--- a/packages/core/src/opendirector/integrations/providers.py
+++ b/packages/core/src/opendirector/integrations/providers.py
@@ -88,16 +88,20 @@ async def resume_result(self, config, suffix, url):
if not config or config.get('protocol') in {None, 'volcengine'}:
return await super().resume_result(config, suffix, url)
# Only send a service token back to that exact configured origin.
- base, target = urlsplit(config['url']), urlsplit(url)
- headers = auth_headers(config) if (base.scheme, base.netloc) == (target.scheme, target.netloc) else {}
try:
+ base, target = urlsplit(config['url']), urlsplit(url)
+ same_origin = (base.scheme, base.netloc) == (target.scheme, target.netloc)
async with httpx.AsyncClient(event_hooks=MODEL_HTTP_HOOKS, transport=self.transport, trust_env=trust_environment(url, config=config), timeout=httpx.Timeout(240, connect=15)) as client:
- async with client.stream('GET', url, headers=headers, follow_redirects=False) as response:
+ if not same_origin:
+ return suffix, await self._download(client, url, secrets=(config.get('apiKey', ''),), public_only=True)
+ async with client.stream('GET', url, headers=auth_headers(config), follow_redirects=False) as response:
return suffix, await bounded(response, stage='download', secrets=(config.get('apiKey', ''),))
except httpx.TimeoutException:
raise transport_failure('download', timeout=True) from None
except httpx.HTTPError:
raise transport_failure('download') from None
+ except (httpx.InvalidURL, ValueError, TypeError, KeyError, AttributeError):
+ raise protocol_failure('download') from None
async def generate(self, config, task, submitted, *, result_ready=None):
adapter = self._profile_adapter(config)
diff --git a/packages/core/src/opendirector/integrations/volcengine.py b/packages/core/src/opendirector/integrations/volcengine.py
index 640530c5..a7db78b4 100644
--- a/packages/core/src/opendirector/integrations/volcengine.py
+++ b/packages/core/src/opendirector/integrations/volcengine.py
@@ -3,6 +3,8 @@
import asyncio
import base64
import json
+import socket
+from ipaddress import ip_address
from urllib.parse import quote, urlsplit
from uuid import uuid4
@@ -33,6 +35,38 @@ async def bounded(response, *, stage, secrets=(), limit=MAX_ASSET_BYTES):
return bytes(data)
+async def _public_result_url(url):
+ """Pin an unconfigured download to checked public DNS answers, retaining TLS identity."""
+ try:
+ target = httpx.URL(url)
+ except httpx.InvalidURL:
+ raise protocol_failure('download') from None
+ host = target.host.rstrip('.').lower()
+ if (target.scheme not in {'http', 'https'} or not host or target.userinfo
+ or '%' in host or host == 'localhost' or host.endswith('.localhost')):
+ raise protocol_failure('download')
+ try:
+ addresses = [ip_address(host)]
+ except ValueError:
+ try:
+ answers = await asyncio.wait_for(asyncio.to_thread(
+ socket.getaddrinfo, host, target.port or (443 if target.scheme == 'https' else 80),
+ type=socket.SOCK_STREAM), timeout=15)
+ except (OSError, asyncio.TimeoutError):
+ raise transport_failure('download') from None
+ addresses = [ip_address(answer[4][0]) for answer in answers]
+ for address in addresses:
+ checked_address = getattr(address, 'ipv4_mapped', None) or address
+ if (not checked_address.is_global or checked_address.is_multicast
+ or checked_address.is_reserved):
+ raise protocol_failure('download')
+ if not addresses:
+ raise protocol_failure('download')
+ # A pooled connection to one IP must not reuse another hostname's TLS identity.
+ headers = {'Host': target.netloc.decode('ascii'), 'Connection': 'close'}
+ return target.copy_with(host=str(addresses[0])), headers, {'sni_hostname': target.host}
+
+
class VolcengineGeneration:
def __init__(self, store, transport=None, poll_interval=3, retry_delays=(0.5, 1.0)):
self.store, self.transport, self.poll_interval = store, transport, poll_interval
@@ -52,15 +86,17 @@ async def _retry_read(self, operation):
raise
await asyncio.sleep(self.retry_delays[attempt])
- async def resume_result(self, config, suffix, url):
+ async def resume_result(self, config, suffix, url, *, public_only=False):
secrets = (config['apiKey'],) if config else ()
try:
async with httpx.AsyncClient(event_hooks=MODEL_HTTP_HOOKS, transport=self.transport, trust_env=trust_environment(url, config=config), timeout=httpx.Timeout(240, connect=15)) as client:
- return suffix, await self._download(client, url, secrets=secrets)
+ return suffix, await self._download(client, url, secrets=secrets, public_only=public_only)
except httpx.TimeoutException:
raise transport_failure('download', timeout=True) from None
except httpx.HTTPError:
raise transport_failure('download') from None
+ except (httpx.InvalidURL, ValueError, TypeError):
+ raise protocol_failure('download') from None
def validate(self, kind, config):
if kind in {"video", "audio"}:
@@ -207,12 +243,29 @@ async def query():
except (ValueError, TypeError, KeyError, IndexError, AttributeError):
raise protocol_failure(stage) from None
- async def _download(self, client, url, *, secrets=()):
+ async def _download(self, client, url, *, secrets=(), public_only=False):
parts = urlsplit(url)
if parts.scheme not in {"http", "https"} or not parts.hostname or parts.username or parts.password:
raise protocol_failure('download')
# CDN downloads never receive provider credentials.
async def download():
+ if public_only:
+ current = httpx.URL(url)
+ for _ in range(6):
+ pinned, headers, extensions = await _public_result_url(current)
+ async with client.stream('GET', pinned, headers=headers, extensions=extensions,
+ follow_redirects=False) as response:
+ if response.is_redirect:
+ location = response.headers.get('location')
+ if not location:
+ raise protocol_failure('download')
+ try:
+ current = current.join(location)
+ except httpx.InvalidURL:
+ raise protocol_failure('download') from None
+ continue
+ return await bounded(response, stage='download', secrets=secrets)
+ raise protocol_failure('download')
async with client.stream("GET", url, follow_redirects=True) as response:
return await bounded(response, stage='download', secrets=secrets)
return await self._retry_read(download)
diff --git a/src/backend/tools.ts b/src/backend/tools.ts
index d6c51249..940157e3 100644
--- a/src/backend/tools.ts
+++ b/src/backend/tools.ts
@@ -10,7 +10,11 @@ import type {
import { CANVAS_TOOLS } from "../canvas/toolCatalog.ts";
import { ApiError, errorMessage, json, request } from "./api.ts";
import { creationClient } from "./creationClient.ts";
-import { toolTaskError, toolTaskPhase } from "../canvas/toolTaskText.ts";
+import {
+ toolTaskError,
+ toolTaskPhase,
+ type ToolTaskFailure,
+} from "../canvas/toolTaskText.ts";
import { toolReasonText } from "../canvas/toolReasonText.ts";
import { tNow } from "../i18n/messages.ts";
import {
@@ -66,6 +70,7 @@ export interface ToolTask {
| "interrupted";
phase: string | null;
error: string | null;
+ errorDetails?: ToolTaskFailure;
outputs: ToolTaskOutput[];
createdAt?: string;
finishedAt?: string | null;
@@ -130,7 +135,7 @@ export function toolTaskMessage(task: ToolTask): string | undefined {
: tNow("errors.tools.taskMessage.interruptedEnded");
return tNow("errors.tools.taskMessage.interrupted", { detail });
}
- return toolTaskError(task.error);
+ return toolTaskError(task.error, undefined, task.errorDetails);
}
export interface ToolCanvasOrigin {
diff --git a/src/canvas/toolTaskText.ts b/src/canvas/toolTaskText.ts
index 992a6e78..9f7eeafd 100644
--- a/src/canvas/toolTaskText.ts
+++ b/src/canvas/toolTaskText.ts
@@ -7,6 +7,16 @@ import { translateToolText } from "./toolText.ts";
const CJK = /[\u3400-\u9fff]/;
+export interface ToolTaskFailure {
+ code: string;
+ params?: MessageParams;
+}
+
+const CODE: Record = {
+ tool_concept_translation: "errors.tools.error.conceptTranslation",
+ tool_no_video_match: "errors.tools.error.noVideoMatch",
+};
+
const PHASE: Record = {
准备素材: "errors.tools.phase.prepare",
停止处理中: "errors.tools.phase.stopping",
@@ -113,7 +123,10 @@ export function toolTaskPhase(
export function toolTaskError(
text: string | null | undefined,
t: Translate = defaultTranslate,
+ failure?: ToolTaskFailure,
): string | undefined {
+ const key = failure && CODE[failure.code];
+ if (key) return t(key, failure?.params);
if (!text) return undefined;
if (!CJK.test(text)) return text;
if (text.startsWith("生成请求的提交结果不明。"))
diff --git a/tests/conftest.py b/tests/conftest.py
index a54e6754..af1661df 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -5,12 +5,26 @@
explains why a case was skipped.
"""
import pytest
+import socket
from opendirector.wiring import configure_runtime
configure_runtime()
+@pytest.fixture
+def public_cdn(monkeypatch):
+ """Mocked downloads still validate DNS, without depending on public resolvers."""
+ lookup = socket.getaddrinfo
+
+ def resolve(host, port, *args, **kwargs):
+ if host in {'cdn.test', 'cdn.example'}:
+ return [(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('93.184.216.34', port))]
+ return lookup(host, port, *args, **kwargs)
+
+ monkeypatch.setattr(socket, 'getaddrinfo', resolve)
+
+
def pytest_configure(config):
config.addinivalue_line('markers', 'requires_ffmpeg: needs a local FFmpeg and ffprobe')
config.addinivalue_line('markers', 'requires_posix: needs POSIX filesystem semantics (symlinks, shebang)')
diff --git a/tests/test_localization.py b/tests/test_localization.py
index 2492f745..121f8c53 100644
--- a/tests/test_localization.py
+++ b/tests/test_localization.py
@@ -19,6 +19,15 @@ def test_accept_language_uses_the_primary_language() -> None:
assert wants_english(request("")) is False
+def test_matting_error_codes_remain_localized_when_backend_copy_changes() -> None:
+ for code, expected in [
+ ('tool_concept_translation', 'short English subject description'),
+ ('tool_no_video_match', 'not found in the video'),
+ ]:
+ assert expected in localized_error(request('en-US'), chinese='更新后的中文', status=422, code=code)
+ assert localized_error(request('zh-CN'), chinese='更新后的中文', status=422, code=code) == '更新后的中文'
+
+
def test_chinese_and_english_messages_do_not_mix() -> None:
assert (
localized_error(
diff --git a/tests/test_result_download_security.py b/tests/test_result_download_security.py
new file mode 100644
index 00000000..72e42da1
--- /dev/null
+++ b/tests/test_result_download_security.py
@@ -0,0 +1,157 @@
+"""Unconfigured result hosts cannot turn a completed image call into SSRF."""
+import asyncio
+import socket
+
+import httpx
+import pytest
+
+from opendirector.domain.capabilities import ImageRequest
+from opendirector.domain.generation import GenerationError
+from opendirector.integrations.model_adapters import OpenAIImages
+from opendirector.integrations.providers import GenerationProviders
+from test_openai_images import CONFIG, png
+
+
+@pytest.fixture
+def resolve(monkeypatch):
+ calls = []
+ addresses = ['93.184.216.34']
+
+ def lookup(host, port, **kwargs):
+ calls.append(host)
+ return [(socket.AF_INET6 if ':' in address else socket.AF_INET,
+ socket.SOCK_STREAM, 6, '', (address, port)) for address in addresses]
+
+ monkeypatch.setattr(socket, 'getaddrinfo', lookup)
+ return addresses, calls
+
+
+def image_from(url, responder=None):
+ calls = []
+
+ def handle(request):
+ calls.append(request)
+ if request.method == 'POST':
+ return httpx.Response(200, json={'data': [{'url': url}]})
+ return responder(request) if responder else httpx.Response(200, content=png())
+
+ adapter = OpenAIImages(httpx.MockTransport(handle))
+ return adapter, calls
+
+
+def generate(adapter, config=CONFIG):
+ return asyncio.run(adapter.image(config, ImageRequest(prompt='edit', width=64, height=48)))
+
+
+@pytest.mark.parametrize('url', [
+ 'http://127.0.0.1:8019/private', 'http://127.1/private',
+ 'http://localhost:8019/private', 'http://LOCALHOST./private',
+ 'http://10.0.0.1/private', 'http://172.16.0.1/private',
+ 'http://192.168.1.1/private', 'http://169.254.169.254/latest/meta-data',
+ 'http://100.64.0.1/private', 'http://0.0.0.0/private',
+ 'http://224.0.0.1/private', 'http://[::1]/private',
+ 'http://[fc00::1]/private', 'http://[fe80::1]/private',
+ 'http://[::ffff:127.0.0.1]/private', 'file:///private.png',
+ 'https://user:password@cdn.test/private',
+ 'https://cdn.test:bad/result.png',
+])
+def test_unconfigured_local_result_is_rejected_before_any_get(url, resolve):
+ # Noncanonical numeric hosts and local aliases must be checked after DNS too.
+ resolve[0][:] = ['127.0.0.1']
+ adapter, calls = image_from(url)
+ with pytest.raises(GenerationError) as error:
+ generate(adapter)
+ assert (error.value.details.stage, error.value.details.category) == ('download', 'protocol')
+ assert [call.method for call in calls] == ['POST']
+
+
+@pytest.mark.parametrize('addresses', [
+ ['127.0.0.1'], ['169.254.169.254'], ['::1'], ['::ffff:192.168.1.1'],
+ ['93.184.216.34', '10.0.0.1'], [],
+])
+def test_dns_names_and_mixed_answers_cannot_hide_private_addresses(resolve, addresses):
+ resolve[0][:] = addresses
+ adapter, calls = image_from('https://cdn.test/result.png')
+ with pytest.raises(GenerationError):
+ generate(adapter)
+ assert len(calls) == 1
+
+
+def test_public_download_pins_checked_ip_and_preserves_host_tls_and_signed_path(resolve):
+ adapter, calls = image_from('https://cdn.test/result.png?signature=keep%2Bme')
+ assert generate(adapter).data == png()
+ post, download = calls
+ assert post.headers['authorization'] == f"Bearer {CONFIG['apiKey']}"
+ assert str(download.url) == 'https://93.184.216.34/result.png?signature=keep%2Bme'
+ assert download.headers['host'] == 'cdn.test'
+ assert download.extensions['sni_hostname'] == 'cdn.test'
+ assert 'authorization' not in download.headers
+ assert resolve[1] == ['cdn.test'] # No second, unchecked DNS lookup to connect.
+
+
+def test_public_ipv6_dns_answer_is_pinned(resolve):
+ resolve[0][:] = ['2606:4700:4700::1111']
+ adapter, calls = image_from('https://cdn.test/result.png')
+ assert generate(adapter).data == png()
+ assert calls[1].url.host == '2606:4700:4700::1111'
+ assert calls[1].headers['host'] == 'cdn.test'
+
+
+def test_failed_dns_cannot_fall_back_to_an_unchecked_request(monkeypatch):
+ def failed(*args, **kwargs):
+ raise socket.gaierror('resolver unavailable')
+
+ monkeypatch.setattr(socket, 'getaddrinfo', failed)
+ adapter, calls = image_from('https://cdn.test/result.png')
+ with pytest.raises(GenerationError) as error:
+ generate(adapter)
+ assert error.value.details.stage == 'download'
+ assert len(calls) == 1
+
+
+@pytest.mark.parametrize('location', [
+ 'http://127.0.0.1:8019/private', 'http://[::1]/private', 'https://[broken/result.png',
+])
+def test_public_redirect_cannot_reach_a_local_service(resolve, location):
+ adapter, calls = image_from('https://cdn.test/start',
+ lambda _: httpx.Response(302, headers={'Location': location}))
+ with pytest.raises(GenerationError) as error:
+ generate(adapter)
+ assert error.value.details.stage == 'download'
+ assert [call.method for call in calls] == ['POST', 'GET']
+
+
+def test_relative_public_redirects_are_revalidated_and_have_a_hop_limit(resolve):
+ def redirected(request):
+ return (httpx.Response(302, headers={'Location': '../final.png'})
+ if request.url.path == '/dir/start' else httpx.Response(200, content=png()))
+
+ adapter, calls = image_from('https://cdn.test/dir/start', redirected)
+ assert generate(adapter).data == png()
+ assert [call.url.path for call in calls[1:]] == ['/dir/start', '/final.png']
+ assert resolve[1] == ['cdn.test', 'cdn.test']
+ adapter, calls = image_from('https://cdn.test/loop',
+ lambda _: httpx.Response(302, headers={'Location': '/loop'}))
+ with pytest.raises(GenerationError):
+ generate(adapter)
+ assert len(calls) <= 7
+
+
+def test_explicit_local_origin_keeps_auth_but_a_different_port_is_untrusted(resolve):
+ config = {**CONFIG, 'url': 'http://127.0.0.1:9000/v1'}
+ adapter, calls = image_from('http://127.0.0.1:9000/result.png')
+ assert generate(adapter, config).data == png()
+ assert calls[1].headers['authorization'] == f"Bearer {CONFIG['apiKey']}"
+ adapter, calls = image_from('http://127.0.0.1:8019/private')
+ with pytest.raises(GenerationError):
+ generate(adapter, config)
+ assert len(calls) == 1
+
+
+def test_compatible_generation_recovery_uses_the_same_public_host_policy(resolve):
+ calls = []
+ provider = GenerationProviders(None, httpx.MockTransport(
+ lambda request: calls.append(request) or httpx.Response(200, content=png())))
+ with pytest.raises(GenerationError):
+ asyncio.run(provider.resume_result(CONFIG, 'png', 'http://127.0.0.1:8019/private'))
+ assert calls == []
diff --git a/tests/test_self_hosted.py b/tests/test_self_hosted.py
index 48838a97..2e69a56a 100644
--- a/tests/test_self_hosted.py
+++ b/tests/test_self_hosted.py
@@ -148,7 +148,7 @@ def test_fasth3_480p_follows_reference_orientation(tmp_path, dimensions, expecte
@pytest.mark.parametrize('origin,expected_auth', [('http://localhost:9999','Bearer private-token'),('https://cdn.example',None)])
-def test_self_hosted_image_result_auth_stays_on_configured_origin(tmp_path, origin, expected_auth):
+def test_self_hosted_image_result_auth_stays_on_configured_origin(tmp_path, origin, expected_auth, public_cdn):
store=LocalProjects(tmp_path)
def handle(request):
if request.method=='POST':
diff --git a/tests/test_self_hosted_proxy.py b/tests/test_self_hosted_proxy.py
index b07fbc03..dfd99fd3 100644
--- a/tests/test_self_hosted_proxy.py
+++ b/tests/test_self_hosted_proxy.py
@@ -116,7 +116,7 @@ async def submitted(value): accepted.append(value)
assert flags and all(flag is False for flag in flags)
-def test_external_result_cdn_keeps_proxy_and_never_receives_service_token(monkeypatch):
+def test_external_result_cdn_keeps_proxy_and_never_receives_service_token(monkeypatch, public_cdn):
async_client = httpx.AsyncClient
flags = []
def client(**kwargs):
diff --git a/tests/test_tool_tasks.py b/tests/test_tool_tasks.py
index 42dfe1eb..b45d25c7 100644
--- a/tests/test_tool_tasks.py
+++ b/tests/test_tool_tasks.py
@@ -205,7 +205,7 @@ def test_inpainting_only_changes_the_masked_region(project):
@pytest.mark.parametrize('cdn_result', ['image', 'timeout', 'invalid_url', 'malformed_url'])
-def test_smart_edit_accepts_cdn_results_without_forwarding_the_service_key(project, cdn_result):
+def test_smart_edit_accepts_cdn_results_without_forwarding_the_service_key(project, cdn_result, public_cdn):
store, project_id, session = project
configs = ModelConfigs(store.local.repository)
configs.save('image', 'https://images.test/v1', 'seedream-5.0-pro', 'service-secret', protocol='openai')
@@ -222,7 +222,7 @@ def handle(request):
if cdn_result == 'malformed_url':
url = 'https://[broken/result.png'
return httpx.Response(200, json={'data': [{'url': url}]})
- assert request.url.host == 'cdn.test'
+ assert request.url.host == '93.184.216.34' and request.headers['host'] == 'cdn.test'
assert 'authorization' not in request.headers
if cdn_result == 'timeout':
raise httpx.ReadTimeout('download interrupted', request=request)
@@ -593,6 +593,7 @@ def test_layer_decompose_by_boxes_and_description_without_an_editor(project):
task = run(store, configs, transport, project_id, session,
{'tool': 'layer-decompose', 'sourceAssetIds': [source], 'parameters': {'mode': 'description', 'prompt': '吉他'}})
assert task['status'] == 'failed' and '吉他' in task['error']
+ assert task['errorDetails'] == {'code': 'tool_concept_translation', 'params': {'label': '吉他'}}
def test_selection_preview_returns_alpha_masks_without_output_assets(project):
@@ -810,6 +811,7 @@ def test_video_matting_keeps_the_tracked_subject_over_the_chosen_background(proj
task = run(store, configs, httpx.MockTransport(tracking_server(log, empty=True)), project_id, session,
{'tool': 'video-matting', 'sourceAssetIds': [source], 'parameters': {'background': 'black'}})
assert task['status'] == 'failed' and '没有识别到「人物」' in task['error']
+ assert task['errorDetails'] == {'code': 'tool_no_video_match', 'params': {'label': '人物'}}
def configure_depth(store):
diff --git a/tests/toolTasks.test.ts b/tests/toolTasks.test.ts
index cba11731..d692d313 100644
--- a/tests/toolTasks.test.ts
+++ b/tests/toolTasks.test.ts
@@ -8,6 +8,29 @@ import {
type ToolTask,
} from "../src/backend/tools.ts";
import type { ProjectSnapshot } from "../src/contracts/generated.ts";
+import { setActiveLocale } from "../src/i18n/messages.ts";
+
+test("structured matting failures survive changed backend wording in both locales", () => {
+ const task: ToolTask = {
+ id: "task", tool: "video-matting", status: "failed", outputs: [],
+ phase: null, error: "Backend wording has changed entirely.",
+ };
+ try {
+ for (const locale of ["zh-CN", "en-US"] as const) {
+ setActiveLocale(locale);
+ for (const [code, expected] of [
+ ["tool_concept_translation", locale === "zh-CN" ? "改用简短的英文描述" : "short English description"],
+ ["tool_no_video_match", locale === "zh-CN" ? "视频中没有识别到" : "Could not find"],
+ ]) {
+ const message = toolTaskMessage({ ...task, errorDetails: { code, params: { label: "三花猫" } } })!;
+ assert.ok(message.includes(expected));
+ assert.ok(message.includes("三花猫"));
+ }
+ }
+ } finally {
+ setActiveLocale("zh-CN");
+ }
+});
test("tool cancellation messages distinguish stopping, remote acknowledgement and historical uncertainty", () => {
const task: ToolTask = {