From c23a819f27d09c8b9288159162be9dbd8b460e32 Mon Sep 17 00:00:00 2001
From: HouYunFei <1844025705@qq.com>
Date: Tue, 28 Jul 2026 10:28:27 +0800
Subject: [PATCH] feat(canvas): add reasoning effort settings for text nodes in
canvas configuration
---
CHANGELOG.md | 1 +
docs/content/docs/progress/pending-test.mdx | 1 +
.../canvas/canvas-config-node-panel.tsx | 8 +-
.../canvas/canvas-node-prompt-panel.tsx | 7 +-
.../canvas/canvas-text-settings-popover.tsx | 92 +++++++++++++++++++
web/src/components/text-settings-panel.tsx | 60 ++++++++++++
.../lib/canvas/canvas-generation-helpers.ts | 1 +
web/src/pages/canvas/project.tsx | 9 +-
web/src/services/api/image.ts | 1 +
web/src/services/api/model-plugin.ts | 13 ++-
web/src/stores/use-config-store.ts | 4 +
web/src/types/canvas.ts | 1 +
12 files changed, 190 insertions(+), 8 deletions(-)
create mode 100644 web/src/components/canvas/canvas-text-settings-popover.tsx
create mode 100644 web/src/components/text-settings-panel.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a18a9b8..be274c4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## Unreleased
++ [新增] 画布文本生成新增推理强度设置,支持默认调用与自定义调用脚本读取所选档位。
+ [新增] 生图与视频工作台的参考资产区域支持直接拖动文件上传。
+ [新增] 配置与用户偏好支持通过配置文件导入和导出。
+ [新增] 模型渠道新增火山方舟协议。
diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx
index af6bdae..355073b 100644
--- a/docs/content/docs/progress/pending-test.mdx
+++ b/docs/content/docs/progress/pending-test.mdx
@@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项
# 待测试
+- 画布文本设置:文本节点和生成配置节点切换到文本模式后应显示推理强度设置,可选择自动、低、中、高、极高;选择自动时默认 OpenAI Responses 请求不应携带 `reasoning`,选择其他档位时应携带所选强度,刷新画布后节点设置应保留;文本模型自定义调用脚本应能读取 `reasoningEffort`,OpenAI 模板应按自动或指定档位正确组装请求。
- 生图工作台参考图:将一张或多张图片拖入参考图区域后应直接上传并显示缩略图;拖入非图片文件应忽略,拖动过程中区域应显示高亮提示,放下文件不应导致浏览器打开或替换当前页面。
- 视频创作台参考资产:将图片、MP4/MOV 视频或 MP3/WAV 音频拖入任一参考资产区域后,应按文件类型自动上传到对应列表;当前拖入区域应显示高亮提示,原有数量、大小、时长和格式限制应继续生效。
- 画布组装提示词:输入或连接超长提示词后,浮层正文高度不应继续撑大,内容应可在正文区域内滚动查看和编辑,标题及关闭按钮保持可见。
diff --git a/web/src/components/canvas/canvas-config-node-panel.tsx b/web/src/components/canvas/canvas-config-node-panel.tsx
index c06b372..f8e3482 100644
--- a/web/src/components/canvas/canvas-config-node-panel.tsx
+++ b/web/src/components/canvas/canvas-config-node-panel.tsx
@@ -9,6 +9,7 @@ import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
+import { CanvasTextSettingsPopover } from "./canvas-text-settings-popover";
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
type CanvasConfigNodePanelProps = {
@@ -95,7 +96,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
-
event.stopPropagation()}>
+
event.stopPropagation()}>
onConfigChange(node.id, { model })} capability={mode} onMissingConfig={() => openConfigDialog(true)} fullWidth />
{mode === "video" ? (
onConfigChange(node.id, videoConfigPatch(key, value))} />
@@ -103,7 +104,9 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} />
) : mode === "audio" ? (
onConfigChange(node.id, audioConfigPatch(key, value))} />
- ) : null}
+ ) : (
+ onConfigChange(node.id, { reasoningEffort: value })} />
+ )}
+
+ {panel}
+ >
+ );
+}
+
+function TextSettingsPortal({ buttonRect, panelRef, placement, theme, config, onConfigChange }: {
+ buttonRect: DOMRect;
+ panelRef: RefObject;
+ placement: CanvasTextSettingsPopoverProps["placement"];
+ theme: (typeof canvasThemes)[keyof typeof canvasThemes];
+ config: AiConfig;
+ onConfigChange: CanvasTextSettingsPopoverProps["onConfigChange"];
+}) {
+ const width = 356;
+ const gap = 8;
+ const margin = 12;
+ const alignRight = placement?.endsWith("Right");
+ const alignCenter = placement === "top" || placement === "bottom";
+ const left = alignCenter ? buttonRect.left + buttonRect.width / 2 - width / 2 : alignRight ? buttonRect.right - width : buttonRect.left;
+ const topPlacement = placement?.startsWith("top");
+ const style = {
+ position: "fixed",
+ zIndex: 1200,
+ width,
+ left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)),
+ ...(topPlacement ? { bottom: window.innerHeight - buttonRect.top + gap } : { top: buttonRect.bottom + gap }),
+ background: theme.toolbar.panel,
+ borderRadius: 18,
+ boxShadow: "0 18px 54px rgba(28, 25, 23, 0.16)",
+ padding: 18,
+ color: theme.node.text,
+ } as const;
+
+ return createPortal(
+ event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
+
+
,
+ document.body,
+ );
+}
diff --git a/web/src/components/text-settings-panel.tsx b/web/src/components/text-settings-panel.tsx
new file mode 100644
index 0000000..e487543
--- /dev/null
+++ b/web/src/components/text-settings-panel.tsx
@@ -0,0 +1,60 @@
+import { type ReactNode } from "react";
+
+import { ImageSettingsTheme } from "@/components/image-settings-panel";
+import { type CanvasTheme } from "@/lib/canvas-theme";
+import type { AiConfig, ReasoningEffort } from "@/stores/use-config-store";
+
+const reasoningEffortOptions: Array<{ value: ReasoningEffort; label: string }> = [
+ { value: "auto", label: "自动" },
+ { value: "low", label: "低" },
+ { value: "medium", label: "中" },
+ { value: "high", label: "高" },
+ { value: "xhigh", label: "极高" },
+];
+
+type TextSettingsPanelProps = {
+ config: AiConfig;
+ onConfigChange: (key: "reasoningEffort", value: ReasoningEffort) => void;
+ theme: CanvasTheme;
+ className?: string;
+};
+
+export function TextSettingsPanel({ config, onConfigChange, theme, className = "space-y-4" }: TextSettingsPanelProps) {
+ return (
+
+ event.stopPropagation()}>
+
文本设置
+
+
+ 推理强度
+
+
+ {reasoningEffortOptions.map((item) => (
+ onConfigChange("reasoningEffort", item.value)}>
+ {item.label}
+
+ ))}
+
+
+
+
+ );
+}
+
+export function reasoningEffortLabel(value: ReasoningEffort) {
+ return reasoningEffortOptions.find((item) => item.value === value)?.label || value;
+}
+
+function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
+ return (
+ event.stopPropagation()}
+ onClick={onClick}
+ >
+ {children}
+
+ );
+}
diff --git a/web/src/lib/canvas/canvas-generation-helpers.ts b/web/src/lib/canvas/canvas-generation-helpers.ts
index 26b86b7..1cfcc49 100644
--- a/web/src/lib/canvas/canvas-generation-helpers.ts
+++ b/web/src/lib/canvas/canvas-generation-helpers.ts
@@ -93,6 +93,7 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
return {
...config,
model: resolveModelForCapability(config, node?.metadata?.model, mode),
+ reasoningEffort: node?.metadata?.reasoningEffort || config.reasoningEffort || defaultConfig.reasoningEffort,
quality: node?.metadata?.quality || config.quality || defaultConfig.quality,
size: node?.metadata?.size || config.size || defaultConfig.size,
background: node?.metadata?.background ?? config.background ?? defaultConfig.background,
diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx
index 63baf05..83f1ed0 100644
--- a/web/src/pages/canvas/project.tsx
+++ b/web/src/pages/canvas/project.tsx
@@ -2383,7 +2383,7 @@ function InfiniteCanvasPage() {
},
width: textConfig.width,
height: textConfig.height,
- metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, fontSize: 14 },
+ metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, fontSize: 14, model: generationConfig.model, reasoningEffort: generationConfig.reasoningEffort },
}));
setNodes((prev) => [...prev.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]);
setConnections((prev) => [...prev, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId }))]);
@@ -2419,7 +2419,12 @@ function InfiniteCanvasPage() {
: node.id === nodeId && isConfigNode
? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } }
: node.id === nodeId && !editingTextNode
- ? { ...node, type: CanvasNodeType.Text, title: prompt.slice(0, 32) || "Generated Text", metadata: { ...node.metadata, content: answerByNodeId.get(node.id) || streamed, status: NODE_STATUS_SUCCESS } }
+ ? {
+ ...node,
+ type: CanvasNodeType.Text,
+ title: prompt.slice(0, 32) || "Generated Text",
+ metadata: { ...node.metadata, content: answerByNodeId.get(node.id) || streamed, model: generationConfig.model, reasoningEffort: generationConfig.reasoningEffort, status: NODE_STATUS_SUCCESS },
+ }
: node,
),
);
diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts
index 51bfc95..eb750c5 100644
--- a/web/src/services/api/image.ts
+++ b/web/src/services/api/image.ts
@@ -893,6 +893,7 @@ export async function requestImageQuestion(config: AiConfig, messages: AiTextMes
const answer = (await requestStreamingResponse(requestConfig, {
model: requestConfig.model,
input: toResponseInput(withSystemMessage(requestConfig, messages)),
+ ...(requestConfig.reasoningEffort === "auto" ? {} : { reasoning: { effort: requestConfig.reasoningEffort } }),
}, onDelta, options)).content || "没有返回内容";
if (answer === "没有返回内容") onDelta(answer);
return answer;
diff --git a/web/src/services/api/model-plugin.ts b/web/src/services/api/model-plugin.ts
index 409b1de..8b0a10d 100644
--- a/web/src/services/api/model-plugin.ts
+++ b/web/src/services/api/model-plugin.ts
@@ -106,7 +106,7 @@ function createPoll(signal?: AbortSignal) {
/**
* Run a user-authored model call script as an async function body with flat locals (see PLUGIN_VARIABLES):
* prompt / images / messages / params —— 本次请求的输入
- * model / baseUrl / apiKey / systemPrompt —— 当前渠道信息
+ * model / baseUrl / apiKey / systemPrompt / reasoningEffort —— 当前渠道与文本设置
* http / request / poll / sleep / signal / onDelta —— 调用辅助
* The script must `return` the result; each caller normalizes it to its capability's shape.
*/
@@ -124,6 +124,7 @@ export async function runModelPlugin(args: RunPluginArgs): Promise<
"baseUrl",
"apiKey",
"systemPrompt",
+ "reasoningEffort",
"http",
"request",
"poll",
@@ -142,6 +143,7 @@ export async function runModelPlugin(args: RunPluginArgs): Promise<
config.baseUrl,
config.apiKey,
config.systemPrompt || "",
+ config.reasoningEffort,
http,
request,
poll,
@@ -169,6 +171,7 @@ export const PLUGIN_VARIABLES: PluginVariable[] = [
{ name: "baseUrl", type: "string", desc: "渠道接口地址(原样,未拼 /v1)" },
{ name: "apiKey", type: "string", desc: "渠道 API Key,请求头里自己带上" },
{ name: "systemPrompt", type: "string", desc: "系统提示词原文" },
+ { name: "reasoningEffort", type: '"auto" | "low" | "medium" | "high" | "xhigh"', desc: "文本推理强度;auto 表示由脚本决定是否传递", capabilities: ["text"] },
{ name: "http", type: "object", desc: "便捷请求:http.post(path, body, {headers,params,responseType})、http.get(path, opts)、http.url(path);默认带 Authorization: Bearer apiKey,可用 headers 覆盖;path 相对时按 baseUrl 拼 /v1" },
{ name: "request", type: "function", desc: "原始请求 request({ method, url, headers, params, data, responseType }),不加任何默认头,鉴权头自己写;url 相对时按 baseUrl 拼接(不加 /v1)" },
{ name: "poll", type: "function", desc: "轮询 poll(request, extract, {intervalMs,timeoutMs}),extract 返回真值即结束" },
@@ -321,12 +324,16 @@ return { data: audio.data };`,
text: [
{
label: "OpenAI 规范",
- script: `// 文本对话(OpenAI Responses 接口)。可用:messages([{role,content}])、systemPrompt、model
+ script: `// 文本对话(OpenAI Responses 接口)。可用:messages([{role,content}])、systemPrompt、model、reasoningEffort
const data = await request({
method: "post",
url: \`\${baseUrl}/v1/responses\`,
headers: { "Content-Type": "application/json", Authorization: \`Bearer \${apiKey}\` },
- data: { model, input: messages },
+ data: {
+ model,
+ input: messages,
+ ...(reasoningEffort === "auto" ? {} : { reasoning: { effort: reasoningEffort } }),
+ },
});
const text = data.output_text
|| (data.output || []).flatMap((o) => o.content || []).map((c) => c.text || "").join("")
diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts
index f4ea685..85517ce 100644
--- a/web/src/stores/use-config-store.ts
+++ b/web/src/stores/use-config-store.ts
@@ -5,6 +5,7 @@ import { nanoid } from "nanoid";
export type ApiCallFormat = "openai" | "gemini" | "ark";
export type ModelCapability = "image" | "video" | "text" | "audio";
+export type ReasoningEffort = "auto" | "low" | "medium" | "high" | "xhigh";
export type ChannelModel = {
name: string;
@@ -41,6 +42,7 @@ export type AiConfig = {
videoGenerateAudio: string;
videoWatermark: string;
systemPrompt: string;
+ reasoningEffort: ReasoningEffort;
models: string[];
quality: string;
size: string;
@@ -98,6 +100,7 @@ export const defaultConfig: AiConfig = {
videoGenerateAudio: "true",
videoWatermark: "false",
systemPrompt: "",
+ reasoningEffort: "auto",
models: ["default::gpt-image-2", "default::grok-imagine-video", "default::gpt-5.5", "default::gpt-4o-mini-tts"],
quality: "auto",
size: "1:1",
@@ -236,6 +239,7 @@ export const useConfigStore = create()(
audioFormat: config.audioFormat || defaultConfig.audioFormat,
audioSpeed: config.audioSpeed || defaultConfig.audioSpeed,
audioInstructions: config.audioInstructions || "",
+ reasoningEffort: config.reasoningEffort || "auto",
videoSeconds: config.videoSeconds || "6",
vquality: config.vquality || "720",
videoGenerateAudio: config.videoGenerateAudio || "true",
diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts
index 48a7d76..0259db6 100644
--- a/web/src/types/canvas.ts
+++ b/web/src/types/canvas.ts
@@ -35,6 +35,7 @@ export type CanvasNodeMetadata = {
generationMode?: CanvasGenerationMode;
generationType?: CanvasImageGenerationType;
model?: string;
+ reasoningEffort?: "auto" | "low" | "medium" | "high" | "xhigh";
size?: string;
quality?: string;
background?: string;