feat(i18n): implement internationalization framework and update UI components for language support

This commit is contained in:
HouYunFei
2026-08-05 14:08:03 +08:00
parent 9bccd0ff1a
commit 0f6809251a
116 changed files with 3269 additions and 1870 deletions
+1
View File
@@ -2,6 +2,7 @@
## Unreleased ## Unreleased
+ [新增] 前端搭建中英文国际化框架,支持持久化切换全局导航、首页、配置中心、Ant Design 和日期组件语言。
+ [新增] Agent 支持查看、创建、编辑、删除、启停和显式调用本地 Codex Skill,并同步多标签页变更。 + [新增] Agent 支持查看、创建、编辑、删除、启停和显式调用本地 Codex Skill,并同步多标签页变更。
+ [新增] Agent 支持从当前对话或当前画布生成可编辑的 Skill 草稿,并在用户确认后保存。 + [新增] Agent 支持从当前对话或当前画布生成可编辑的 Skill 草稿,并在用户确认后保存。
+ [调整] Agent 面板连接状态、内容标签和新对话统一排版,窄面板仅隐藏文字并保留图标与数量。 + [调整] Agent 面板连接状态、内容标签和新对话统一排版,窄面板仅隐藏文字并保留图标与数量。
@@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项
# 待测试 # 待测试
- 国际化基础框架:进入「配置与用户偏好 → 偏好设置」切换简体中文或 English 后,首页、顶部及移动端导航、版本弹窗、配置主面板、渠道编辑、模型选择、页面标题、页面描述和 Ant Design 内置文案应立即切换,刷新页面后保持所选语言;WebDAV 同步错误、提示词来源及其他尚未接入翻译的业务模块暂时仍可能显示中文。
- 画布节点缩放稳定性:选中任意节点并反复拖动四角缩放时,节点工具条应在缩放期间隐藏、松开后恢复,页面不应再出现 `Maximum update depth exceeded`。 - 画布节点缩放稳定性:选中任意节点并反复拖动四角缩放时,节点工具条应在缩放期间隐藏、松开后恢复,页面不应再出现 `Maximum update depth exceeded`。
- Agent MCP 初始化状态:首次进入空白对话、点击「新对话」或恢复历史对话后,无需先发送消息,对话区应立即显示 Codex 会话准备,并列出 `codex_apps`、`infinite-canvas`、`node_repl` 等完整 MCP 服务状态;全部服务进入终态前可输入并保留草稿,但发送按钮、上传和回车提交应禁用;可选 MCP 失败时应提示警告但允许发送,`infinite-canvas` 缺失或失败时应阻止发送并显示明确原因;首轮及后续任务运行期间再次收到的 MCP 状态只进入诊断日志,模型开始处理及回复完成后对话区不应残留初始化卡片。 - Agent MCP 初始化状态:首次进入空白对话、点击「新对话」或恢复历史对话后,无需先发送消息,对话区应立即显示 Codex 会话准备,并列出 `codex_apps`、`infinite-canvas`、`node_repl` 等完整 MCP 服务状态;全部服务进入终态前可输入并保留草稿,但发送按钮、上传和回车提交应禁用;可选 MCP 失败时应提示警告但允许发送,`infinite-canvas` 缺失或失败时应阻止发送并显示明确原因;首轮及后续任务运行期间再次收到的 MCP 状态只进入诊断日志,模型开始处理及回复完成后对话区不应残留初始化卡片。
- Agent 模型设置:连接 Canvas Agent 后,输入框左下方应显示当前 Codex 模型与推理强度;模型列表应来自当前账号实际可用模型且不显示内部审查模型或重复项,切换模型后强度选项随模型能力更新且无空白选项,刷新页面后保留选择,发送任务时实际使用所选模型和强度;本地控制台与右侧「日志」应记录本轮使用的模型和推理强度。 - Agent 模型设置:连接 Canvas Agent 后,输入框左下方应显示当前 Codex 模型与推理强度;模型列表应来自当前账号实际可用模型且不显示内部审查模型或重复项,切换模型后强度选项随模型能力更新且无空白选项,刷新页面后保留选择,发送任务时实际使用所选模型和强度;本地控制台与右侧「日志」应记录本轮使用的模型和推理强度。
+8
View File
@@ -19,6 +19,7 @@
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"i18next": "^26.3.6",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"motion": "^12.38.0", "motion": "^12.38.0",
@@ -26,6 +27,7 @@
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.5", "react": "19.2.5",
"react-dom": "19.2.5", "react-dom": "19.2.5",
"react-i18next": "^17.0.11",
"react-router": "^7.12.0", "react-router": "^7.12.0",
"react-router-dom": "^7.12.0", "react-router-dom": "^7.12.0",
"shadcn": "^4.7.0", "shadcn": "^4.7.0",
@@ -1125,6 +1127,8 @@
"hono": ["hono@4.12.18", "https://registry.npmmirror.com/hono/-/hono-4.12.18.tgz", {}, "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ=="], "hono": ["hono@4.12.18", "https://registry.npmmirror.com/hono/-/hono-4.12.18.tgz", {}, "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ=="],
"html-parse-stringify": ["html-parse-stringify@4.0.1", "", {}, "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
@@ -1135,6 +1139,8 @@
"human-signals": ["human-signals@8.0.1", "https://registry.npmmirror.com/human-signals/-/human-signals-8.0.1.tgz", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], "human-signals": ["human-signals@8.0.1", "https://registry.npmmirror.com/human-signals/-/human-signals-8.0.1.tgz", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
"i18next": ["i18next@26.3.6", "", { "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["typescript"] }, "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA=="],
"iconv-lite": ["iconv-lite@0.7.2", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "iconv-lite": ["iconv-lite@0.7.2", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -1531,6 +1537,8 @@
"react-draggable": ["react-draggable@4.5.0", "https://registry.npmmirror.com/react-draggable/-/react-draggable-4.5.0.tgz", { "dependencies": { "clsx": "^2.1.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.3.0", "react-dom": ">= 16.3.0" } }, "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw=="], "react-draggable": ["react-draggable@4.5.0", "https://registry.npmmirror.com/react-draggable/-/react-draggable-4.5.0.tgz", { "dependencies": { "clsx": "^2.1.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.3.0", "react-dom": ">= 16.3.0" } }, "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw=="],
"react-i18next": ["react-i18next@17.0.11", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^4.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["typescript"] }, "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg=="],
"react-is": ["react-is@18.3.1", "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "react-is": ["react-is@18.3.1", "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-layout-kit": ["react-layout-kit@1.9.2", "https://registry.npmmirror.com/react-layout-kit/-/react-layout-kit-1.9.2.tgz", { "dependencies": { "@babel/runtime": "^7", "@emotion/css": "^11" }, "peerDependencies": { "react": ">=18" } }, "sha512-fzmrwMBNGIAiDIrdFMV3NvJhUNl01QC9EMcI8SP7osg51N4j+z6w4tx9i2yWxEEXZ2armLV6EtkFd3KST8PYiA=="], "react-layout-kit": ["react-layout-kit@1.9.2", "https://registry.npmmirror.com/react-layout-kit/-/react-layout-kit-1.9.2.tgz", { "dependencies": { "@babel/runtime": "^7", "@emotion/css": "^11" }, "peerDependencies": { "react": ">=18" } }, "sha512-fzmrwMBNGIAiDIrdFMV3NvJhUNl01QC9EMcI8SP7osg51N4j+z6w4tx9i2yWxEEXZ2armLV6EtkFd3KST8PYiA=="],
+2
View File
@@ -26,6 +26,7 @@
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"i18next": "^26.3.6",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"motion": "^12.38.0", "motion": "^12.38.0",
@@ -33,6 +34,7 @@
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.5", "react": "19.2.5",
"react-dom": "19.2.5", "react-dom": "19.2.5",
"react-i18next": "^17.0.11",
"react-router": "^7.12.0", "react-router": "^7.12.0",
"react-router-dom": "^7.12.0", "react-router-dom": "^7.12.0",
"shadcn": "^4.7.0", "shadcn": "^4.7.0",
@@ -1,6 +1,7 @@
import { useRef, useState, type ReactNode } from "react"; import { useRef, useState, type ReactNode } from "react";
import { Button, Dropdown, Tooltip } from "antd"; import { Button, Dropdown, Tooltip } from "antd";
import { ArrowUp, Check, ChevronUp, Cpu, Gauge, Hand, ImagePlus, LoaderCircle, RefreshCw, ShieldAlert, ShieldCheck, ShieldOff, Square, X } from "lucide-react"; import { ArrowUp, Check, ChevronUp, Cpu, Gauge, Hand, ImagePlus, LoaderCircle, RefreshCw, ShieldAlert, ShieldCheck, ShieldOff, Square, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
@@ -55,6 +56,7 @@ export function AgentChatComposer({
onReasoningEffortChange?: (effort: AgentReasoningEffort) => void; onReasoningEffortChange?: (effort: AgentReasoningEffort) => void;
left?: ReactNode; left?: ReactNode;
}) { }) {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const selectedSkill = useAgentSkillStore((state) => state.selectedSkill); const selectedSkill = useAgentSkillStore((state) => state.selectedSkill);
const clearSkillSelection = useAgentSkillStore((state) => state.clearSelection); const clearSkillSelection = useAgentSkillStore((state) => state.clearSelection);
@@ -68,7 +70,7 @@ export function AgentChatComposer({
<div key={item.id} className="group relative size-14 shrink-0 overflow-hidden rounded-xl border" style={{ borderColor: theme.node.stroke }} title={item.name}> <div key={item.id} className="group relative size-14 shrink-0 overflow-hidden rounded-xl border" style={{ borderColor: theme.node.stroke }} title={item.name}>
<img src={item.url} alt={item.name} className="size-full object-cover" /> <img src={item.url} alt={item.name} className="size-full object-cover" />
{onRemoveAttachment ? ( {onRemoveAttachment ? (
<button type="button" className="absolute right-1 top-1 grid size-5 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover:opacity-100" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text }} onClick={() => onRemoveAttachment(item.id)} aria-label="移除图片"> <button type="button" className="absolute right-1 top-1 grid size-5 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover:opacity-100" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text }} onClick={() => onRemoveAttachment(item.id)} aria-label={t("agent.composer.removeImage")}>
<X className="size-3" /> <X className="size-3" />
</button> </button>
) : null} ) : null}
@@ -80,7 +82,7 @@ export function AgentChatComposer({
<div className="mb-2 flex items-center px-1"> <div className="mb-2 flex items-center px-1">
<span className="inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium" style={{ borderColor: theme.node.stroke, color: theme.node.text }}> <span className="inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
<span className="truncate">{selectedSkill.interface?.displayName || selectedSkill.name}</span> <span className="truncate">{selectedSkill.interface?.displayName || selectedSkill.name}</span>
<button type="button" className="grid size-4 shrink-0 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" onClick={() => clearSkillSelection()} aria-label="移除 Skill"> <button type="button" className="grid size-4 shrink-0 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" onClick={() => clearSkillSelection()} aria-label={t("agent.composer.removeSkill")}>
<X className="size-3" /> <X className="size-3" />
</button> </button>
</span> </span>
@@ -114,8 +116,8 @@ export function AgentChatComposer({
void onAddFiles(event.target.files); void onAddFiles(event.target.files);
event.target.value = ""; event.target.value = "";
}} /> }} />
<Tooltip title="上传图片"> <Tooltip title={t("agent.composer.uploadImage")}>
<Button type="text" shape="circle" className="!h-9 !w-9 !min-w-9" disabled={disabled || sending} style={{ color: theme.node.muted }} icon={<ImagePlus className="size-4" />} onClick={() => fileInputRef.current?.click()} aria-label="上传图片" /> <Button type="text" shape="circle" className="!h-9 !w-9 !min-w-9" disabled={disabled || sending} style={{ color: theme.node.muted }} icon={<ImagePlus className="size-4" />} onClick={() => fileInputRef.current?.click()} aria-label={t("agent.composer.uploadImage")} />
</Tooltip> </Tooltip>
</> </>
) : null} ) : null}
@@ -127,9 +129,9 @@ export function AgentChatComposer({
</div> </div>
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
{sending && onStop ? ( {sending && onStop ? (
<Tooltip title="停止" placement="top"><Button danger shape="circle" className="!h-10 !w-10 !min-w-10" icon={<Square className="size-4" />} onClick={() => void onStop()} aria-label="停止" /></Tooltip> <Tooltip title={t("agent.composer.stop")} placement="top"><Button danger shape="circle" className="!h-10 !w-10 !min-w-10" icon={<Square className="size-4" />} onClick={() => void onStop()} aria-label={t("agent.composer.stop")} /></Tooltip>
) : ( ) : (
<Tooltip title="发送" placement="top"><Button type="primary" shape="circle" className="!h-10 !w-10 !min-w-10" disabled={!canSubmit} icon={sending ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} onClick={() => void onSubmit()} aria-label="发送" /></Tooltip> <Tooltip title={t("agent.composer.send")} placement="top"><Button type="primary" shape="circle" className="!h-10 !w-10 !min-w-10" disabled={!canSubmit} icon={sending ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} onClick={() => void onSubmit()} aria-label={t("agent.composer.send")} /></Tooltip>
)} )}
</div> </div>
</div> </div>
@@ -139,15 +141,17 @@ export function AgentChatComposer({
} }
function AgentModelControls({ models, model, reasoningEffort, onModelChange, onReasoningEffortChange }: { models: AgentModel[]; model: string; reasoningEffort: AgentReasoningEffort; onModelChange: (model: string) => void; onReasoningEffortChange: (effort: AgentReasoningEffort) => void }) { function AgentModelControls({ models, model, reasoningEffort, onModelChange, onReasoningEffortChange }: { models: AgentModel[]; model: string; reasoningEffort: AgentReasoningEffort; onModelChange: (model: string) => void; onReasoningEffortChange: (effort: AgentReasoningEffort) => void }) {
const { t } = useTranslation();
const current = models.find((item) => item.model === model) || models[0]; const current = models.find((item) => item.model === model) || models[0];
const effortLabel = (effort: AgentReasoningEffort) => t(`agent.composer.effort.${effort}`);
const [modelOpen, setModelOpen] = useState(false); const [modelOpen, setModelOpen] = useState(false);
const [reasoningOpen, setReasoningOpen] = useState(false); const [reasoningOpen, setReasoningOpen] = useState(false);
return ( return (
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
<Tooltip title={`模型:${current.displayName || current.model}`} placement="top" open={modelOpen ? false : undefined}> <Tooltip title={t("agent.composer.model", { model: current.displayName || current.model })} placement="top" open={modelOpen ? false : undefined}>
<span className="inline-flex shrink-0"> <span className="inline-flex shrink-0">
<Select value={model} open={modelOpen} onOpenChange={setModelOpen} onValueChange={onModelChange}> <Select value={model} open={modelOpen} onOpenChange={setModelOpen} onValueChange={onModelChange}>
<SelectTrigger hideChevron className="h-9 w-9 min-w-9 justify-center gap-0 rounded-full border-0 bg-transparent px-0 text-xs font-medium shadow-none hover:bg-black/5 focus:ring-0 @min-[660px]:w-auto @min-[660px]:min-w-36 @min-[660px]:max-w-36 @min-[660px]:justify-start @min-[660px]:gap-1.5 @min-[660px]:px-2.5 dark:bg-transparent dark:hover:bg-white/10" aria-label={`选择 Codex 模型,当前为 ${current.displayName || current.model}`}> <SelectTrigger hideChevron className="h-9 w-9 min-w-9 justify-center gap-0 rounded-full border-0 bg-transparent px-0 text-xs font-medium shadow-none hover:bg-black/5 focus:ring-0 @min-[660px]:w-auto @min-[660px]:min-w-36 @min-[660px]:max-w-36 @min-[660px]:justify-start @min-[660px]:gap-1.5 @min-[660px]:px-2.5 dark:bg-transparent dark:hover:bg-white/10" aria-label={t("agent.composer.selectModel", { model: current.displayName || current.model })}>
<Cpu className="size-3.5 shrink-0 opacity-70" /> <Cpu className="size-3.5 shrink-0 opacity-70" />
<span className="hidden min-w-0 flex-1 truncate text-left @min-[660px]:inline">{current.displayName || current.model}</span> <span className="hidden min-w-0 flex-1 truncate text-left @min-[660px]:inline">{current.displayName || current.model}</span>
<ChevronUp className="hidden size-3 opacity-50 @min-[660px]:block" /> <ChevronUp className="hidden size-3 opacity-50 @min-[660px]:block" />
@@ -158,16 +162,16 @@ function AgentModelControls({ models, model, reasoningEffort, onModelChange, onR
</Select> </Select>
</span> </span>
</Tooltip> </Tooltip>
<Tooltip title={`思考程度:${effortLabels[reasoningEffort]}`} placement="top" open={reasoningOpen ? false : undefined}> <Tooltip title={t("agent.composer.reasoning", { effort: effortLabel(reasoningEffort) })} placement="top" open={reasoningOpen ? false : undefined}>
<span className="inline-flex shrink-0"> <span className="inline-flex shrink-0">
<Select value={reasoningEffort} open={reasoningOpen} onOpenChange={setReasoningOpen} onValueChange={(value) => onReasoningEffortChange(value as AgentReasoningEffort)}> <Select value={reasoningEffort} open={reasoningOpen} onOpenChange={setReasoningOpen} onValueChange={(value) => onReasoningEffortChange(value as AgentReasoningEffort)}>
<SelectTrigger hideChevron className="h-9 w-9 min-w-9 justify-center gap-0 rounded-full border-0 bg-transparent px-0 text-xs font-medium shadow-none hover:bg-black/5 focus:ring-0 @min-[660px]:w-auto @min-[660px]:min-w-[4.5rem] @min-[660px]:justify-start @min-[660px]:gap-1.5 @min-[660px]:px-2.5 dark:bg-transparent dark:hover:bg-white/10" aria-label={`选择思考程度,当前为 ${effortLabels[reasoningEffort]}`}> <SelectTrigger hideChevron className="h-9 w-9 min-w-9 justify-center gap-0 rounded-full border-0 bg-transparent px-0 text-xs font-medium shadow-none hover:bg-black/5 focus:ring-0 @min-[660px]:w-auto @min-[660px]:min-w-[4.5rem] @min-[660px]:justify-start @min-[660px]:gap-1.5 @min-[660px]:px-2.5 dark:bg-transparent dark:hover:bg-white/10" aria-label={t("agent.composer.selectReasoning", { effort: effortLabel(reasoningEffort) })}>
<Gauge className="size-3.5 opacity-70" /> <Gauge className="size-3.5 opacity-70" />
<span className="hidden @min-[660px]:inline">{effortLabels[reasoningEffort]}</span> <span className="hidden @min-[660px]:inline">{effortLabel(reasoningEffort)}</span>
<ChevronUp className="hidden size-3 opacity-50 @min-[660px]:block" /> <ChevronUp className="hidden size-3 opacity-50 @min-[660px]:block" />
</SelectTrigger> </SelectTrigger>
<SelectContent data-canvas-no-zoom position="popper" side="top" align="start" sideOffset={6} className="z-[1200] min-w-32 rounded-xl border border-border/70 bg-popover p-1 shadow-xl"> <SelectContent data-canvas-no-zoom position="popper" side="top" align="start" sideOffset={6} className="z-[1200] min-w-32 rounded-xl border border-border/70 bg-popover p-1 shadow-xl">
{current.supportedReasoningEfforts.map((item) => <SelectItem key={item.reasoningEffort} value={item.reasoningEffort}>{effortLabels[item.reasoningEffort]}</SelectItem>)} {current.supportedReasoningEfforts.map((item) => <SelectItem key={item.reasoningEffort} value={item.reasoningEffort}>{effortLabel(item.reasoningEffort)}</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </Select>
</span> </span>
@@ -176,21 +180,17 @@ function AgentModelControls({ models, model, reasoningEffort, onModelChange, onR
); );
} }
const effortLabels: Record<AgentReasoningEffort, string> = {
minimal: "最低",
low: "轻度",
medium: "中",
high: "高",
xhigh: "极高",
max: "最高",
ultra: "Ultra",
};
function PermissionModeMenu({ permissionMode, theme, onChange }: { permissionMode: AgentPermissionMode; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (permissionMode: AgentPermissionMode) => void }) { function PermissionModeMenu({ permissionMode, theme, onChange }: { permissionMode: AgentPermissionMode; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (permissionMode: AgentPermissionMode) => void }) {
const { t } = useTranslation();
const permissionOptions: Array<{ key: AgentPermissionMode; title: string; shortTitle: string; description: string; icon: ReactNode }> = [
{ key: "request", title: t("agent.composer.permission.request"), shortTitle: t("agent.composer.permission.request"), description: t("agent.composer.permission.requestDescription"), icon: <ShieldAlert className="size-3.5" /> },
{ key: "automatic", title: t("agent.composer.permission.automatic"), shortTitle: t("agent.composer.permission.automatic"), description: t("agent.composer.permission.automaticDescription"), icon: <ShieldCheck className="size-3.5" /> },
{ key: "full", title: t("agent.composer.permission.full"), shortTitle: t("agent.composer.permission.fullShort"), description: t("agent.composer.permission.fullDescription"), icon: <ShieldOff className="size-3.5" /> },
];
const current = permissionOptions.find((item) => item.key === permissionMode) || permissionOptions[0]; const current = permissionOptions.find((item) => item.key === permissionMode) || permissionOptions[0];
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
<Tooltip title={`权限:${current.shortTitle}`} placement="top" open={open ? false : undefined}> <Tooltip title={t("agent.composer.permissionLabel", { mode: current.shortTitle })} placement="top" open={open ? false : undefined}>
<span className="inline-flex shrink-0"> <span className="inline-flex shrink-0">
<Dropdown <Dropdown
trigger={["click"]} trigger={["click"]}
@@ -205,7 +205,7 @@ function PermissionModeMenu({ permissionMode, theme, onChange }: { permissionMod
})), })),
}} }}
> >
<button type="button" className="flex h-9 w-9 min-w-9 shrink-0 items-center justify-center gap-0 rounded-full px-0 text-xs font-medium transition hover:bg-black/5 @min-[660px]:h-9 @min-[660px]:w-auto @min-[660px]:min-w-0 @min-[660px]:justify-start @min-[660px]:gap-1.5 @min-[660px]:px-2.5 dark:hover:bg-white/10" style={{ color: permissionMode === "full" ? "#ea580c" : theme.node.text }} aria-label={`选择 Codex 权限模式,当前为 ${current.title}`}> <button type="button" className="flex h-9 w-9 min-w-9 shrink-0 items-center justify-center gap-0 rounded-full px-0 text-xs font-medium transition hover:bg-black/5 @min-[660px]:h-9 @min-[660px]:w-auto @min-[660px]:min-w-0 @min-[660px]:justify-start @min-[660px]:gap-1.5 @min-[660px]:px-2.5 dark:hover:bg-white/10" style={{ color: permissionMode === "full" ? "#ea580c" : theme.node.text }} aria-label={t("agent.composer.selectPermission", { mode: current.title })}>
{current.icon} {current.icon}
<span className="hidden @min-[660px]:inline">{current.shortTitle}</span> <span className="hidden @min-[660px]:inline">{current.shortTitle}</span>
<ChevronUp className="hidden size-3 opacity-50 @min-[660px]:block" /> <ChevronUp className="hidden size-3 opacity-50 @min-[660px]:block" />
@@ -216,16 +216,12 @@ function PermissionModeMenu({ permissionMode, theme, onChange }: { permissionMod
); );
} }
const permissionOptions: Array<{ key: AgentPermissionMode; title: string; shortTitle: string; description: string; icon: ReactNode }> = [
{ key: "request", title: "请求批准", shortTitle: "请求批准", description: "编辑工作区外文件或联网时始终询问", icon: <ShieldAlert className="size-3.5" /> },
{ key: "automatic", title: "自动审查", shortTitle: "自动审查", description: "由 Codex 审查风险操作,必要时再询问", icon: <ShieldCheck className="size-3.5" /> },
{ key: "full", title: "完全访问权限", shortTitle: "完全访问", description: "不受限制地访问网络和本机文件", icon: <ShieldOff className="size-3.5" /> },
];
function ToolConfirmationMenu({ confirmTools, theme, onChange }: { confirmTools: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (confirmTools: boolean) => void }) { function ToolConfirmationMenu({ confirmTools, theme, onChange }: { confirmTools: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (confirmTools: boolean) => void }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const mode = t(confirmTools ? "agent.composer.tools.manual" : "agent.composer.tools.automatic");
return ( return (
<Tooltip title={`工具确认:${confirmTools ? "手动确认" : "自动确认"}`} placement="top" open={open ? false : undefined}> <Tooltip title={t("agent.composer.tools.label", { mode })} placement="top" open={open ? false : undefined}>
<span className="inline-flex shrink-0"> <span className="inline-flex shrink-0">
<Dropdown <Dropdown
trigger={["click"]} trigger={["click"]}
@@ -236,20 +232,20 @@ function ToolConfirmationMenu({ confirmTools, theme, onChange }: { confirmTools:
items: [ items: [
{ {
key: "manual", key: "manual",
label: <ConfirmationOption icon={<Hand className="size-4" />} title="手动确认" description="Agent 执行画布写入前会请求确认" selected={confirmTools} />, label: <ConfirmationOption icon={<Hand className="size-4" />} title={t("agent.composer.tools.manual")} description={t("agent.composer.tools.manualDescription")} selected={confirmTools} />,
onClick: () => onChange(true), onClick: () => onChange(true),
}, },
{ {
key: "automatic", key: "automatic",
label: <ConfirmationOption icon={<RefreshCw className="size-4" />} title="自动确认" description="Agent 会自动执行画布写入操作" selected={!confirmTools} />, label: <ConfirmationOption icon={<RefreshCw className="size-4" />} title={t("agent.composer.tools.automatic")} description={t("agent.composer.tools.automaticDescription")} selected={!confirmTools} />,
onClick: () => onChange(false), onClick: () => onChange(false),
}, },
], ],
}} }}
> >
<button type="button" className="flex h-9 w-9 min-w-9 shrink-0 items-center justify-center gap-0 rounded-full px-0 text-xs font-medium transition hover:bg-black/5 @min-[660px]:w-auto @min-[660px]:min-w-0 @min-[660px]:justify-start @min-[660px]:gap-1.5 @min-[660px]:px-2.5 dark:hover:bg-white/10" style={{ color: theme.node.text }} aria-label={`选择工具确认模式,当前为 ${confirmTools ? "手动确认" : "自动确认"}`}> <button type="button" className="flex h-9 w-9 min-w-9 shrink-0 items-center justify-center gap-0 rounded-full px-0 text-xs font-medium transition hover:bg-black/5 @min-[660px]:w-auto @min-[660px]:min-w-0 @min-[660px]:justify-start @min-[660px]:gap-1.5 @min-[660px]:px-2.5 dark:hover:bg-white/10" style={{ color: theme.node.text }} aria-label={t("agent.composer.tools.select", { mode })}>
{confirmTools ? <Hand className="size-3.5" /> : <RefreshCw className="size-3.5" />} {confirmTools ? <Hand className="size-3.5" /> : <RefreshCw className="size-3.5" />}
<span className="hidden @min-[660px]:inline">{confirmTools ? "手动确认" : "自动确认"}</span> <span className="hidden @min-[660px]:inline">{mode}</span>
<ChevronUp className="hidden size-3 opacity-50 @min-[660px]:block" /> <ChevronUp className="hidden size-3 opacity-50 @min-[660px]:block" />
</button> </button>
</Dropdown> </Dropdown>
+60 -53
View File
@@ -2,30 +2,27 @@ import { useEffect, useId, useState, type ReactNode } from "react";
import { App, Button, Image, Modal } from "antd"; import { App, Button, Image, Modal } from "antd";
import { Brain, CheckCircle2, ChevronDown, ChevronRight, Circle, CircleAlert, Copy, ExternalLink, FilePenLine, FileText, FolderOpen, ListChecks, LoaderCircle, Search, ShieldAlert, TerminalSquare, Wrench, XCircle } from "lucide-react"; import { Brain, CheckCircle2, ChevronDown, ChevronRight, Circle, CircleAlert, Copy, ExternalLink, FilePenLine, FileText, FolderOpen, ListChecks, LoaderCircle, Search, ShieldAlert, TerminalSquare, Wrench, XCircle } from "lucide-react";
import { Streamdown, type LinkSafetyModalProps } from "streamdown"; import { Streamdown, type LinkSafetyModalProps } from "streamdown";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n";
import { useCopyText } from "@/hooks/use-copy-text"; import { useCopyText } from "@/hooks/use-copy-text";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { useAgentStore, type AgentPendingApproval } from "@/stores/use-agent-store"; import { useAgentStore, type AgentPendingApproval } from "@/stores/use-agent-store";
import { revealAgentLocalFile } from "@/services/api/canvas-agent"; import { revealAgentLocalFile } from "@/services/api/canvas-agent";
const streamdownProps = { const streamdownProps = () => ({
className: "agent-streamdown", className: "agent-streamdown",
controls: { code: { copy: true, download: false }, table: { copy: true, download: false, fullscreen: false } }, controls: { code: { copy: true, download: false }, table: { copy: true, download: false, fullscreen: false } },
linkSafety: { enabled: true, renderModal: (props: LinkSafetyModalProps) => <AgentLinkModal {...props} /> }, linkSafety: { enabled: true, renderModal: (props: LinkSafetyModalProps) => <AgentLinkModal {...props} /> },
lineNumbers: false, lineNumbers: false,
translations: { translations: {
close: "关闭", close: tr("close"), copied: tr("copied"), copyCode: tr("copyCode"), copyLink: tr("copyLink"), externalLinkWarning: tr("externalWarning"), openExternalLink: tr("openExternal"), openLink: tr("continueOpen"),
copied: "已复制",
copyCode: "复制代码",
copyLink: "复制链接",
externalLinkWarning: "即将打开以下外部链接,请确认链接可信。",
openExternalLink: "打开外部链接?",
openLink: "继续打开",
}, },
} as const; } as const);
const streamdownAnimation = { duration: 20, stagger: 0, sep: "word" } as const; const streamdownAnimation = { duration: 20, stagger: 0, sep: "word" } as const;
function AgentLinkModal({ isOpen, onClose, onConfirm, url }: LinkSafetyModalProps) { function AgentLinkModal({ isOpen, onClose, onConfirm, url }: LinkSafetyModalProps) {
const { t } = useTranslation();
const { message } = App.useApp(); const { message } = App.useApp();
const copyText = useCopyText(); const copyText = useCopyText();
const localPath = localFilePath(url); const localPath = localFilePath(url);
@@ -36,26 +33,26 @@ function AgentLinkModal({ isOpen, onClose, onConfirm, url }: LinkSafetyModalProp
setOpening(true); setOpening(true);
try { try {
await revealAgentLocalFile(endpoint, token, localPath); await revealAgentLocalFile(endpoint, token, localPath);
message.success("已在文件管理器中定位"); message.success(t("agent.message.revealed"));
onClose(); onClose();
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : "无法打开本地文件"); message.error(error instanceof Error ? error.message : t("agent.message.openLocalFailed"));
} finally { } finally {
setOpening(false); setOpening(false);
} }
}; };
return ( return (
<Modal open={isOpen} onCancel={onClose} footer={null} centered width={420} title={localPath ? "打开本地文件?" : "打开外部链接?"}> <Modal open={isOpen} onCancel={onClose} footer={null} centered width={420} title={t(localPath ? "agent.message.openLocal" : "agent.message.openExternal")}>
<div className="text-sm text-black/55 dark:text-white/55"> <div className="text-sm text-black/55 dark:text-white/55">
{localPath ? "将在本机文件管理器中定位该路径,不会通过浏览器打开。" : "即将打开以下外部链接,请确认链接可信。"} {t(localPath ? "agent.message.localDescription" : "agent.message.externalDescription")}
</div> </div>
<div className="mt-4 max-h-32 overflow-auto break-all rounded-lg bg-black/[.035] px-3 py-2.5 font-mono text-xs leading-5 dark:bg-white/[.06]">{localPath || url}</div> <div className="mt-4 max-h-32 overflow-auto break-all rounded-lg bg-black/[.035] px-3 py-2.5 font-mono text-xs leading-5 dark:bg-white/[.06]">{localPath || url}</div>
<div className="mt-5 flex justify-end gap-2"> <div className="mt-5 flex justify-end gap-2">
<Button type="text" icon={<Copy className="size-4" />} onClick={() => copyText(localPath || url, localPath ? "路径已复制" : "链接已复制")}> <Button type="text" icon={<Copy className="size-4" />} onClick={() => copyText(localPath || url, t(localPath ? "agent.message.pathCopied" : "agent.message.linkCopied"))}>
{localPath ? "复制路径" : "复制链接"} {t(localPath ? "agent.message.copyPath" : "agent.message.copyLink")}
</Button> </Button>
<Button type="text" loading={opening} icon={localPath ? <FolderOpen className="size-4" /> : <ExternalLink className="size-4" />} onClick={open}> <Button type="text" loading={opening} icon={localPath ? <FolderOpen className="size-4" /> : <ExternalLink className="size-4" />} onClick={open}>
{localPath ? "在文件管理器中显示" : "继续打开"} {t(localPath ? "agent.message.showInFolder" : "agent.message.continueOpen")}
</Button> </Button>
</div> </div>
</Modal> </Modal>
@@ -117,7 +114,7 @@ export function AgentChatMessage({ item, theme, onRejectTool, onApproveTool }: {
} }
if (item.role === "tool") { if (item.role === "tool") {
if (objectField(item.detail, "status") === "pending") return <AgentPendingToolCard summary={item.text} detail={item.detail} theme={theme} onReject={() => onRejectTool?.(item.id)} onApprove={() => onApproveTool?.(item.id)} />; if (objectField(item.detail, "status") === "pending") return <AgentPendingToolCard summary={item.text} detail={item.detail} theme={theme} onReject={() => onRejectTool?.(item.id)} onApprove={() => onApproveTool?.(item.id)} />;
return <AgentToolCard title={item.title || "工具调用"} text={item.text} detail={item.detail} theme={theme} />; return <AgentToolCard title={item.title || tr("toolCall")} text={item.text} detail={item.detail} theme={theme} />;
} }
return ( return (
<div className={`flex ${isUser ? "justify-end" : "justify-start"}`}> <div className={`flex ${isUser ? "justify-end" : "justify-start"}`}>
@@ -128,7 +125,7 @@ export function AgentChatMessage({ item, theme, onRejectTool, onApproveTool }: {
{isUser ? ( {isUser ? (
<div className="whitespace-pre-wrap break-words">{item.text}</div> <div className="whitespace-pre-wrap break-words">{item.text}</div>
) : ( ) : (
<Streamdown {...streamdownProps} animated={streamdownAnimation} isAnimating={!!item.streamId}>{item.text}</Streamdown> <Streamdown {...streamdownProps()} animated={streamdownAnimation} isAnimating={!!item.streamId}>{item.text}</Streamdown>
)} )}
{item.attachments?.length ? <AgentMessageAttachments attachments={item.attachments} alignRight={isUser} /> : null} {item.attachments?.length ? <AgentMessageAttachments attachments={item.attachments} alignRight={isUser} /> : null}
{item.meta ? <div className={`mt-1 text-[11px] tabular-nums opacity-55 ${isUser ? "text-right" : ""}`}>{item.meta}</div> : null} {item.meta ? <div className={`mt-1 text-[11px] tabular-nums opacity-55 ${isUser ? "text-right" : ""}`}>{item.meta}</div> : null}
@@ -138,6 +135,7 @@ export function AgentChatMessage({ item, theme, onRejectTool, onApproveTool }: {
} }
export function AgentPendingToolCard({ summary, detail, theme, onReject, onApprove }: { summary: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onReject?: () => void; onApprove?: () => void }) { export function AgentPendingToolCard({ summary, detail, theme, onReject, onApprove }: { summary: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onReject?: () => void; onApprove?: () => void }) {
const { t } = useTranslation();
const view = userDetail(detail); const view = userDetail(detail);
return ( return (
<div className="min-w-0 rounded-xl border px-3 py-3" style={{ borderColor: "rgba(217,119,6,.28)", background: "rgba(217,119,6,.025)", color: theme.node.text }}> <div className="min-w-0 rounded-xl border px-3 py-3" style={{ borderColor: "rgba(217,119,6,.28)", background: "rgba(217,119,6,.025)", color: theme.node.text }}>
@@ -145,7 +143,7 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
<summary className={`list-none ${view ? "cursor-pointer" : "cursor-default"}`} onClick={(event) => { if (!view) event.preventDefault(); }}> <summary className={`list-none ${view ? "cursor-pointer" : "cursor-default"}`} onClick={(event) => { if (!view) event.preventDefault(); }}>
<div className="flex min-w-0 items-center gap-2 text-sm font-medium leading-5"> <div className="flex min-w-0 items-center gap-2 text-sm font-medium leading-5">
<CircleAlert className="size-4 shrink-0 text-amber-600" /> <CircleAlert className="size-4 shrink-0 text-amber-600" />
<span className="min-w-0 flex-1"></span> <span className="min-w-0 flex-1">{t("agent.message.awaitingConfirmation")}</span>
{view ? <ChevronDown className="size-3.5 shrink-0 transition-transform group-open:rotate-180" style={{ color: theme.node.muted }} /> : null} {view ? <ChevronDown className="size-3.5 shrink-0 transition-transform group-open:rotate-180" style={{ color: theme.node.muted }} /> : null}
</div> </div>
<div className="mt-1 pl-6 text-sm leading-5" style={{ color: theme.node.muted }}>{summary}</div> <div className="mt-1 pl-6 text-sm leading-5" style={{ color: theme.node.muted }}>{summary}</div>
@@ -155,10 +153,10 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
{onReject || onApprove ? ( {onReject || onApprove ? (
<div className="mt-3 flex justify-end gap-2 border-t pt-3" style={{ borderColor: theme.node.stroke }}> <div className="mt-3 flex justify-end gap-2 border-t pt-3" style={{ borderColor: theme.node.stroke }}>
<Button danger type="text" className="!h-8" icon={<XCircle className="size-3.5" />} onClick={() => onReject?.()}> <Button danger type="text" className="!h-8" icon={<XCircle className="size-3.5" />} onClick={() => onReject?.()}>
{t("agent.message.reject")}
</Button> </Button>
<Button type="text" className="!h-8" icon={<CheckCircle2 className="size-3.5" />} style={{ color: "#16a34a" }} onClick={() => onApprove?.()}> <Button type="text" className="!h-8" icon={<CheckCircle2 className="size-3.5" />} style={{ color: "#16a34a" }} onClick={() => onApprove?.()}>
{t("agent.message.approve")}
</Button> </Button>
</div> </div>
) : null} ) : null}
@@ -167,9 +165,10 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
} }
export function AgentApprovalCard({ approval, theme, onDecision }: { approval: AgentPendingApproval; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onDecision: (decision: "accept" | "acceptForSession" | "decline") => void }) { export function AgentApprovalCard({ approval, theme, onDecision }: { approval: AgentPendingApproval; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onDecision: (decision: "accept" | "acceptForSession" | "decline") => void }) {
const { t } = useTranslation();
const isFile = approval.method === "item/fileChange/requestApproval"; const isFile = approval.method === "item/fileChange/requestApproval";
const isNetwork = Boolean(approval.networkApprovalContext); const isNetwork = Boolean(approval.networkApprovalContext);
const title = isNetwork ? "请求网络访问" : isFile ? "请求编辑文件" : approval.method === "item/permissions/requestApproval" ? "请求扩展权限" : "请求执行命令"; const title = t(isNetwork ? "agent.message.networkApproval" : isFile ? "agent.message.fileApproval" : approval.method === "item/permissions/requestApproval" ? "agent.message.permissionApproval" : "agent.message.commandApproval");
const target = isNetwork ? approvalTarget(approval.networkApprovalContext) : isFile ? approval.grantRoot || approval.cwd : commandText(approval.command) || approval.cwd; const target = isNetwork ? approvalTarget(approval.networkApprovalContext) : isFile ? approval.grantRoot || approval.cwd : commandText(approval.command) || approval.cwd;
return ( return (
<div className="min-w-0 rounded-xl border px-3 py-3" style={{ borderColor: "rgba(234,88,12,.32)", background: "rgba(234,88,12,.035)", color: theme.node.text }}> <div className="min-w-0 rounded-xl border px-3 py-3" style={{ borderColor: "rgba(234,88,12,.32)", background: "rgba(234,88,12,.035)", color: theme.node.text }}>
@@ -182,9 +181,9 @@ export function AgentApprovalCard({ approval, theme, onDecision }: { approval: A
</div> </div>
</div> </div>
<div className="mt-3 flex flex-wrap justify-end gap-1.5 border-t pt-3" style={{ borderColor: theme.node.stroke }}> <div className="mt-3 flex flex-wrap justify-end gap-1.5 border-t pt-3" style={{ borderColor: theme.node.stroke }}>
<Button danger type="text" className="!h-8" disabled={Boolean(approval.deciding)} loading={approval.deciding === "decline"} onClick={() => onDecision("decline")}></Button> <Button danger type="text" className="!h-8" disabled={Boolean(approval.deciding)} loading={approval.deciding === "decline"} onClick={() => onDecision("decline")}>{t("agent.message.decline")}</Button>
<Button type="text" className="!h-8" disabled={Boolean(approval.deciding)} loading={approval.deciding === "accept"} onClick={() => onDecision("accept")}></Button> <Button type="text" className="!h-8" disabled={Boolean(approval.deciding)} loading={approval.deciding === "accept"} onClick={() => onDecision("accept")}>{t("agent.message.allowOnce")}</Button>
<Button type="text" className="!h-8" disabled={Boolean(approval.deciding)} loading={approval.deciding === "acceptForSession"} style={{ color: "#ea580c" }} onClick={() => onDecision("acceptForSession")}></Button> <Button type="text" className="!h-8" disabled={Boolean(approval.deciding)} loading={approval.deciding === "acceptForSession"} style={{ color: "#ea580c" }} onClick={() => onDecision("acceptForSession")}>{t("agent.message.allowSession")}</Button>
</div> </div>
</div> </div>
); );
@@ -226,6 +225,7 @@ export function AgentToolCard({ title, text, detail, theme }: { title: string; t
} }
function AgentReasoningSummary({ text, detail, theme }: { text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { function AgentReasoningSummary({ text, detail, theme }: { text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const { t } = useTranslation();
const status = String(objectField(detail, "status") || ""); const status = String(objectField(detail, "status") || "");
const running = ["inProgress", "in_progress", "running", "started", "pending"].includes(status); const running = ["inProgress", "in_progress", "running", "started", "pending"].includes(status);
return ( return (
@@ -233,12 +233,12 @@ function AgentReasoningSummary({ text, detail, theme }: { text: string; detail?:
<summary className="cursor-pointer list-none py-1 text-sm" style={{ color: theme.node.muted }}> <summary className="cursor-pointer list-none py-1 text-sm" style={{ color: theme.node.muted }}>
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
{running ? <LoaderCircle className="size-4 shrink-0 animate-spin" /> : <Brain className="size-4 shrink-0" />} {running ? <LoaderCircle className="size-4 shrink-0 animate-spin" /> : <Brain className="size-4 shrink-0" />}
<span>{running ? "正在思考" : "思考摘要"}</span> <span>{t(running ? "agent.message.thinking" : "agent.events.reasoning")}</span>
<ChevronRight className="size-3.5 shrink-0 transition-transform group-open:rotate-90" /> <ChevronRight className="size-3.5 shrink-0 transition-transform group-open:rotate-90" />
</div> </div>
</summary> </summary>
<div className="break-words pb-1 pl-6 pr-2 text-xs leading-5 [&_code]:rounded [&_code]:px-1 [&_p]:my-1 [&_pre]:my-2" style={{ color: theme.node.muted }}> <div className="break-words pb-1 pl-6 pr-2 text-xs leading-5 [&_code]:rounded [&_code]:px-1 [&_p]:my-1 [&_pre]:my-2" style={{ color: theme.node.muted }}>
<Streamdown {...streamdownProps} animated={streamdownAnimation} isAnimating={running}>{text}</Streamdown> <Streamdown {...streamdownProps()} animated={streamdownAnimation} isAnimating={running}>{text}</Streamdown>
</div> </div>
</details> </details>
); );
@@ -247,14 +247,13 @@ function AgentReasoningSummary({ text, detail, theme }: { text: string; detail?:
type AgentCommandItem = Pick<AgentChatMessageItem, "id" | "text" | "detail">; type AgentCommandItem = Pick<AgentChatMessageItem, "id" | "text" | "detail">;
export function AgentCommandGroup({ items, theme }: { items: AgentCommandItem[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { export function AgentCommandGroup({ items, theme }: { items: AgentCommandItem[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const { t } = useTranslation();
const states = items.map((item) => commandViewState(item.detail)); const states = items.map((item) => commandViewState(item.detail));
const running = states.some((state) => state.running); const running = states.some((state) => state.running);
const failed = states.filter((state) => state.failed).length; const failed = states.filter((state) => state.failed).length;
const expandable = items.some((item) => Boolean(item.text.trim() || userDetail(item.detail))); const expandable = items.some((item) => Boolean(item.text.trim() || userDetail(item.detail)));
const color = running ? "#d97706" : failed ? "#dc2626" : theme.node.muted; const color = running ? "#d97706" : failed ? "#dc2626" : theme.node.muted;
const label = running const label = running ? t(items.length > 1 ? "agent.message.commandsRunning" : "agent.message.commandRunning", { count: items.length }) : t("agent.message.commandsCompleted", { count: items.length, failed: failed ? t("agent.message.commandsFailed", { count: failed }) : "" });
? items.length > 1 ? `正在执行 ${items.length} 条命令` : "正在执行命令"
: `已执行 ${items.length} 条命令${failed ? ` · ${failed} 条失败` : ""}`;
const header = ( const header = (
<div className="flex min-w-0 items-center gap-2 text-sm" style={{ color }}> <div className="flex min-w-0 items-center gap-2 text-sm" style={{ color }}>
{running ? <LoaderCircle className="size-4 shrink-0 animate-spin" /> : <TerminalSquare className="size-4 shrink-0" />} {running ? <LoaderCircle className="size-4 shrink-0 animate-spin" /> : <TerminalSquare className="size-4 shrink-0" />}
@@ -285,16 +284,17 @@ function AgentSingleCommand({ item, theme }: { item: AgentCommandItem; theme: (t
} }
function AgentCommandEntry({ item, index, theme }: { item: AgentCommandItem; index: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { function AgentCommandEntry({ item, index, theme }: { item: AgentCommandItem; index: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const detailId = useId(); const detailId = useId();
const view = userDetail(item.detail); const view = userDetail(item.detail);
const state = commandViewState(item.detail); const state = commandViewState(item.detail);
const status = state.failed ? "执行失败" : state.running ? "执行中" : "已完成"; const status = t(state.failed ? "agent.message.failed" : state.running ? "agent.message.running" : "agent.message.completed");
const color = state.failed ? "#dc2626" : state.running ? "#d97706" : "#16a34a"; const color = state.failed ? "#dc2626" : state.running ? "#d97706" : "#16a34a";
const content = ( const content = (
<> <>
<span className="w-4 shrink-0 text-center text-[10px] tabular-nums opacity-50" style={{ color: theme.node.muted }}>{index + 1}</span> <span className="w-4 shrink-0 text-center text-[10px] tabular-nums opacity-50" style={{ color: theme.node.muted }}>{index + 1}</span>
<code className="min-w-0 flex-1 truncate text-[11px] leading-5" style={{ color: theme.node.text }} title={item.text}>{item.text || "命令"}</code> <code className="min-w-0 flex-1 truncate text-[11px] leading-5" style={{ color: theme.node.text }} title={item.text}>{item.text || t("agent.message.command")}</code>
<span className="shrink-0" style={{ color }} title={status} aria-label={status}> <span className="shrink-0" style={{ color }} title={status} aria-label={status}>
{state.running ? <LoaderCircle className="size-3.5 animate-spin" /> : state.failed ? <XCircle className="size-3.5" /> : <CheckCircle2 className="size-3.5" />} {state.running ? <LoaderCircle className="size-3.5 animate-spin" /> : state.failed ? <XCircle className="size-3.5" /> : <CheckCircle2 className="size-3.5" />}
</span> </span>
@@ -350,6 +350,7 @@ function AgentPlanCard({ title, plan, theme }: { title: string; plan: PlanDetail
} }
export function AgentWorkingMessage({ text, detail, status = "running", mcpStatuses = [], activityKey, theme }: { text: string; detail?: string; status?: "running" | "ready" | "error"; mcpStatuses?: Array<{ name: string; status: "running" | "ready" | "error"; detail: string }>; activityKey: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { export function AgentWorkingMessage({ text, detail, status = "running", mcpStatuses = [], activityKey, theme }: { text: string; detail?: string; status?: "running" | "ready" | "error"; mcpStatuses?: Array<{ name: string; status: "running" | "ready" | "error"; detail: string }>; activityKey: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const { t } = useTranslation();
const [elapsed, setElapsed] = useState(0); const [elapsed, setElapsed] = useState(0);
useEffect(() => { useEffect(() => {
const startedAt = Date.now(); const startedAt = Date.now();
@@ -378,15 +379,15 @@ export function AgentWorkingMessage({ text, detail, status = "running", mcpStatu
))} ))}
</div> </div>
) : null} ) : null}
{status === "running" && elapsed >= 30 ? <div className="mt-1 text-xs leading-5 opacity-65" style={{ color: theme.node.muted }}></div> : null} {status === "running" && elapsed >= 30 ? <div className="mt-1 text-xs leading-5 opacity-65" style={{ color: theme.node.muted }}>{t("agent.message.slowResponse")}</div> : null}
</div> </div>
); );
} }
function waitingTime(seconds: number) { function waitingTime(seconds: number) {
if (seconds < 60) return `已等待 ${seconds}`; if (seconds < 60) return tr("waitingSeconds", { seconds });
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
return `已等待 ${minutes}${seconds % 60}`; return tr("waitingMinutes", { minutes, seconds: seconds % 60 });
} }
function commandText(value: unknown) { function commandText(value: unknown) {
@@ -406,6 +407,7 @@ type PlanDetail = { status: string; tasks: PlanTask[]; explanation?: string };
type UserDetail = { kind?: string; status?: string; rows?: Array<{ label: string; value: string }>; output?: string; files?: Array<{ path: string; action?: string }> }; type UserDetail = { kind?: string; status?: string; rows?: Array<{ label: string; value: string }>; output?: string; files?: Array<{ path: string; action?: string }> };
function AgentDetailBlock({ detail, theme }: { detail: UserDetail; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { function AgentDetailBlock({ detail, theme }: { detail: UserDetail; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const { t } = useTranslation();
return ( return (
<div className="mt-3 space-y-2.5 border-t pt-3 text-xs" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}> <div className="mt-3 space-y-2.5 border-t pt-3 text-xs" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
{detail.rows?.length ? ( {detail.rows?.length ? (
@@ -420,7 +422,7 @@ function AgentDetailBlock({ detail, theme }: { detail: UserDetail; theme: (typeo
) : null} ) : null}
{detail.files?.length ? ( {detail.files?.length ? (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="opacity-60"></div> <div className="opacity-60">{t("agent.message.files")}</div>
{detail.files.map((file) => ( {detail.files.map((file) => (
<div key={`${file.action}-${file.path}`} className="flex items-start gap-2"> <div key={`${file.action}-${file.path}`} className="flex items-start gap-2">
<FileText className="mt-0.5 size-3.5 shrink-0" /> <FileText className="mt-0.5 size-3.5 shrink-0" />
@@ -432,7 +434,7 @@ function AgentDetailBlock({ detail, theme }: { detail: UserDetail; theme: (typeo
) : null} ) : null}
{detail.output ? ( {detail.output ? (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="opacity-60">{detail.status === "failed" || detail.status === "error" ? "错误信息" : "运行输出"}</div> <div className="opacity-60">{t(detail.status === "failed" || detail.status === "error" ? "agent.message.errorInfo" : "agent.message.output")}</div>
<pre className="thin-scrollbar max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-lg px-3 py-2 font-mono text-[11px] leading-4" style={{ background: theme.toolbar.panel, color: theme.node.text }}>{detail.output}</pre> <pre className="thin-scrollbar max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-lg px-3 py-2 font-mono text-[11px] leading-4" style={{ background: theme.toolbar.panel, color: theme.node.text }}>{detail.output}</pre>
</div> </div>
) : null} ) : null}
@@ -441,6 +443,7 @@ function AgentDetailBlock({ detail, theme }: { detail: UserDetail; theme: (typeo
} }
function AgentMessageAttachments({ attachments, alignRight }: { attachments: AgentChatAttachment[]; alignRight?: boolean }) { function AgentMessageAttachments({ attachments, alignRight }: { attachments: AgentChatAttachment[]; alignRight?: boolean }) {
const { t } = useTranslation();
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
return ( return (
<> <>
@@ -450,7 +453,7 @@ function AgentMessageAttachments({ attachments, alignRight }: { attachments: Age
key={item.id} key={item.id}
src={item.url} src={item.url}
alt={item.name} alt={item.name}
title="点击查看大图" title={t("agent.message.viewLarge")}
className="size-10 cursor-zoom-in rounded-lg object-cover" className="size-10 cursor-zoom-in rounded-lg object-cover"
draggable={false} draggable={false}
onClick={() => setPreviewUrl(item.url)} onClick={() => setPreviewUrl(item.url)}
@@ -459,7 +462,7 @@ function AgentMessageAttachments({ attachments, alignRight }: { attachments: Age
</div> </div>
{previewUrl ? ( {previewUrl ? (
<div className="hidden"> <div className="hidden">
<Image src={previewUrl} alt="图片附件预览" preview={{ visible: true, src: previewUrl, onVisibleChange: (visible) => !visible && setPreviewUrl(null) }} /> <Image src={previewUrl} alt={t("agent.message.attachmentPreview")} preview={{ visible: true, src: previewUrl, onVisibleChange: (visible) => !visible && setPreviewUrl(null) }} />
</div> </div>
) : null} ) : null}
</> </>
@@ -470,12 +473,12 @@ function toolCardState(title: string, text: string, detail?: unknown) {
const raw = `${title} ${text} ${normalizeText(objectField(detail, "error"))}`; const raw = `${title} ${text} ${normalizeText(objectField(detail, "error"))}`;
const lower = raw.toLowerCase(); const lower = raw.toLowerCase();
const status = String(objectField(detail, "status") || "").toLowerCase(); const status = String(objectField(detail, "status") || "").toLowerCase();
if (status === "noop" || /未生效|无需|没有找到|没有.*可|已存在/.test(raw)) return { label: "未生效", color: "#d97706", icon: <CircleAlert className="size-4" />, isError: false }; if (status === "noop" || /未生效|无需|没有找到|没有.*可|已存在/.test(raw)) return { label: tr("noEffect"), color: "#d97706", icon: <CircleAlert className="size-4" />, isError: false };
if (["declined", "rejected", "cancelled", "canceled"].includes(status) || /拒绝|取消/.test(raw)) return { label: "已取消", color: "#dc2626", icon: <XCircle className="size-4" />, isError: true }; if (["declined", "rejected", "cancelled", "canceled"].includes(status) || /拒绝|取消/.test(raw)) return { label: tr("canceled"), color: "#dc2626", icon: <XCircle className="size-4" />, isError: true };
if (["failed", "error"].includes(status) || /失败|错误/.test(raw) || lower.includes("failed") || lower.includes("error")) return { label: "执行失败", color: "#dc2626", icon: <XCircle className="size-4" />, isError: true }; if (["failed", "error"].includes(status) || /失败|错误/.test(raw) || lower.includes("failed") || lower.includes("error")) return { label: tr("failed"), color: "#dc2626", icon: <XCircle className="size-4" />, isError: true };
if (["inprogress", "in_progress", "running", "started", "pending"].includes(status)) return { label: "进行中", color: "#d97706", icon: <LoaderCircle className="size-4 animate-spin" />, isError: false }; if (["inprogress", "in_progress", "running", "started", "pending"].includes(status)) return { label: tr("running"), color: "#d97706", icon: <LoaderCircle className="size-4 animate-spin" />, isError: false };
if (["completed", "succeeded", "success"].includes(status) || /完成|成功/.test(raw)) return { label: "已完成", color: "#16a34a", icon: <CheckCircle2 className="size-4" />, isError: false }; if (["completed", "succeeded", "success"].includes(status) || /完成|成功/.test(raw)) return { label: tr("completed"), color: "#16a34a", icon: <CheckCircle2 className="size-4" />, isError: false };
return { label: "已记录", color: "#2563eb", icon: <Wrench className="size-4" />, isError: false }; return { label: tr("recorded"), color: "#2563eb", icon: <Wrench className="size-4" />, isError: false };
} }
function toolIcon(kind: string | undefined, fallback: ReactNode) { function toolIcon(kind: string | undefined, fallback: ReactNode) {
@@ -486,17 +489,17 @@ function toolIcon(kind: string | undefined, fallback: ReactNode) {
} }
function planCardState(plan: PlanDetail, completed: number) { function planCardState(plan: PlanDetail, completed: number) {
if (plan.status === "failed") return { label: "执行失败", color: "#dc2626" }; if (plan.status === "failed") return { label: tr("failed"), color: "#dc2626" };
if (["interrupted", "cancelled", "canceled"].includes(plan.status)) return { label: "已停止", color: "#d97706" }; if (["interrupted", "cancelled", "canceled"].includes(plan.status)) return { label: tr("stopped"), color: "#d97706" };
if (completed === plan.tasks.length) return { label: "已完成", color: "#16a34a" }; if (completed === plan.tasks.length) return { label: tr("completed"), color: "#16a34a" };
if (plan.status === "finished") return { label: "已结束", color: "#2563eb" }; if (plan.status === "finished") return { label: tr("finished"), color: "#2563eb" };
return { label: "进行中", color: "#d97706" }; return { label: tr("running"), color: "#d97706" };
} }
function planTaskState(status: string, muted: string) { function planTaskState(status: string, muted: string) {
if (status === "completed") return { label: "已完成", color: "#16a34a", icon: <CheckCircle2 className="size-3.5" /> }; if (status === "completed") return { label: tr("completed"), color: "#16a34a", icon: <CheckCircle2 className="size-3.5" /> };
if (status === "inProgress") return { label: "进行中", color: "#d97706", icon: <LoaderCircle className="size-3.5 animate-spin" /> }; if (status === "inProgress") return { label: tr("running"), color: "#d97706", icon: <LoaderCircle className="size-3.5 animate-spin" /> };
return { label: "待处理", color: muted, icon: <Circle className="size-3.5" /> }; return { label: tr("pending"), color: muted, icon: <Circle className="size-3.5" /> };
} }
function planDetail(value: unknown): PlanDetail | null { function planDetail(value: unknown): PlanDetail | null {
@@ -546,3 +549,7 @@ function normalizeText(value: unknown) {
function objectField(value: unknown, key: string) { function objectField(value: unknown, key: string) {
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined; return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
} }
function tr(key: string, options?: Record<string, unknown>) {
return i18n.t(`agent.message.${key}`, options);
}
+10 -6
View File
@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { motion, useSpring, useTransform } from "motion/react"; import { motion, useSpring, useTransform } from "motion/react";
import { useTranslation } from "react-i18next";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { summarizeCanvasAgentOps } from "@/lib/canvas/canvas-agent-ops"; import { summarizeCanvasAgentOps } from "@/lib/canvas/canvas-agent-ops";
@@ -30,6 +31,7 @@ export function AgentChatTimeline({
onApproveTool: () => void; onApproveTool: () => void;
onApprovalDecision: (approval: AgentPendingApproval, decision: "accept" | "acceptForSession" | "decline") => void; onApprovalDecision: (approval: AgentPendingApproval, decision: "accept" | "acceptForSession" | "decline") => void;
}) { }) {
const { t } = useTranslation();
const messages = useAgentStore((state) => state.messages); const messages = useAgentStore((state) => state.messages);
const bootstrapStatus = useAgentStore((state) => state.bootstrapStatus); const bootstrapStatus = useAgentStore((state) => state.bootstrapStatus);
const mcpStartupStatuses = useAgentStore((state) => state.mcpStartupStatuses); const mcpStartupStatuses = useAgentStore((state) => state.mcpStartupStatuses);
@@ -94,18 +96,19 @@ export function AgentChatTimeline({
</div> </div>
</div> </div>
{showScrollToBottom ? ( {showScrollToBottom ? (
<AgentScrollToBottom theme={theme} title="查看最新消息" onClick={() => scrollToBottom()} /> <AgentScrollToBottom theme={theme} title={t("agent.chat.latestMessages")} onClick={() => scrollToBottom()} />
) : null} ) : null}
</div> </div>
); );
} }
export function AgentTaskProgress({ theme, busy }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; busy: boolean }) { export function AgentTaskProgress({ theme, busy }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; busy: boolean }) {
const { t } = useTranslation();
const plan = useAgentStore((state) => busy ? currentPlanMessage(state.messages) : latestPlanMessage(state.messages)); const plan = useAgentStore((state) => busy ? currentPlanMessage(state.messages) : latestPlanMessage(state.messages));
if (!plan) return null; if (!plan) return null;
return ( return (
<div className="shrink-0 px-4 pt-2"> <div className="shrink-0 px-4 pt-2">
<AgentToolCard key={plan.id} title={plan.title || "任务进度"} text={plan.text} detail={plan.detail} theme={theme} /> <AgentToolCard key={plan.id} title={plan.title || t("agent.events.progress")} text={plan.text} detail={plan.detail} theme={theme} />
</div> </div>
); );
} }
@@ -159,12 +162,13 @@ function isCommandMessage(item: AgentChatItem) {
} }
export function AgentUsageBar({ usage, theme }: { usage: AgentTokenUsage; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { export function AgentUsageBar({ usage, theme }: { usage: AgentTokenUsage; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const { t } = useTranslation();
return ( return (
<div className="flex items-center justify-center gap-4 px-4 pt-1 text-[11px] tabular-nums" style={{ color: theme.node.muted }}> <div className="flex items-center justify-center gap-4 px-4 pt-1 text-[11px] tabular-nums" style={{ color: theme.node.muted }}>
<span className="opacity-70"></span> <span className="opacity-70">{t("agent.chat.latestCall")}</span>
<UsageNumber label="输入" value={usage.input} color={theme.node.text} /> <UsageNumber label={t("agent.chat.input")} value={usage.input} color={theme.node.text} />
<UsageNumber label="缓存" value={usage.cached} color={theme.node.text} /> <UsageNumber label={t("agent.chat.cached")} value={usage.cached} color={theme.node.text} />
<UsageNumber label="输出" value={usage.output} color={theme.node.text} /> <UsageNumber label={t("agent.chat.output")} value={usage.output} color={theme.node.text} />
</div> </div>
); );
} }
+21 -23
View File
@@ -2,13 +2,10 @@ import { Fragment } from "react";
import { App, Button, Input, Tooltip } from "antd"; import { App, Button, Input, Tooltip } from "antd";
import copyToClipboard from "copy-to-clipboard"; import copyToClipboard from "copy-to-clipboard";
import { Copy, KeyRound, Link2, PlugZap } from "lucide-react"; import { Copy, KeyRound, Link2, PlugZap } from "lucide-react";
import { useTranslation } from "react-i18next";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
const AGENT_CONNECT_STEPS = [
{ title: "方式一:在 Codex 中使用插件", text: "在 Codex app 安装 Infinite Canvas 插件后,通过插件启动画布,插件会自动启动本地 Agent 并带上连接信息。" },
{ title: "方式二:直接运行 Agent", text: "不使用 Codex 插件时,在终端运行下面命令,再回到网页里连接或手动填入 Local URL 和 Connect token。", command: "npx -y @basketikun/canvas-agent" },
];
const AGENT_PLUGIN_REMOVE_COMMAND = "codex plugin remove infinite-canvas"; const AGENT_PLUGIN_REMOVE_COMMAND = "codex plugin remove infinite-canvas";
const AGENT_MCP_REMOVE_COMMAND = "codex mcp remove infinite-canvas"; const AGENT_MCP_REMOVE_COMMAND = "codex mcp remove infinite-canvas";
@@ -35,30 +32,32 @@ export function AgentConnectView({
onTokenChange: (value: string) => void; onTokenChange: (value: string) => void;
onToggleEnabled: () => void; onToggleEnabled: () => void;
}) { }) {
const { t } = useTranslation();
const { message } = App.useApp(); const { message } = App.useApp();
const statusText = connectError ? "连接失败" : connected ? activity : enabled ? "连接中" : "未连接"; const steps = [{ title: t("agent.connect.pluginTitle"), text: t("agent.connect.pluginText") }, { title: t("agent.connect.directTitle"), text: t("agent.connect.directText"), command: "npx -y @basketikun/canvas-agent" }];
const statusText = connectError ? t("agent.status.failed") : connected ? activity : enabled ? t("agent.status.connecting") : t("agent.status.disconnected");
const statusColor = connectError ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme.node.muted; const statusColor = connectError ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme.node.muted;
const copyCommand = (command: string) => { const copyCommand = (command: string) => {
copyToClipboard(command); copyToClipboard(command);
message.success("命令已复制"); message.success(t("agent.connect.commandCopied"));
}; };
const codexPluginReminder = ( const codexPluginReminder = (
<div className="rounded-lg border px-3 py-2.5 text-xs leading-5" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}> <div className="rounded-lg border px-3 py-2.5 text-xs leading-5" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
<div className="font-medium" style={{ color: theme.node.text }}> <div className="font-medium" style={{ color: theme.node.text }}>
Codex {t("agent.connect.pluginReminder")}
</div> </div>
<div className="mt-1"> Codex MCP Codex token `npx -y @basketikun/canvas-agent` Agent MCP</div> <div className="mt-1">{t("agent.connect.pluginReminderText")}</div>
<div className="mt-2 grid gap-1.5"> <div className="mt-2 grid gap-1.5">
{[ {[
["移除插件", AGENT_PLUGIN_REMOVE_COMMAND], [t("agent.connect.removePlugin"), AGENT_PLUGIN_REMOVE_COMMAND],
["移除手动 MCP", AGENT_MCP_REMOVE_COMMAND], [t("agent.connect.removeMcp"), AGENT_MCP_REMOVE_COMMAND],
].map(([label, command]) => ( ].map(([label, command]) => (
<div key={command} className="flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style={{ borderColor: theme.node.stroke, color: theme.node.text }}> <div key={command} className="flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
<span className="shrink-0 text-[11px]" style={{ color: theme.node.muted }}> <span className="shrink-0 text-[11px]" style={{ color: theme.node.muted }}>
{label} {label}
</span> </span>
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code> <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code>
<Tooltip title="复制命令"> <Tooltip title={t("agent.connect.copyCommand")}>
<Button size="small" type="text" className="!h-6 !w-6 !min-w-6" icon={<Copy className="size-3.5" />} onClick={() => copyCommand(command)} /> <Button size="small" type="text" className="!h-6 !w-6 !min-w-6" icon={<Copy className="size-3.5" />} onClick={() => copyCommand(command)} />
</Tooltip> </Tooltip>
</div> </div>
@@ -70,13 +69,13 @@ export function AgentConnectView({
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4"> <div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<div className="text-base font-semibold leading-6"> Agent</div> <div className="text-base font-semibold leading-6">{t("agent.connect.title")}</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}> <div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
使 {t("agent.connect.description")}
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{AGENT_CONNECT_STEPS.map((step, index) => { {steps.map((step, index) => {
const command = "command" in step ? step.command : ""; const command = "command" in step ? step.command : "";
return ( return (
<Fragment key={step.title}> <Fragment key={step.title}>
@@ -88,7 +87,7 @@ export function AgentConnectView({
{command ? ( {command ? (
<div className="mt-2 flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style={{ borderColor: theme.node.stroke, color: theme.node.text }}> <div className="mt-2 flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code> <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code>
<Tooltip title="复制命令"> <Tooltip title={t("agent.connect.copyCommand")}>
<Button size="small" type="text" className="!h-6 !w-6 !min-w-6" icon={<Copy className="size-3.5" />} onClick={() => copyCommand(command)} /> <Button size="small" type="text" className="!h-6 !w-6 !min-w-6" icon={<Copy className="size-3.5" />} onClick={() => copyCommand(command)} />
</Tooltip> </Tooltip>
</div> </div>
@@ -103,7 +102,7 @@ export function AgentConnectView({
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-sm font-medium leading-5"></span> <span className="shrink-0 text-sm font-medium leading-5">{t("agent.connect.webConnection")}</span>
<span <span
className="inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] leading-4" className="inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] leading-4"
style={{ borderColor: connected || enabled || connectError ? statusColor : theme.node.stroke, color: statusColor }} style={{ borderColor: connected || enabled || connectError ? statusColor : theme.node.stroke, color: statusColor }}
@@ -113,26 +112,26 @@ export function AgentConnectView({
</span> </span>
</div> </div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}> <div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
Local URL Connect token {t("agent.connect.autoDiscover")}
</div> </div>
</div> </div>
<Button className="!h-8 !px-3" type={enabled ? "default" : "primary"} icon={<PlugZap className="size-4" />} onClick={onToggleEnabled}> <Button className="!h-8 !px-3" type={enabled ? "default" : "primary"} icon={<PlugZap className="size-4" />} onClick={onToggleEnabled}>
{enabled ? "断开" : "连接"} {t(enabled ? "agent.connect.disconnect" : "agent.connect.connect")}
</Button> </Button>
</div> </div>
<div className="mt-3 grid gap-2.5"> <div className="mt-3 grid gap-2.5">
<label className="grid gap-1.5"> <label className="grid gap-1.5">
<span className="flex items-center gap-1.5 text-xs font-medium" style={{ color: theme.node.muted }}> <span className="flex items-center gap-1.5 text-xs font-medium" style={{ color: theme.node.muted }}>
<Link2 className="size-3.5" /> <Link2 className="size-3.5" />
{t("agent.connect.localAddress")}
<span className="font-normal opacity-70">Local URL</span> <span className="font-normal opacity-70">Local URL</span>
</span> </span>
<Input size="large" prefix={<Link2 className="mr-1 size-4" style={{ color: theme.node.faint }} />} value={url} onChange={(event) => onUrlChange(event.target.value)} placeholder="例如 http://127.0.0.1:17371" /> <Input size="large" prefix={<Link2 className="mr-1 size-4" style={{ color: theme.node.faint }} />} value={url} onChange={(event) => onUrlChange(event.target.value)} placeholder={t("agent.connect.urlPlaceholder")} />
</label> </label>
<label className="grid gap-1.5"> <label className="grid gap-1.5">
<span className="flex items-center gap-1.5 text-xs font-medium" style={{ color: theme.node.muted }}> <span className="flex items-center gap-1.5 text-xs font-medium" style={{ color: theme.node.muted }}>
<KeyRound className="size-3.5" /> <KeyRound className="size-3.5" />
Token {t("agent.connect.token")}
<span className="font-normal opacity-70">Connect token</span> <span className="font-normal opacity-70">Connect token</span>
</span> </span>
<Input.Password <Input.Password
@@ -140,7 +139,7 @@ export function AgentConnectView({
prefix={<KeyRound className="mr-1 size-4" style={{ color: theme.node.faint }} />} prefix={<KeyRound className="mr-1 size-4" style={{ color: theme.node.faint }} />}
value={token} value={token}
onChange={(event) => onTokenChange(event.target.value)} onChange={(event) => onTokenChange(event.target.value)}
placeholder="自动发现,或手动填入 Connect token" placeholder={t("agent.connect.tokenPlaceholder")}
/> />
</label> </label>
{connectError ? ( {connectError ? (
@@ -154,4 +153,3 @@ export function AgentConnectView({
</div> </div>
); );
} }
+106 -120
View File
@@ -1,9 +1,10 @@
import { isSiteTool, SITE_TOOL_LABELS } from "@/lib/agent/agent-site-tools"; import { isSiteTool, SITE_TOOL_LABELS } from "@/lib/agent/agent-site-tools";
import i18n from "@/i18n";
import { summarizeCanvasAgentOps, type CanvasAgentOp } from "@/lib/canvas/canvas-agent-ops"; import { summarizeCanvasAgentOps, type CanvasAgentOp } from "@/lib/canvas/canvas-agent-ops";
import { randomId } from "@/lib/utils"; import { randomId } from "@/lib/utils";
import { useAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentTokenUsage } from "@/stores/use-agent-store"; import { useAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentTokenUsage } from "@/stores/use-agent-store";
import type { AgentChatAttachment } from "./agent-chat-message"; import type { AgentChatAttachment } from "./agent-chat-message";
export const REASONING_PLACEHOLDER = "正在分析任务…"; export const REASONING_PLACEHOLDER = i18n.t("agent.events.analyzing");
export type AgentEventPayload = { export type AgentEventPayload = {
agent?: string; agent?: string;
@@ -78,33 +79,33 @@ export function formatAgentActivity(event: AgentEventPayload): Omit<AgentChatIte
if (item.type === "reasoning") { if (item.type === "reasoning") {
const text = readableText(item.summary); const text = readableText(item.summary);
if (completed && !text) return null; if (completed && !text) return null;
return { role: "tool", title: "思考摘要", text: text || activityPlaceholder(item.type), detail: { kind: "reasoning", status: itemStatus } }; return { role: "tool", title: tr("reasoning"), text: text || activityPlaceholder(item.type), detail: { kind: "reasoning", status: itemStatus } };
} }
if (item.type === "plan") { if (item.type === "plan") {
const text = stringText(item.text); const text = stringText(item.text);
if (completed && !text && !item.error?.message) return null; if (completed && !text && !item.error?.message) return null;
return { role: "tool", title: "执行计划", text: item.error?.message || text || activityPlaceholder(item.type), detail: { kind: "plan", status: itemStatus, ...(item.error?.message ? { output: item.error.message } : {}) } }; return { role: "tool", title: tr("plan"), text: item.error?.message || text || activityPlaceholder(item.type), detail: { kind: "plan", status: itemStatus, ...(item.error?.message ? { output: item.error.message } : {}) };
} }
if (item.type === "command_execution") { if (item.type === "command_execution") {
const command = stringText(item.command); const command = stringText(item.command);
const text = command || (completed ? failed ? "命令执行失败" : "命令已完成" : activityPlaceholder(item.type)); const text = command || (completed ? tr(failed ? "commandFailed" : "commandCompleted") : activityPlaceholder(item.type));
return { role: "tool", title: "执行命令", text, detail: commandActivityDetail(item, itemStatus) }; return { role: "tool", title: tr("executeCommand"), text, detail: commandActivityDetail(item, itemStatus) };
} }
if (item.type === "file_change") { if (item.type === "file_change") {
const files = activityFiles(item.changes); const files = activityFiles(item.changes);
return { role: "tool", title: "修改文件", text: item.error?.message || fileActivitySummary(files, completed), detail: { kind: "file", status: itemStatus, files, ...(item.error?.message ? { output: item.error.message } : {}) } }; return { role: "tool", title: tr("editFiles"), text: item.error?.message || fileActivitySummary(files, completed), detail: { kind: "file", status: itemStatus, files, ...(item.error?.message ? { output: item.error.message } : {}) } };
} }
if (item.type === "web_search") { if (item.type === "web_search") {
return { role: "tool", title: "搜索资料", text: item.error?.message || webSearchSummary(item), detail: { kind: "search", status: itemStatus, rows: webSearchDetailRows(item), ...(item.error?.message ? { output: item.error.message } : {}) } }; return { role: "tool", title: tr("searchWeb"), text: item.error?.message || webSearchSummary(item), detail: { kind: "search", status: itemStatus, rows: webSearchDetailRows(item), ...(item.error?.message ? { output: item.error.message } : {}) } };
} }
if (item.type === "image_view") return { role: "tool", title: "查看图片", text: item.error?.message || stringText(item.path) || (completed ? "已查看图片" : "正在查看图片"), detail: { kind: "image", status: itemStatus, ...(item.error?.message ? { output: item.error.message } : {}) } }; if (item.type === "image_view") return { role: "tool", title: tr("viewImage"), text: item.error?.message || stringText(item.path) || tr(completed ? "imageViewed" : "viewingImage"), detail: { kind: "image", status: itemStatus, ...(item.error?.message ? { output: item.error.message } : {}) } };
if (item.type === "image_generation") { if (item.type === "image_generation") {
return { role: "tool", title: "内置生图", text: item.error?.message || (completed ? failed ? "图片生成失败" : "图片生成完成" : "正在生成图片…"), detail: { kind: "image", status: itemStatus, savedPath: item.savedPath, ...(item.error?.message ? { output: item.error.message } : {}) } }; return { role: "tool", title: tr("imageGeneration"), text: item.error?.message || tr(completed ? failed ? "imageFailed" : "imageCompleted" : "generatingImage"), detail: { kind: "image", status: itemStatus, savedPath: item.savedPath, ...(item.error?.message ? { output: item.error.message } : {}) } };
} }
if (item.type === "context_compaction") return { role: "tool", title: "整理上下文", text: item.error?.message || (completed ? "已整理当前对话,继续处理任务" : "正在整理当前对话…"), detail: { kind: "context", status: itemStatus, ...(item.error?.message ? { output: item.error.message } : {}) } }; if (item.type === "context_compaction") return { role: "tool", title: tr("compactContext"), text: item.error?.message || tr(completed ? "contextCompacted" : "compactingContext"), detail: { kind: "context", status: itemStatus, ...(item.error?.message ? { output: item.error.message } : {}) } };
if (isMcpToolItem(item)) { if (isMcpToolItem(item)) {
const name = String(item.tool || ""); const name = String(item.tool || "");
return { role: "tool", title: toolName(name), text: completed ? item.error?.message || toolSummary(item) : `正在${toolAction(name)}`, detail: toolDetail(item, itemStatus) }; return { role: "tool", title: toolName(name), text: completed ? item.error?.message || toolSummary(item) : tr("toolRunning", { action: toolAction(name) }), detail: toolDetail(item, itemStatus) };
} }
if (item.type === "dynamic_tool_call") { if (item.type === "dynamic_tool_call") {
const name = String(item.tool || ""); const name = String(item.tool || "");
@@ -112,11 +113,11 @@ export function formatAgentActivity(event: AgentEventPayload): Omit<AgentChatIte
return { return {
role: "tool", role: "tool",
title, title,
text: completed ? item.error?.message || readableText(item.contentItems) : `正在${toolAction(name)}`, text: completed ? item.error?.message || readableText(item.contentItems) : tr("toolRunning", { action: toolAction(name) }),
detail: toolDetail(item, itemStatus), detail: toolDetail(item, itemStatus),
}; };
} }
if (item.type === "collab_tool_call") return { role: "tool", title: "协作处理", text: item.error?.message || (completed ? failed ? "协作任务失败" : "已完成协作任务" : "正在协作处理任务…"), detail: { kind: "tool", status: itemStatus, ...(item.error?.message ? { output: item.error.message } : {}) } }; if (item.type === "collab_tool_call") return { role: "tool", title: tr("collaboration"), text: item.error?.message || tr(completed ? failed ? "collaborationFailed" : "collaborationCompleted" : "collaborating"), detail: { kind: "tool", status: itemStatus, ...(item.error?.message ? { output: item.error.message } : {}) } };
return null; return null;
} }
@@ -126,8 +127,8 @@ export function formatAgentPlan(event: AgentEventPayload): Omit<AgentChatItem, "
const completed = tasks.filter((item) => item.status === "completed").length; const completed = tasks.filter((item) => item.status === "completed").length;
return { return {
role: "tool", role: "tool",
title: "任务进度", title: tr("progress"),
text: `已完成 ${completed}/${tasks.length}`, text: tr("progressCount", { completed, total: tasks.length }),
detail: { kind: "todo", status: completed === tasks.length ? "completed" : "inProgress", tasks, explanation: stringText(event.explanation) }, detail: { kind: "todo", status: completed === tasks.length ? "completed" : "inProgress", tasks, explanation: stringText(event.explanation) },
}; };
} }
@@ -148,14 +149,14 @@ export function turnPlanStatus(detail: unknown, turnStatus?: string) {
} }
export function activityDeltaFallback(item: AgentEventItem, delta: string): AgentChatItem { export function activityDeltaFallback(item: AgentEventItem, delta: string): AgentChatItem {
if (item.type === "command_execution") return { id: item.id || randomId(), role: "tool", title: "执行命令", text: activityPlaceholder(item.type), detail: { kind: "command", status: "inProgress", output: delta } }; if (item.type === "command_execution") return { id: item.id || randomId(), role: "tool", title: tr("executeCommand"), text: activityPlaceholder(item.type), detail: { kind: "command", status: "inProgress", output: delta } };
return { id: item.id || randomId(), role: "tool", title: item.type === "plan" ? "执行计划" : "思考摘要", text: delta, detail: { kind: activityKind(item.type), status: "inProgress" } }; return { id: item.id || randomId(), role: "tool", title: item.type === "plan" ? tr("plan") : tr("reasoning"), text: delta, detail: { kind: activityKind(item.type), status: "inProgress" } };
} }
export function activityPlaceholder(type?: string) { export function activityPlaceholder(type?: string) {
if (type === "plan") return "正在整理执行步骤…"; if (type === "plan") return tr("planning");
if (type === "command_execution") return "正在执行命令…"; if (type === "command_execution") return tr("executingCommand");
return REASONING_PLACEHOLDER; return tr("analyzing");
} }
export function activityKind(type?: string) { export function activityKind(type?: string) {
@@ -170,7 +171,7 @@ export function activityDetail(value: unknown, kind: string, status: string): Ag
} }
function commandActivityDetail(item: AgentEventItem, status: string): AgentUserDetail { function commandActivityDetail(item: AgentEventItem, status: string): AgentUserDetail {
const rows = [detailRow("工作目录", item.cwd), detailRow("退出状态", item.exitCode), durationDetailRow(item.durationMs)].flatMap((row) => (row ? [row] : [])); const rows = [detailRow(tr("workingDirectory"), item.cwd), detailRow(tr("exitStatus"), item.exitCode), durationDetailRow(item.durationMs)].flatMap((row) => (row ? [row] : []));
const commandStatus = typeof item.exitCode === "number" && item.exitCode !== 0 ? "failed" : status; const commandStatus = typeof item.exitCode === "number" && item.exitCode !== 0 ? "failed" : status;
return { kind: "command", status: commandStatus, rows, output: item.error?.message || stringText(item.aggregatedOutput) }; return { kind: "command", status: commandStatus, rows, output: item.error?.message || stringText(item.aggregatedOutput) };
} }
@@ -183,23 +184,23 @@ function activityFiles(value: unknown) {
} }
function fileActivitySummary(files: Array<{ path: string; action?: string }>, completed: boolean) { function fileActivitySummary(files: Array<{ path: string; action?: string }>, completed: boolean) {
if (!files.length) return completed ? "已完成文件修改" : "正在准备文件修改…"; if (!files.length) return tr(completed ? "filesCompleted" : "preparingFiles");
if (files.length === 1) return `${completed ? "已" : "正在"}${files[0].action || "修改"} ${files[0].path}`; if (files.length === 1) return tr(completed ? "fileCompleted" : "editingFile", { action: files[0].action || tr("edit"), path: files[0].path });
const names = files.slice(0, 3).map((file) => file.path).join(""); const names = files.slice(0, 3).map((file) => file.path).join(", ");
return `${completed ? "已修改" : "正在修改"} ${files.length} 个文件:${names}${files.length > 3 ? " 等" : ""}`; return tr(completed ? "filesEdited" : "editingFiles", { count: files.length, names, more: files.length > 3 ? tr("andMore") : "" });
} }
function webSearchSummary(item: AgentEventItem) { function webSearchSummary(item: AgentEventItem) {
const action = item.action; const action = item.action;
const type = stringText(objectField(action, "type")); const type = stringText(objectField(action, "type"));
if (type === "openPage") return `打开网页:${stringText(objectField(action, "url"))}`; if (type === "openPage") return tr("openPage", { url: stringText(objectField(action, "url")) });
if (type === "findInPage") return `在网页中查找“${stringText(objectField(action, "pattern")) || "内容"}`; if (type === "findInPage") return tr("findInPage", { pattern: stringText(objectField(action, "pattern")) || tr("content") });
return `搜索:${stringText(item.query) || stringText(objectField(action, "query")) || "相关资料"}`; return tr("search", { query: stringText(item.query) || stringText(objectField(action, "query")) || tr("relatedInfo") });
} }
function webSearchDetailRows(item: AgentEventItem) { function webSearchDetailRows(item: AgentEventItem) {
const action = item.action; const action = item.action;
return [detailRow("关键词", item.query || objectField(action, "query")), detailRow("网页", objectField(action, "url"))].flatMap((row) => (row ? [row] : [])); return [detailRow(tr("keyword"), item.query || objectField(action, "query")), detailRow(tr("webpage"), objectField(action, "url"))].flatMap((row) => (row ? [row] : []));
} }
function readableText(value: unknown): string { function readableText(value: unknown): string {
@@ -215,13 +216,13 @@ function detailRow(label: string, value: unknown) {
function durationDetailRow(value: unknown) { function durationDetailRow(value: unknown) {
const duration = Number(value || 0); const duration = Number(value || 0);
return duration > 0 ? { label: "耗时", value: `${(duration / 1000).toFixed(1)}` } : null; return duration > 0 ? { label: tr("duration"), value: tr("seconds", { value: (duration / 1000).toFixed(1) }) } : null;
} }
function changeAction(value: unknown) { function changeAction(value: unknown) {
if (value === "add") return "新增"; if (value === "add") return tr("add");
if (value === "delete") return "删除"; if (value === "delete") return tr("delete");
return "修改"; return tr("edit");
} }
export function parseEventData<T>(event: Event) { export function parseEventData<T>(event: Event) {
@@ -252,13 +253,13 @@ export function registerLiveAgentTurn(
export function formatLogText(logs: AgentEventLog[], context: AgentLogContext) { export function formatLogText(logs: AgentEventLog[], context: AgentLogContext) {
const head = [ const head = [
"Infinite Canvas Agent 诊断", tr("diagnostics"),
`地址:${context.endpoint}`, tr("address", { endpoint: context.endpoint }),
`连接:${context.connected ? "在线" : context.enabled ? "连接中" : "未启用"} · 状态:${context.activity}`, tr("connection", { connection: tr(context.connected ? "online" : context.enabled ? "connecting" : "disabled"), status: context.activity }),
`消息:${context.messages} · 工具:${context.pendingTool ? toolName(context.pendingTool) : "无"}`, tr("messageTool", { messages: context.messages, tool: context.pendingTool ? toolName(context.pendingTool) : tr("none") }),
].join("\n"); ].join("\n");
const body = logs.map((item) => `${item.time} ${item.title}${item.text && item.text !== item.title ? ` · ${item.text}` : ""}`).join("\n"); const body = logs.map((item) => `${item.time} ${item.title}${item.text && item.text !== item.title ? ` · ${item.text}` : ""}`).join("\n");
return [head, body || "暂无事件日志"].join("\n\n"); return [head, body || tr("noLogs")].join("\n\n");
} }
export function formatLogJson(logs: AgentEventLog[], context: AgentLogContext) { export function formatLogJson(logs: AgentEventLog[], context: AgentLogContext) {
@@ -267,29 +268,29 @@ export function formatLogJson(logs: AgentEventLog[], context: AgentLogContext) {
export function formatAgentEventLog(event: AgentEventPayload) { export function formatAgentEventLog(event: AgentEventPayload) {
const item = event.item; const item = event.item;
if (event.type === "thread.started") return { title: "创建会话", text: shortId(event.thread_id) }; if (event.type === "thread.started") return { title: tr("threadCreated"), text: shortId(event.thread_id) };
if (event.type === "turn.started") return { title: "开始处理", text: shortId(event.turn_id) }; if (event.type === "turn.started") return { title: tr("turnStarted"), text: shortId(event.turn_id) };
if (event.type === "plan.updated") { if (event.type === "plan.updated") {
const tasks = planTasks(event.plan); const tasks = planTasks(event.plan);
return { title: "更新任务进度", text: `已完成 ${tasks.filter((item) => item.status === "completed").length}/${tasks.length}` }; return { title: tr("progressUpdated"), text: tr("progressCount", { completed: tasks.filter((item) => item.status === "completed").length, total: tasks.length }) };
} }
if (event.type === "turn.completed" && event.status === "failed") return { title: "处理失败", text: agentErrorView(event.error?.message).text }; if (event.type === "turn.completed" && event.status === "failed") return { title: tr("turnFailed"), text: agentErrorView(event.error?.message).text };
if (event.type === "turn.completed") return { title: event.status === "interrupted" ? "处理已停止" : "处理完成", text: turnSummary(event) }; if (event.type === "turn.completed") return { title: tr(event.status === "interrupted" ? "turnStopped" : "turnCompleted"), text: turnSummary(event) };
if (event.type === "turn.failed" || event.type === "error") return { title: "处理失败", text: agentErrorView(event.message || event.error?.message).text }; if (event.type === "turn.failed" || event.type === "error") return { title: tr("turnFailed"), text: agentErrorView(event.message || event.error?.message).text };
if (event.type === "item.started" && isMcpToolItem(item)) return { title: "调用工具", text: toolName(String(item?.tool || "")) }; if (event.type === "item.started" && isMcpToolItem(item)) return { title: tr("toolCalled"), text: toolName(String(item?.tool || "")) };
if (event.type === "item.completed" && isMcpToolItem(item)) return { title: item.error ? "工具失败" : "工具完成", text: `${toolName(String(item?.tool || ""))}${item.error?.message ? ` · ${item.error.message}` : ""}` }; if (event.type === "item.completed" && isMcpToolItem(item)) return { title: tr(item.error ? "toolFailed" : "toolCompleted"), text: `${toolName(String(item?.tool || ""))}${item.error?.message ? ` · ${item.error.message}` : ""}` };
if (event.type === "item.completed" && item?.type === "agent_message") return { title: "收到回复", text: compactText(stringText(item.text)) }; if (event.type === "item.completed" && item?.type === "agent_message") return { title: tr("replyReceived"), text: compactText(stringText(item.text)) };
return null; return null;
} }
function turnSummary(event: AgentEventPayload) { function turnSummary(event: AgentEventPayload) {
return event.duration_ms ? `${(event.duration_ms / 1000).toFixed(1)}` : "完成"; return event.duration_ms ? tr("seconds", { value: (event.duration_ms / 1000).toFixed(1) }) : tr("completed");
} }
export function agentErrorView(value: unknown) { export function agentErrorView(value: unknown) {
const text = normalizeText(value); const text = normalizeText(value);
if (/selected model is at capacity/i.test(text)) return { title: "模型暂时繁忙", text: "当前选择的模型请求量过大,暂时无法处理。请稍后重试,或切换其他模型后再试。" }; if (/selected model is at capacity/i.test(text)) return { title: tr("modelBusy"), text: tr("modelBusyDescription") };
return { title: "任务失败", text: text || "Codex 未能完成本次任务,请稍后重试。" }; return { title: tr("taskFailed"), text: text || tr("taskFailedDescription") };
} }
export function eventUsage(event: AgentEventPayload): AgentTokenUsage { export function eventUsage(event: AgentEventPayload): AgentTokenUsage {
@@ -314,53 +315,33 @@ export function isConnectionErrorMessage(item: AgentChatItem) {
} }
export function toolName(name: string) { export function toolName(name: string) {
if (name === "imagegen" || name.endsWith("__imagegen")) return "生成图片"; if (name === "imagegen" || name.endsWith("__imagegen")) return tr("tools.generateImage");
if (name === "view_image" || name.endsWith("__view_image")) return "查看图片"; if (name === "view_image" || name.endsWith("__view_image")) return tr("tools.viewImage");
if (name === "exec" || name === "exec_command" || name.endsWith("__exec_command")) return "执行命令"; if (name === "exec" || name === "exec_command" || name.endsWith("__exec_command")) return tr("tools.executeCommand");
if (name === "apply_patch" || name.endsWith("__apply_patch")) return "修改文件"; if (name === "apply_patch" || name.endsWith("__apply_patch")) return tr("tools.editFiles");
if (name === "web__run" || name.endsWith("__web__run")) return "搜索资料"; if (name === "web__run" || name.endsWith("__web__run")) return tr("tools.searchWeb");
if (name === "canvas_apply_ops") return "画布操作"; if (toolTranslationKeys[name]) return tr(`tools.${toolTranslationKeys[name]}`);
if (name === "canvas_get_state") return "读取画布";
if (name === "canvas_get_selection") return "读取选区";
if (name === "canvas_export_snapshot") return "导出快照";
if (name === "canvas_create_node") return "创建节点";
if (name === "canvas_create_attachment_nodes") return "添加附件图片";
if (name === "canvas_create_text_node") return "创建文本";
if (name === "canvas_create_text_nodes") return "批量创建文本";
if (name === "canvas_create_config_node") return "创建生成配置";
if (name === "canvas_create_image_prompt_flow") return "创建生图流程";
if (name === "canvas_create_generation_flow") return "创建生成流程";
if (name === "canvas_generate_text") return "生成文本";
if (name === "canvas_generate_image") return "生成图片";
if (name === "canvas_generate_video") return "生成视频";
if (name === "canvas_generate_audio") return "生成音频";
if (name === "canvas_update_node") return "更新节点";
if (name === "canvas_update_node_text") return "更新文本";
if (name === "canvas_move_nodes") return "移动节点";
if (name === "canvas_resize_node") return "调整节点尺寸";
if (name === "canvas_delete_nodes") return "删除节点";
if (name === "canvas_connect_nodes") return "连接节点";
if (name === "canvas_select_nodes") return "选择节点";
if (name === "canvas_set_viewport") return "调整视口";
if (name === "canvas_run_generation") return "触发生成";
if (name === "site_navigate") return "打开页面";
if (isSiteTool(name)) return SITE_TOOL_LABELS[name]; if (isSiteTool(name)) return SITE_TOOL_LABELS[name];
return name ? `调用工具:${name}` : "工具操作"; return name ? tr("toolNamed", { name }) : tr("toolOperation");
} }
const toolTranslationKeys: Record<string, string> = {
canvas_apply_ops: "canvasOps", canvas_get_state: "readCanvas", canvas_get_selection: "readSelection", canvas_export_snapshot: "exportSnapshot", canvas_create_node: "createNode", canvas_create_attachment_nodes: "addAttachments", canvas_create_text_node: "createText", canvas_create_text_nodes: "createTexts", canvas_create_config_node: "createConfig", canvas_create_image_prompt_flow: "createImageFlow", canvas_create_generation_flow: "createGenerationFlow", canvas_generate_text: "generateText", canvas_generate_image: "generateImage", canvas_generate_video: "generateVideo", canvas_generate_audio: "generateAudio", canvas_update_node: "updateNode", canvas_update_node_text: "updateText", canvas_move_nodes: "moveNodes", canvas_resize_node: "resizeNode", canvas_delete_nodes: "deleteNodes", canvas_connect_nodes: "connectNodes", canvas_select_nodes: "selectNodes", canvas_set_viewport: "setViewport", canvas_run_generation: "runGeneration", site_navigate: "openPage",
};
function siteToolSummary(name: string, result: unknown, input: unknown) { function siteToolSummary(name: string, result: unknown, input: unknown) {
const data = result && typeof result === "object" ? (result as Record<string, unknown>) : {}; const data = result && typeof result === "object" ? (result as Record<string, unknown>) : {};
if (name === "site_navigate") return `已打开${routeName(stringText(objectField(input, "path")) || "/")}`; if (name === "site_navigate") return tr("openedRoute", { route: routeName(stringText(objectField(input, "path")) || "/") });
if (name === "canvas_list_projects") return `${numberField(data, "total")} 个画布`; if (name === "canvas_list_projects") return tr("canvasCount", { count: numberField(data, "total") });
if (name === "prompts_search") return `找到 ${numberField(data, "total")} 条提示词`; if (name === "prompts_search") return tr("promptCount", { count: numberField(data, "total") });
if (name === "assets_list") return `${numberField(data, "total")} 个资产`; if (name === "assets_list") return tr("assetCount", { count: numberField(data, "total") });
if (name === "assets_add") return "已加入我的素材"; if (name === "assets_add") return tr("assetAdded");
if (name === "generation_get_status") { if (name === "generation_get_status") {
const summary = data.summary && typeof data.summary === "object" ? (data.summary as Record<string, unknown>) : {}; const summary = data.summary && typeof data.summary === "object" ? (data.summary as Record<string, unknown>) : {};
return `${numberField(data, "total")} 个任务,排队 ${numberField(summary, "queued")},运行中 ${numberField(summary, "running")},成功 ${numberField(summary, "succeeded")},失败 ${numberField(summary, "failed")}`; return tr("generationStatus", { total: numberField(data, "total"), queued: numberField(summary, "queued"), running: numberField(summary, "running"), succeeded: numberField(summary, "succeeded"), failed: numberField(summary, "failed") });
} }
if (name === "workbench_image_generate" || name === "workbench_video_generate") return typeof data.note === "string" ? data.note : "已在工作台执行"; if (name === "workbench_image_generate" || name === "workbench_video_generate") return typeof data.note === "string" ? data.note : tr("workbenchExecuted");
if (name === "workbench_image_get_config" || name === "workbench_video_get_config") return "已读取工作台配置"; if (name === "workbench_image_get_config" || name === "workbench_video_get_config") return tr("workbenchConfigRead");
return ""; return "";
} }
@@ -379,11 +360,11 @@ export function toolCallDetail(name: string, input: unknown, status: string, err
function toolInputRows(name: string, input: unknown) { function toolInputRows(name: string, input: unknown) {
input = parseToolArguments(input); input = parseToolArguments(input);
if (name === "site_navigate") return [detailRow("目标页面", routeName(stringText(objectField(input, "path")) || "/"))].flatMap((row) => (row ? [row] : [])); if (name === "site_navigate") return [detailRow(tr("targetPage"), routeName(stringText(objectField(input, "path")) || "/"))].flatMap((row) => (row ? [row] : []));
if (name === "prompts_search") return [detailRow("搜索内容", objectField(input, "query"))].flatMap((row) => (row ? [row] : [])); if (name === "prompts_search") return [detailRow(tr("searchContent"), objectField(input, "query"))].flatMap((row) => (row ? [row] : []));
if (name === "canvas_create_text_node") return [detailRow("文本内容", objectField(input, "text"))].flatMap((row) => (row ? [row] : [])); if (name === "canvas_create_text_node") return [detailRow(tr("textContent"), objectField(input, "text"))].flatMap((row) => (row ? [row] : []));
if (name === "canvas_apply_ops") return [detailRow("操作内容", summarizeCanvasAgentOps((objectField(input, "ops") as CanvasAgentOp[] | undefined) || []))].flatMap((row) => (row ? [row] : [])); if (name === "canvas_apply_ops") return [detailRow(tr("operationContent"), summarizeCanvasAgentOps((objectField(input, "ops") as CanvasAgentOp[] | undefined) || []))].flatMap((row) => (row ? [row] : []));
if (name === "canvas_create_attachment_nodes") return [detailRow("图片数量", Array.isArray(objectField(input, "attachmentIds")) ? (objectField(input, "attachmentIds") as unknown[]).length : 0)].flatMap((row) => (row ? [row] : [])); if (name === "canvas_create_attachment_nodes") return [detailRow(tr("imageCount"), Array.isArray(objectField(input, "attachmentIds")) ? (objectField(input, "attachmentIds") as unknown[]).length : 0)].flatMap((row) => (row ? [row] : []));
return []; return [];
} }
@@ -395,8 +376,8 @@ export function toolSummary(item?: AgentEventItem) {
const connectionField = objectField(result, "connections"); const connectionField = objectField(result, "connections");
const nodes = Array.isArray(nodeField) ? nodeField : []; const nodes = Array.isArray(nodeField) ? nodeField : [];
const connections = Array.isArray(connectionField) ? connectionField : []; const connections = Array.isArray(connectionField) ? connectionField : [];
if (name === "canvas_get_state") return Array.isArray(nodeField) || Array.isArray(connectionField) ? canvasContentSummary(nodes, connections.length) : "已读取当前画布内容"; if (name === "canvas_get_state") return Array.isArray(nodeField) || Array.isArray(connectionField) ? canvasContentSummary(nodes, connections.length) : tr("canvasRead");
if (name === "canvas_get_selection") return "已读取当前选中内容"; if (name === "canvas_get_selection") return tr("selectionRead");
return ""; return "";
} }
@@ -409,33 +390,32 @@ function canvasContentSummary(nodes: unknown[], connections: number) {
const known = new Set(["text", "image", "config", "video", "audio", "group"]); const known = new Set(["text", "image", "config", "video", "audio", "group"]);
const other = Object.entries(counts).reduce((total, [type, count]) => total + (known.has(type) ? 0 : count), 0); const other = Object.entries(counts).reduce((total, [type, count]) => total + (known.has(type) ? 0 : count), 0);
const parts = [ const parts = [
counts.text ? `${counts.text} 个文本` : "", counts.text ? tr("textNodes", { count: counts.text }) : "",
counts.image ? `${counts.image} 张图片` : "", counts.image ? tr("imageNodes", { count: counts.image }) : "",
counts.config ? `${counts.config} 个配置` : "", counts.config ? tr("configNodes", { count: counts.config }) : "",
counts.video ? `${counts.video} 个视频` : "", counts.video ? tr("videoNodes", { count: counts.video }) : "",
counts.audio ? `${counts.audio} 个音频` : "", counts.audio ? tr("audioNodes", { count: counts.audio }) : "",
counts.group ? `${counts.group} 个分组` : "", counts.group ? tr("groupNodes", { count: counts.group }) : "",
other ? `${other} 个其他节点` : "", other ? tr("otherNodes", { count: other }) : "",
connections ? `${connections} 条连线` : "", connections ? tr("connections", { count: connections }) : "",
].filter(Boolean); ].filter(Boolean);
return parts.length ? parts.join("、") : "当前画布为空"; return parts.length ? parts.join(i18n.language === "zh-CN" ? "、" : ", ") : tr("emptyCanvas");
} }
export function toolAction(name: string) { export function toolAction(name: string) {
const label = toolName(name); const label = toolName(name);
if (label.startsWith("读取") || label.startsWith("查看") || label.startsWith("搜索") || label.startsWith("打开")) return label; return tr("executeTool", { tool: label });
return `执行${label}`;
} }
export function routeName(path: string) { export function routeName(path: string) {
if (path === "/") return "首页"; if (path === "/") return tr("routes.home");
if (path === "/canvas") return "画布页面"; if (path === "/canvas") return tr("routes.canvas");
if (path.startsWith("/canvas/")) return "指定画布"; if (path.startsWith("/canvas/")) return tr("routes.canvasProject");
if (path.startsWith("/image")) return "生图工作台"; if (path.startsWith("/image")) return tr("routes.image");
if (path.startsWith("/video")) return "视频工作台"; if (path.startsWith("/video")) return tr("routes.video");
if (path.startsWith("/prompts")) return "提示词中心"; if (path.startsWith("/prompts")) return tr("routes.prompts");
if (path.startsWith("/assets")) return "我的素材"; if (path.startsWith("/assets")) return tr("routes.assets");
if (path.startsWith("/config")) return "配置页面"; if (path.startsWith("/config")) return tr("routes.config");
return path; return path;
} }
@@ -443,10 +423,10 @@ export function workingActivity(item?: AgentChatItem) {
const status = String(objectField(item?.detail, "status") || ""); const status = String(objectField(item?.detail, "status") || "");
const output = stringText(objectField(item?.detail, "output")); const output = stringText(objectField(item?.detail, "output"));
const key = `${item?.id || "waiting"}-${status}-${item?.text || ""}-${output.length}`; const key = `${item?.id || "waiting"}-${status}-${item?.text || ""}-${output.length}`;
if (item?.role !== "tool") return { key, text: "正在思考..." }; if (item?.role !== "tool") return { key, text: tr("thinking") };
if (["inProgress", "in_progress", "running", "pending"].includes(status)) return { key, text: `${item.title || "工具操作"}正在进行...` }; if (["inProgress", "in_progress", "running", "pending"].includes(status)) return { key, text: tr("operationRunning", { operation: item.title || tr("toolOperation") }) };
if (item.title === "读取画布") return { key, text: "画布已读取,Codex 正在整理结果..." }; if (item.title === toolName("canvas_get_state")) return { key, text: tr("organizingCanvas") };
return { key, text: `${item.title || "工具操作"}已完成,Codex 正在继续处理...` }; return { key, text: tr("operationCompleted", { operation: item.title || tr("toolOperation") }) };
} }
export function currentPlanMessage(messages: AgentChatItem[]) { export function currentPlanMessage(messages: AgentChatItem[]) {
@@ -503,7 +483,7 @@ function numberField(value: unknown, key: string) {
} }
export function promptWithAttachments(text: string, attachments: AgentAttachment[]) { export function promptWithAttachments(text: string, attachments: AgentAttachment[]) {
return text || (attachments.length ? "请处理上传的图片附件。" : ""); return text || (attachments.length ? tr("attachmentPrompt") : "");
} }
export function attachmentPayloadBytes(attachments: AgentAttachment[]) { export function attachmentPayloadBytes(attachments: AgentAttachment[]) {
@@ -615,3 +595,9 @@ export function mergeStreamText(prefix: string, incoming: string) {
if (!incoming || prefix.startsWith(incoming)) return prefix; if (!incoming || prefix.startsWith(incoming)) return prefix;
return incoming.length >= prefix.length ? incoming : prefix; return incoming.length >= prefix.length ? incoming : prefix;
} }
function tr(key: string, options?: Record<string, unknown>) {
const path = `agent.events.${key}`;
const extraPath = `agent.eventExtra.${key}`;
return i18n.t(i18n.exists(path) ? path : i18n.exists(extraPath) ? extraPath : `agent.eventMore.${key}`, options);
}
+15 -14
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { Button, Checkbox } from "antd"; import { Button, Checkbox } from "antd";
import { FolderOpen, Plus, RefreshCw, Trash2 } from "lucide-react"; import { FolderOpen, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import type { AgentThreadSummary } from "@/stores/use-agent-store"; import type { AgentThreadSummary } from "@/stores/use-agent-store";
@@ -30,6 +31,7 @@ export function AgentHistoryView({
onResumeThread: (threadId: string) => void; onResumeThread: (threadId: string) => void;
onDeleteThreads: (threadIds: string[]) => void; onDeleteThreads: (threadIds: string[]) => void;
}) { }) {
const { i18n, t } = useTranslation();
const [selectedIds, setSelectedIds] = useState(() => new Set<string>()); const [selectedIds, setSelectedIds] = useState(() => new Set<string>());
const selectedThreads = threads.filter((thread) => selectedIds.has(thread.id)); const selectedThreads = threads.filter((thread) => selectedIds.has(thread.id));
const allSelected = Boolean(threads.length) && selectedThreads.length === threads.length; const allSelected = Boolean(threads.length) && selectedThreads.length === threads.length;
@@ -46,27 +48,27 @@ export function AgentHistoryView({
<div className="space-y-3"> <div className="space-y-3">
<div className="flex min-w-0 items-center gap-2 text-xs" style={{ color: theme.node.muted }}> <div className="flex min-w-0 items-center gap-2 text-xs" style={{ color: theme.node.muted }}>
<FolderOpen className="size-3.5 shrink-0" /> <FolderOpen className="size-3.5 shrink-0" />
<span className="shrink-0"></span> <span className="shrink-0">{t("agent.history.workspace")}</span>
<span className="min-w-0 truncate" title={workspacePath}> <span className="min-w-0 truncate" title={workspacePath}>
{workspacePath || "默认画布目录"} {workspacePath || t("agent.history.defaultWorkspace")}
</span> </span>
</div> </div>
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm" style={{ color: theme.node.muted }}> <div className="flex items-center gap-2 text-sm" style={{ color: theme.node.muted }}>
{threads.length ? <Checkbox checked={allSelected} indeterminate={Boolean(selectedThreads.length) && !allSelected} disabled={loading || busy} onChange={() => setSelectedIds(allSelected ? new Set() : new Set(threads.map((thread) => thread.id)))} /> : null} {threads.length ? <Checkbox checked={allSelected} indeterminate={Boolean(selectedThreads.length) && !allSelected} disabled={loading || busy} onChange={() => setSelectedIds(allSelected ? new Set() : new Set(threads.map((thread) => thread.id)))} /> : null}
<span>{selectedThreads.length ? `已选 ${selectedThreads.length}` : threads.length ? `${threads.length} 条历史` : connected ? "暂无历史" : "未连接"}</span> <span>{selectedThreads.length ? t("agent.history.selected", { count: selectedThreads.length }) : threads.length ? t("agent.history.count", { count: threads.length }) : connected ? t("agent.history.empty") : t("agent.status.disconnected")}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{selectedThreads.length ? ( {selectedThreads.length ? (
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} disabled={loading || busy} onClick={() => onDeleteThreads(selectedThreads.map((thread) => thread.id))}> <Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} disabled={loading || busy} onClick={() => onDeleteThreads(selectedThreads.map((thread) => thread.id))}>
{selectedThreads.length} {t("agent.history.deleteCount", { count: selectedThreads.length })}
</Button> </Button>
) : null} ) : null}
<Button size="small" icon={<RefreshCw className={`size-3.5 ${loading ? "animate-spin" : ""}`} />} disabled={!connected || loading} onClick={onRefresh}> <Button size="small" icon={<RefreshCw className={`size-3.5 ${loading ? "animate-spin" : ""}`} />} disabled={!connected || loading} onClick={onRefresh}>
{t("agent.history.refresh")}
</Button> </Button>
<Button size="small" type="primary" icon={<Plus className="size-3.5" />} disabled={!connected || loading || busy} onClick={onNewThread}> <Button size="small" type="primary" icon={<Plus className="size-3.5" />} disabled={!connected || loading || busy} onClick={onNewThread}>
{t("agent.history.newThread")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -94,7 +96,7 @@ export function AgentHistoryView({
<Checkbox <Checkbox
checked={selectedIds.has(thread.id)} checked={selectedIds.has(thread.id)}
disabled={loading || busy} disabled={loading || busy}
aria-label={`选择${thread.name || thread.preview || "未命名对话"}`} aria-label={t("agent.history.selectThread", { name: thread.name || thread.preview || t("agent.history.untitled") })}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}
onChange={() => toggleThread(thread.id)} onChange={() => toggleThread(thread.id)}
@@ -103,21 +105,21 @@ export function AgentHistoryView({
<div className="flex min-w-0 items-center gap-1.5"> <div className="flex min-w-0 items-center gap-1.5">
{active ? ( {active ? (
<span className="shrink-0 text-[10px] font-medium" style={{ color: theme.node.text }}> <span className="shrink-0 text-[10px] font-medium" style={{ color: theme.node.text }}>
{t("agent.history.current")}
</span> </span>
) : null} ) : null}
<div className="truncate text-sm font-medium leading-5">{thread.name || thread.preview || "未命名对话"}</div> <div className="truncate text-sm font-medium leading-5">{thread.name || thread.preview || t("agent.history.untitled")}</div>
</div> </div>
<div className="truncate text-[11px] leading-4 opacity-65">{thread.preview || thread.id}</div> <div className="truncate text-[11px] leading-4 opacity-65">{thread.preview || thread.id}</div>
</div> </div>
<span className="shrink-0 text-[10px] opacity-55">{formatThreadTime(thread.updatedAt || thread.createdAt)}</span> <span className="shrink-0 text-[10px] opacity-55">{formatThreadTime(thread.updatedAt || thread.createdAt, i18n.language)}</span>
</div> </div>
</div> </div>
); );
})} })}
{!threads.length ? ( {!threads.length ? (
<div className="px-3 py-8 text-center text-sm" style={{ color: theme.node.muted }}> <div className="px-3 py-8 text-center text-sm" style={{ color: theme.node.muted }}>
{connected ? "当前工作空间还没有对话记录" : "连接本地 Agent 后显示历史记录"} {t(connected ? "agent.history.noWorkspaceThreads" : "agent.history.connectHint")}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -126,8 +128,7 @@ export function AgentHistoryView({
); );
} }
function formatThreadTime(value?: number) { function formatThreadTime(value: number | undefined, locale: string) {
if (!value) return ""; if (!value) return "";
return new Date(value * 1000).toLocaleString(); return new Date(value * 1000).toLocaleString(locale);
} }
+35 -30
View File
@@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, Segmented, Tooltip } from "antd"; import { Button, Segmented, Tooltip } from "antd";
import copyToClipboard from "copy-to-clipboard"; import copyToClipboard from "copy-to-clipboard";
import { CheckCircle2, ChevronDown, CircleAlert, CircleDot, Copy, Trash2, TriangleAlert } from "lucide-react"; import { CheckCircle2, ChevronDown, CircleAlert, CircleDot, Copy, Trash2, TriangleAlert } from "lucide-react";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import type { AgentEventLog } from "@/stores/use-agent-store"; import type { AgentEventLog } from "@/stores/use-agent-store";
import { formatLogJson, formatLogText, type AgentLogContext } from "./agent-event-formatters"; import { formatLogJson, formatLogText, type AgentLogContext } from "./agent-event-formatters";
@@ -27,6 +29,7 @@ export function AgentLogView({
onCopied: (text: string) => void; onCopied: (text: string) => void;
onCopyBlocked: (text: string) => void; onCopyBlocked: (text: string) => void;
}) { }) {
const { t } = useTranslation();
const [mode, setMode] = useState<"text" | "json">("text"); const [mode, setMode] = useState<"text" | "json">("text");
const [filter, setFilter] = useState<LogFilter>("all"); const [filter, setFilter] = useState<LogFilter>("all");
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -93,28 +96,28 @@ export function AgentLogView({
}); });
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, [mode, scrollToBottom, updateScrollState, visibleLogCount]); }, [mode, scrollToBottom, updateScrollState, visibleLogCount]);
const copy = async (value = content, tip = "日志已复制") => { const copy = async (value = content, tip = t("agent.logs.copied")) => {
if (await copyToClipboard(value)) { if (await copyToClipboard(value)) {
onCopied(tip); onCopied(tip);
return; return;
} }
textareaRef.current?.focus(); textareaRef.current?.focus();
textareaRef.current?.select(); textareaRef.current?.select();
onCopyBlocked(mode === "json" ? "已选中日志,请手动复制" : "复制失败,请切换到原始 JSON 后手动复制"); onCopyBlocked(t(mode === "json" ? "agent.logs.selectedManual" : "agent.logs.copyFailed"));
}; };
const connectionLabel = context.connected ? "在线" : context.enabled ? "连接中" : "未启用"; const connectionLabel = t(context.connected ? "agent.events.online" : context.enabled ? "agent.events.connecting" : "agent.events.disabled");
return ( return (
<div className="min-h-0 flex-1 overflow-hidden px-4 py-3"> <div className="min-h-0 flex-1 overflow-hidden px-4 py-3">
<div className="flex h-full min-h-0 flex-col gap-3"> <div className="flex h-full min-h-0 flex-col gap-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="text-base font-semibold leading-6"></div> <div className="text-base font-semibold leading-6">{t("agent.logs.title")}</div>
<Segmented <Segmented
size="small" size="small"
value={mode} value={mode}
onChange={(value) => setMode(value as "text" | "json")} onChange={(value) => setMode(value as "text" | "json")}
options={[ options={[
{ label: "排查日志", value: "text" }, { label: t("agent.logs.diagnostics"), value: "text" },
{ label: "原始 JSON", value: "json" }, { label: t("agent.logs.rawJson"), value: "json" },
]} ]}
/> />
</div> </div>
@@ -134,8 +137,8 @@ export function AgentLogView({
</div> </div>
</div> </div>
<div className="shrink-0 text-right text-[11px] leading-4" style={{ color: theme.node.muted }}> <div className="shrink-0 text-right text-[11px] leading-4" style={{ color: theme.node.muted }}>
<div>{context.messages} </div> <div>{t("agent.logs.messages", { count: context.messages })}</div>
<div>{context.pendingTool ? `工具:${context.pendingTool}` : "无待处理工具"}</div> <div>{context.pendingTool ? t("agent.logs.tool", { tool: context.pendingTool }) : t("agent.logs.noPendingTool")}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -148,30 +151,30 @@ export function AgentLogView({
value={filter} value={filter}
onChange={(value) => setFilter(value as LogFilter)} onChange={(value) => setFilter(value as LogFilter)}
options={[ options={[
{ label: `全部 ${counts.all}`, value: "all" }, { label: t("agent.logs.all", { count: counts.all }), value: "all" },
{ label: `错误 ${counts.error}`, value: "error" }, { label: t("agent.logs.errors", { count: counts.error }), value: "error" },
{ label: `警告 ${counts.warning}`, value: "warning" }, { label: t("agent.logs.warnings", { count: counts.warning }), value: "warning" },
{ label: `信息 ${counts.info}`, value: "info" }, { label: t("agent.logs.info", { count: counts.info }), value: "info" },
]} ]}
/> />
<LogActions logs={logs} lastError={lastError} onClear={onClear} onCopy={(value, tip) => void copy(value, tip)} context={context} /> <LogActions logs={logs} lastError={lastError} onClear={onClear} onCopy={(value, tip) => void copy(value, tip)} context={context} />
</div> </div>
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
<div ref={listRef} tabIndex={0} aria-label="排查日志列表" className="thin-scrollbar h-full overflow-y-auto border-y focus-visible:outline-none" style={{ borderColor: theme.node.stroke }} onScroll={updateScrollState}> <div ref={listRef} tabIndex={0} aria-label={t("agent.logs.list")} className="thin-scrollbar h-full overflow-y-auto border-y focus-visible:outline-none" style={{ borderColor: theme.node.stroke }} onScroll={updateScrollState}>
{visibleLogs.map((item, index) => ( {visibleLogs.map((item, index) => (
<LogRow key={item.id} item={item} theme={theme} onToggle={index === visibleLogs.length - 1 ? handleLastLogToggle : undefined} /> <LogRow key={item.id} item={item} theme={theme} onToggle={index === visibleLogs.length - 1 ? handleLastLogToggle : undefined} />
))} ))}
{!visibleLogs.length ? ( {!visibleLogs.length ? (
<div className="px-3 py-10 text-center text-sm" style={{ color: theme.node.muted }}> <div className="px-3 py-10 text-center text-sm" style={{ color: theme.node.muted }}>
{logs.length ? "当前筛选下没有日志" : "暂无事件日志"} {t(logs.length ? "agent.logs.noFiltered" : "agent.logs.empty")}
</div> </div>
) : null} ) : null}
</div> </div>
{showScrollToBottom ? ( {showScrollToBottom ? (
<AgentScrollToBottom <AgentScrollToBottom
theme={theme} theme={theme}
title={newLogCount ? `${newLogCount} 条新日志` : "查看最新日志"} title={newLogCount ? t("agent.logs.new", { count: newLogCount }) : t("agent.logs.latest")}
ariaLabel={newLogCount ? `${newLogCount} 条新日志,查看最新日志` : "查看最新日志"} ariaLabel={newLogCount ? t("agent.logs.newLabel", { count: newLogCount }) : t("agent.logs.latest")}
className="!bottom-52" className="!bottom-52"
onClick={() => scrollToBottom()} onClick={() => scrollToBottom()}
/> />
@@ -182,7 +185,7 @@ export function AgentLogView({
<> <>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="text-xs" style={{ color: theme.node.muted }}> <span className="text-xs" style={{ color: theme.node.muted }}>
· {logs.length} {t("agent.logs.fullData", { count: logs.length })}
</span> </span>
<LogActions logs={logs} lastError={lastError} onClear={onClear} onCopy={(value, tip) => void copy(value, tip)} context={context} /> <LogActions logs={logs} lastError={lastError} onClear={onClear} onCopy={(value, tip) => void copy(value, tip)} context={context} />
</div> </div>
@@ -202,22 +205,24 @@ export function AgentLogView({
} }
function LogActions({ logs, lastError, context, onClear, onCopy }: { logs: AgentEventLog[]; lastError?: AgentEventLog; context: AgentLogContext; onClear: () => void; onCopy: (value?: string, tip?: string) => void }) { function LogActions({ logs, lastError, context, onClear, onCopy }: { logs: AgentEventLog[]; lastError?: AgentEventLog; context: AgentLogContext; onClear: () => void; onCopy: (value?: string, tip?: string) => void }) {
const { t } = useTranslation();
return ( return (
<div className="flex shrink-0 items-center gap-0.5"> <div className="flex shrink-0 items-center gap-0.5">
<Tooltip title="复制全部日志"> <Tooltip title={t("agent.logs.copyAll")}>
<Button type="text" size="small" shape="circle" aria-label="复制全部日志" icon={<Copy className="size-3.5" />} onClick={() => onCopy()} /> <Button type="text" size="small" shape="circle" aria-label={t("agent.logs.copyAll")} icon={<Copy className="size-3.5" />} onClick={() => onCopy()} />
</Tooltip> </Tooltip>
<Tooltip title="复制最近错误"> <Tooltip title={t("agent.logs.copyLastError")}>
<Button type="text" size="small" shape="circle" aria-label="复制最近错误" disabled={!lastError} icon={<CircleAlert className="size-3.5" />} onClick={() => lastError && onCopy(formatLogText([lastError], context), "最近错误已复制")} /> <Button type="text" size="small" shape="circle" aria-label={t("agent.logs.copyLastError")} disabled={!lastError} icon={<CircleAlert className="size-3.5" />} onClick={() => lastError && onCopy(formatLogText([lastError], context), t("agent.logs.lastErrorCopied"))} />
</Tooltip> </Tooltip>
<Tooltip title="清空日志"> <Tooltip title={t("agent.logs.clear")}>
<Button danger type="text" size="small" shape="circle" aria-label="清空日志" disabled={!logs.length} icon={<Trash2 className="size-3.5" />} onClick={onClear} /> <Button danger type="text" size="small" shape="circle" aria-label={t("agent.logs.clear")} disabled={!logs.length} icon={<Trash2 className="size-3.5" />} onClick={onClear} />
</Tooltip> </Tooltip>
</div> </div>
); );
} }
function LogRow({ item, theme, onToggle }: { item: DisplayLog; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onToggle?: (open: boolean) => void }) { function LogRow({ item, theme, onToggle }: { item: DisplayLog; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onToggle?: (open: boolean) => void }) {
const { t } = useTranslation();
const tone = item.level === "error" ? "text-red-600 dark:text-red-400" : item.level === "warning" ? "text-amber-600 dark:text-amber-400" : item.success ? "text-emerald-600 dark:text-emerald-400" : ""; const tone = item.level === "error" ? "text-red-600 dark:text-red-400" : item.level === "warning" ? "text-amber-600 dark:text-amber-400" : item.success ? "text-emerald-600 dark:text-emerald-400" : "";
const Icon = item.level === "error" ? CircleAlert : item.level === "warning" ? TriangleAlert : item.success ? CheckCircle2 : CircleDot; const Icon = item.level === "error" ? CircleAlert : item.level === "warning" ? TriangleAlert : item.success ? CheckCircle2 : CircleDot;
return ( return (
@@ -233,7 +238,7 @@ function LogRow({ item, theme, onToggle }: { item: DisplayLog; theme: (typeof ca
<span className="truncate text-sm font-medium leading-5">{item.title}</span> <span className="truncate text-sm font-medium leading-5">{item.title}</span>
{item.count > 1 ? ( {item.count > 1 ? (
<span className="shrink-0 text-[10px] leading-4" style={{ color: theme.node.muted }}> <span className="shrink-0 text-[10px] leading-4" style={{ color: theme.node.muted }}>
{item.count} {t("agent.logs.repeated", { count: item.count })}
</span> </span>
) : null} ) : null}
</div> </div>
@@ -248,7 +253,7 @@ function LogRow({ item, theme, onToggle }: { item: DisplayLog; theme: (typeof ca
</summary> </summary>
<div className="pb-3 pl-[34px] pr-2"> <div className="pb-3 pl-[34px] pr-2">
<div className="mb-1 text-[10px] font-medium" style={{ color: theme.node.faint }}> <div className="mb-1 text-[10px] font-medium" style={{ color: theme.node.faint }}>
{t("agent.logs.details")}
</div> </div>
<pre className="thin-scrollbar max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-md border p-2.5 font-mono text-[11px] leading-5" style={{ borderColor: theme.node.stroke, background: theme.node.panel, color: theme.node.text }}> <pre className="thin-scrollbar max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-md border p-2.5 font-mono text-[11px] leading-5" style={{ borderColor: theme.node.stroke, background: theme.node.panel, color: theme.node.text }}>
{item.detail} {item.detail}
@@ -323,13 +328,13 @@ function declaredLogLevel(value: unknown): DisplayLog["level"] | "" {
} }
function logTitle(fallback: string, value: unknown) { function logTitle(fallback: string, value: unknown) {
if (fallback !== "日志" || !value || typeof value !== "object" || Array.isArray(value)) return fallback; if (fallback !== "日志" && fallback !== "Log" || !value || typeof value !== "object" || Array.isArray(value)) return fallback;
const target = String((value as Record<string, unknown>).target || "").toLowerCase(); const target = String((value as Record<string, unknown>).target || "").toLowerCase();
if (target.includes("skill")) return "技能加载"; if (target.includes("skill")) return i18n.t("agent.logs.skillLoading");
if (target.includes("plugin")) return "插件"; if (target.includes("plugin")) return i18n.t("agent.logs.plugin");
if (target.includes("mcp") || target.includes("rmcp")) return "MCP"; if (target.includes("mcp") || target.includes("rmcp")) return "MCP";
if (target.includes("shell")) return "终端"; if (target.includes("shell")) return i18n.t("agent.logs.terminal");
if (target.includes("state_db")) return "会话存储"; if (target.includes("state_db")) return i18n.t("agent.logs.conversationStorage");
return "Codex"; return "Codex";
} }
@@ -1,14 +1,16 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Tooltip } from "antd"; import { Tooltip } from "antd";
import { useTranslation } from "react-i18next";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
export function AgentPanelTabs<T extends string>({ value, items, theme, leading, right, onChange }: { value: T; items: { value: T; label: string; icon?: ReactNode; count?: number }[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; leading?: ReactNode; right?: ReactNode; onChange: (value: T) => void }) { export function AgentPanelTabs<T extends string>({ value, items, theme, leading, right, onChange }: { value: T; items: { value: T; label: string; icon?: ReactNode; count?: number }[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; leading?: ReactNode; right?: ReactNode; onChange: (value: T) => void }) {
const { t } = useTranslation();
return ( return (
<div className="@container border-b px-2" style={{ borderColor: theme.node.stroke }}> <div className="@container border-b px-2" style={{ borderColor: theme.node.stroke }}>
<div className="flex h-12 items-center gap-1"> <div className="flex h-12 items-center gap-1">
{leading ? <div className="flex shrink-0 items-center">{leading}</div> : null} {leading ? <div className="flex shrink-0 items-center">{leading}</div> : null}
<nav className="flex min-w-0 flex-1 items-center justify-center gap-0.5 overflow-hidden text-sm @min-[560px]:gap-3" role="tablist" aria-label="Agent 内容"> <nav className="flex min-w-0 flex-1 items-center justify-center gap-0.5 overflow-hidden text-sm @min-[560px]:gap-3" role="tablist" aria-label={t("agent.panel.content")}>
{items.map((item) => ( {items.map((item) => (
<Tooltip key={item.value} title={`${item.label}${item.count ? ` ${item.count}` : ""}`} placement="bottom"> <Tooltip key={item.value} title={`${item.label}${item.count ? ` ${item.count}` : ""}`} placement="bottom">
<button type="button" role="tab" aria-label={`${item.label}${item.count ? ` ${item.count}` : ""}`} aria-selected={value === item.value} className={`inline-flex h-12 min-w-8 shrink-0 items-center justify-center gap-1.5 border-b-2 px-1.5 transition @min-[560px]:px-0.5 ${value === item.value ? "font-medium" : "font-normal"}`} style={{ borderColor: value === item.value ? theme.node.text : "transparent", color: value === item.value ? theme.node.text : theme.node.muted }} onClick={() => onChange(item.value)}> <button type="button" role="tab" aria-label={`${item.label}${item.count ? ` ${item.count}` : ""}`} aria-selected={value === item.value} className={`inline-flex h-12 min-w-8 shrink-0 items-center justify-center gap-1.5 border-b-2 px-1.5 transition @min-[560px]:px-0.5 ${value === item.value ? "font-medium" : "font-normal"}`} style={{ borderColor: value === item.value ? theme.node.text : "transparent", color: value === item.value ? theme.node.text : theme.node.muted }} onClick={() => onChange(item.value)}>
+3 -1
View File
@@ -1,5 +1,6 @@
import { useState, type PointerEvent as ReactPointerEvent } from "react"; import { useState, type PointerEvent as ReactPointerEvent } from "react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useTranslation } from "react-i18next";
import { LocalAgentPanel } from "./local-agent-panel"; import { LocalAgentPanel } from "./local-agent-panel";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
@@ -9,6 +10,7 @@ import { useThemeStore } from "@/stores/use-theme-store";
const PANEL_MOTION_SECONDS = CANVAS_AGENT_PANEL_MOTION_MS / 1000; const PANEL_MOTION_SECONDS = CANVAS_AGENT_PANEL_MOTION_MS / 1000;
export function AgentPanel() { export function AgentPanel() {
const { t } = useTranslation();
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const width = useAgentStore((state) => state.width); const width = useAgentStore((state) => state.width);
const [resizing, setResizing] = useState(false); const [resizing, setResizing] = useState(false);
@@ -54,7 +56,7 @@ export function AgentPanel() {
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }} transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ width, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }} style={{ width, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
> >
<button type="button" className="absolute inset-y-0 left-0 z-40 w-4 -translate-x-1/2 cursor-col-resize" onPointerDown={startResize} aria-label="调整右侧面板宽度" /> <button type="button" className="absolute inset-y-0 left-0 z-40 w-4 -translate-x-1/2 cursor-col-resize" onPointerDown={startResize} aria-label={t("agent.panel.resize")} />
<LocalAgentPanel embedded /> <LocalAgentPanel embedded />
</motion.aside> </motion.aside>
</motion.div> </motion.div>
@@ -1,6 +1,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Button, Input, Popover, Tooltip } from "antd"; import { Button, Input, Popover, Tooltip } from "antd";
import { Check, Search, Sparkles } from "lucide-react"; import { Check, Search, Sparkles } from "lucide-react";
import { useTranslation } from "react-i18next";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { useAgentSkillStore } from "@/stores/use-agent-skill-store"; import { useAgentSkillStore } from "@/stores/use-agent-skill-store";
@@ -8,6 +9,7 @@ import { useAgentStore } from "@/stores/use-agent-store";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
export function AgentSkillPicker() { export function AgentSkillPicker() {
const { t } = useTranslation();
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const connected = useAgentStore((state) => state.connected); const connected = useAgentStore((state) => state.connected);
const url = useAgentStore((state) => state.url); const url = useAgentStore((state) => state.url);
@@ -26,8 +28,8 @@ export function AgentSkillPicker() {
}, [query, skills]); }, [query, skills]);
const content = ( const content = (
<div className="w-72" style={{ color: theme.node.text }}> <div className="w-72" style={{ color: theme.node.text }}>
<div className="mb-2 px-1 text-xs font-medium" style={{ color: theme.node.muted }}> Skill</div> <div className="mb-2 px-1 text-xs font-medium" style={{ color: theme.node.muted }}>{t("agent.skills.selectLocal")}</div>
<Input allowClear size="small" value={query} onChange={(event) => setQuery(event.target.value)} prefix={<Search className="size-3.5" />} placeholder="搜索 Skill" /> <Input allowClear size="small" value={query} onChange={(event) => setQuery(event.target.value)} prefix={<Search className="size-3.5" />} placeholder={t("agent.skills.search")} />
<div className="thin-scrollbar mt-2 max-h-64 overflow-y-auto"> <div className="thin-scrollbar mt-2 max-h-64 overflow-y-auto">
{enabledSkills.length ? enabledSkills.map((skill) => { {enabledSkills.length ? enabledSkills.map((skill) => {
const selected = selectedSkill?.name === skill.name && selectedSkill.path === skill.path; const selected = selectedSkill?.name === skill.name && selectedSkill.path === skill.path;
@@ -51,14 +53,14 @@ export function AgentSkillPicker() {
); );
}) : ( }) : (
<div className="px-2 py-6 text-center text-xs leading-5" style={{ color: theme.node.muted }}> <div className="px-2 py-6 text-center text-xs leading-5" style={{ color: theme.node.muted }}>
{loading ? "正在读取 Skill…" : skills.length ? "没有匹配的已启用 Skill" : "还没有可用的 Skill"} {t(loading ? "agent.skills.loading" : skills.length ? "agent.skills.noMatch" : "agent.skills.none")}
</div> </div>
)} )}
</div> </div>
</div> </div>
); );
return ( return (
<Tooltip title={connected ? "选择 Skill" : "连接 Agent 后使用 Skill"} placement="top" open={open ? false : undefined}> <Tooltip title={t(connected ? "agent.skills.select" : "agent.skills.connectHint")} placement="top" open={open ? false : undefined}>
<Popover <Popover
arrow={false} arrow={false}
trigger="click" trigger="click"
@@ -77,7 +79,7 @@ export function AgentSkillPicker() {
disabled={!connected} disabled={!connected}
style={{ color: selectedSkill ? theme.node.text : theme.node.muted, background: selectedSkill ? theme.toolbar.activeBg : undefined }} style={{ color: selectedSkill ? theme.node.text : theme.node.muted, background: selectedSkill ? theme.toolbar.activeBg : undefined }}
icon={<Sparkles className="size-4" />} icon={<Sparkles className="size-4" />}
aria-label="选择 Skill" aria-label={t("agent.skills.select")}
/> />
</Popover> </Popover>
</Tooltip> </Tooltip>
+62 -62
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { App, Button, Collapse, Dropdown, Form, Input, Modal, Select, Switch, Tooltip } from "antd"; import { App, Button, Collapse, Dropdown, Form, Input, Modal, Select, Switch, Tooltip } from "antd";
import type { MenuProps } from "antd"; import type { MenuProps } from "antd";
import { Check, ChevronDown, CircleAlert, FilePenLine, LoaderCircle, LockKeyhole, MessageSquareText, Plus, RefreshCw, Search, Sparkles, Trash2, Workflow } from "lucide-react"; import { Check, ChevronDown, CircleAlert, FilePenLine, LoaderCircle, LockKeyhole, MessageSquareText, Plus, RefreshCw, Search, Sparkles, Trash2, Workflow } from "lucide-react";
import { useTranslation } from "react-i18next";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { createCodexSkill, createCodexSkillDraft, deleteCodexSkill, fetchCodexSkill, postState, setCodexSkillEnabled, updateCodexSkill, type AgentSkillDetail, type AgentSkillDraft, type AgentSkillInterface, type AgentSkillScope, type AgentSkillSummary } from "@/services/api/canvas-agent"; import { createCodexSkill, createCodexSkillDraft, deleteCodexSkill, fetchCodexSkill, postState, setCodexSkillEnabled, updateCodexSkill, type AgentSkillDetail, type AgentSkillDraft, type AgentSkillInterface, type AgentSkillScope, type AgentSkillSummary } from "@/services/api/canvas-agent";
@@ -14,10 +15,10 @@ type SkillDraftSource = "conversation" | "canvas";
type SkillEditor = { mode: "create"; values?: SkillFormValues } | { mode: "edit"; detail: AgentSkillDetail }; type SkillEditor = { mode: "create"; values?: SkillFormValues } | { mode: "edit"; detail: AgentSkillDetail };
type SkillFormValues = { name: string; description: string; instructions: string; displayName?: string; shortDescription?: string; defaultPrompt?: string }; type SkillFormValues = { name: string; description: string; instructions: string; displayName?: string; shortDescription?: string; defaultPrompt?: string };
const scopeLabels: Record<AgentSkillScope, string> = { repo: "项目", user: "个人", system: "系统", admin: "管理员" };
const skillNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const skillNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export function AgentSkillsView({ clientId }: { clientId: string }) { export function AgentSkillsView({ clientId }: { clientId: string }) {
const { t } = useTranslation();
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { message, modal } = App.useApp(); const { message, modal } = App.useApp();
const connected = useAgentStore((state) => state.connected); const connected = useAgentStore((state) => state.connected);
@@ -89,16 +90,16 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
}; };
const generateDraft = async (source: SkillDraftSource) => { const generateDraft = async (source: SkillDraftSource) => {
const agent = useAgentStore.getState(); const agent = useAgentStore.getState();
if (agent.sending || agent.waiting) return message.warning("Codex 正在运行,请完成当前任务后再提炼 Skill"); if (agent.sending || agent.waiting) return message.warning(t("agent.skillManager.codexBusy"));
if (source === "conversation" && !hasSettledConversation(agent.messages, agent.activeThreadId)) return message.warning("当前对话还没有可提炼的已完成内容"); if (source === "conversation" && !hasSettledConversation(agent.messages, agent.activeThreadId)) return message.warning(t("agent.skillManager.noConversation"));
if (source === "canvas" && !agent.canvasContext) return message.warning("当前页面没有可提炼的画布"); if (source === "canvas" && !agent.canvasContext) return message.warning(t("agent.skillManager.noCanvas"));
if (!clientId) return message.warning("当前页面仍在连接 Agent,请稍后再试"); if (!clientId) return message.warning(t("agent.skillManager.connecting"));
const connectionRevision = useAgentSkillStore.getState().connectionRevision; const connectionRevision = useAgentSkillStore.getState().connectionRevision;
setGeneratingSource(source); setGeneratingSource(source);
try { try {
if (source === "canvas") { if (source === "canvas") {
const synced = await postState(endpoint, token, clientId, agent.canvasContext?.snapshot || null); const synced = await postState(endpoint, token, clientId, agent.canvasContext?.snapshot || null);
if (!synced) throw new Error("同步当前画布失败,请检查 Agent 连接后重试"); if (!synced) throw new Error(t("agent.skillManager.syncFailed"));
} }
if (!connectionIsCurrent(connectionRevision)) return; if (!connectionIsCurrent(connectionRevision)) return;
const response = await createCodexSkillDraft(endpoint, token, { const response = await createCodexSkillDraft(endpoint, token, {
@@ -109,11 +110,11 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
...(agent.reasoningEffort ? { effort: agent.reasoningEffort } : {}), ...(agent.reasoningEffort ? { effort: agent.reasoningEffort } : {}),
}); });
if (!connectionIsCurrent(connectionRevision)) return; if (!connectionIsCurrent(connectionRevision)) return;
if (!response.data) throw new Error("未生成 Skill 草稿"); if (!response.data) throw new Error(t("agent.skillManager.noDraft"));
setDraft(response.data); setDraft(response.data);
message.success("草稿已生成,可在技能页确认后创建"); message.success(t("agent.skillManager.draftCreated"));
} catch (error) { } catch (error) {
if (connectionIsCurrent(connectionRevision)) message.error(error instanceof Error ? error.message : "生成 Skill 草稿失败"); if (connectionIsCurrent(connectionRevision)) message.error(error instanceof Error ? error.message : t("agent.skillManager.draftFailed"));
} finally { } finally {
if (connectionIsCurrent(connectionRevision)) setGeneratingSource(null); if (connectionIsCurrent(connectionRevision)) setGeneratingSource(null);
} }
@@ -125,10 +126,10 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
try { try {
const response = await fetchCodexSkill(endpoint, token, skill.name); const response = await fetchCodexSkill(endpoint, token, skill.name);
if (!connectionIsCurrent(connectionRevision)) return; if (!connectionIsCurrent(connectionRevision)) return;
if (!response.data) throw new Error("未读取到 Skill 内容"); if (!response.data) throw new Error(t("agent.skillManager.contentMissing"));
setEditor({ mode: "edit", detail: response.data }); setEditor({ mode: "edit", detail: response.data });
} catch (error) { } catch (error) {
if (connectionIsCurrent(connectionRevision)) message.error(error instanceof Error ? error.message : "读取 Skill 失败"); if (connectionIsCurrent(connectionRevision)) message.error(error instanceof Error ? error.message : t("agent.skillManager.readFailed"));
} finally { } finally {
if (connectionIsCurrent(connectionRevision)) setBusySkill(""); if (connectionIsCurrent(connectionRevision)) setBusySkill("");
} }
@@ -147,7 +148,7 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
const name = editor.mode === "edit" ? editor.detail.name : values.name.trim(); const name = editor.mode === "edit" ? editor.detail.name : values.name.trim();
const skillInterface = compactInterface(values); const skillInterface = compactInterface(values);
if (skillInterface?.defaultPrompt && !mentionsSkill(skillInterface.defaultPrompt, name)) { if (skillInterface?.defaultPrompt && !mentionsSkill(skillInterface.defaultPrompt, name)) {
form.setFields([{ name: "defaultPrompt", errors: [`默认提示词需要包含 $${name}`] }]); form.setFields([{ name: "defaultPrompt", errors: [t("agent.skillManager.defaultPromptMention", { name })] }]);
setAdvancedOpen(true); setAdvancedOpen(true);
requestAnimationFrame(() => form.scrollToField("defaultPrompt", { block: "center" })); requestAnimationFrame(() => form.scrollToField("defaultPrompt", { block: "center" }));
return; return;
@@ -165,9 +166,9 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
setAdvancedOpen(false); setAdvancedOpen(false);
await refresh(); await refresh();
if (!connectionIsCurrent(connectionRevision)) return; if (!connectionIsCurrent(connectionRevision)) return;
message.success(editor.mode === "create" ? "Skill 已创建" : "Skill 已更新"); message.success(t(editor.mode === "create" ? "agent.skillManager.created" : "agent.skillManager.updated"));
} catch (error) { } catch (error) {
if (connectionIsCurrent(connectionRevision)) message.error(error instanceof Error ? error.message : "保存 Skill 失败"); if (connectionIsCurrent(connectionRevision)) message.error(error instanceof Error ? error.message : t("agent.skillManager.saveFailed"));
} finally { } finally {
if (connectionIsCurrent(connectionRevision)) setSaving(false); if (connectionIsCurrent(connectionRevision)) setSaving(false);
} }
@@ -175,27 +176,27 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
const confirmDelete = (skill: AgentSkillSummary) => { const confirmDelete = (skill: AgentSkillSummary) => {
const connectionRevision = useAgentSkillStore.getState().connectionRevision; const connectionRevision = useAgentSkillStore.getState().connectionRevision;
confirmRef.current = modal.confirm({ confirmRef.current = modal.confirm({
title: `删除 ${skill.interface?.displayName || skill.name}`, title: t("agent.skillManager.deleteTitle", { name: skill.interface?.displayName || skill.name }),
content: "删除后本地文件无法恢复,确定继续吗?", content: t("agent.skillManager.deleteDescription"),
okText: "删除", okText: t("agent.skillManager.delete"),
okType: "danger", okType: "danger",
cancelText: "取消", cancelText: t("common.cancel"),
onOk: async () => { onOk: async () => {
if (!connectionIsCurrent(connectionRevision)) return; if (!connectionIsCurrent(connectionRevision)) return;
setBusySkill(skill.path); setBusySkill(skill.path);
try { try {
const response = await fetchCodexSkill(endpoint, token, skill.name); const response = await fetchCodexSkill(endpoint, token, skill.name);
if (!connectionIsCurrent(connectionRevision)) return; if (!connectionIsCurrent(connectionRevision)) return;
if (!response.data) throw new Error("未读取到 Skill 内容"); if (!response.data) throw new Error(t("agent.skillManager.contentMissing"));
await deleteCodexSkill(endpoint, token, skill.name, response.data.revision); await deleteCodexSkill(endpoint, token, skill.name, response.data.revision);
if (!connectionIsCurrent(connectionRevision)) return; if (!connectionIsCurrent(connectionRevision)) return;
if (selectedSkill?.name === skill.name && selectedSkill.path === skill.path) clearSelection(); if (selectedSkill?.name === skill.name && selectedSkill.path === skill.path) clearSelection();
await refresh(); await refresh();
if (!connectionIsCurrent(connectionRevision)) return; if (!connectionIsCurrent(connectionRevision)) return;
message.success("Skill 已删除"); message.success(t("agent.skillManager.deleted"));
} catch (error) { } catch (error) {
if (!connectionIsCurrent(connectionRevision)) return; if (!connectionIsCurrent(connectionRevision)) return;
message.error(error instanceof Error ? error.message : "删除 Skill 失败"); message.error(error instanceof Error ? error.message : t("agent.skillManager.deleteFailed"));
throw error; throw error;
} finally { } finally {
if (connectionIsCurrent(connectionRevision)) setBusySkill(""); if (connectionIsCurrent(connectionRevision)) setBusySkill("");
@@ -216,7 +217,7 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
if (!enabled && selectedSkill?.name === skill.name && selectedSkill.path === skill.path) clearSelection(); if (!enabled && selectedSkill?.name === skill.name && selectedSkill.path === skill.path) clearSelection();
await refresh(); await refresh();
} catch (error) { } catch (error) {
if (connectionIsCurrent(connectionRevision)) message.error(error instanceof Error ? error.message : "更新 Skill 状态失败"); if (connectionIsCurrent(connectionRevision)) message.error(error instanceof Error ? error.message : t("agent.skillManager.statusFailed"));
} finally { } finally {
if (connectionIsCurrent(connectionRevision)) setBusySkill(""); if (connectionIsCurrent(connectionRevision)) setBusySkill("");
} }
@@ -230,8 +231,8 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
disabled: codexBusy || !hasConversation, disabled: codexBusy || !hasConversation,
label: ( label: (
<div className="py-0.5"> <div className="py-0.5">
<div className="text-sm">稿</div> <div className="text-sm">{t("agent.skillManager.fromConversation")}</div>
<div className="mt-0.5 text-xs" style={{ color: theme.node.muted }}>{codexBusy ? "Codex 运行结束后可用" : hasConversation ? "整理当前对话中的可复用流程" : activeThreadId ? "当前对话还没有已完成内容" : "请先开始一段对话"}</div> <div className="mt-0.5 text-xs" style={{ color: theme.node.muted }}>{t(codexBusy ? "agent.skillManager.availableAfterRun" : hasConversation ? "agent.skillManager.conversationDescription" : activeThreadId ? "agent.skillManager.noCompletedContent" : "agent.skillManager.startConversation")}</div>
</div> </div>
), ),
}, },
@@ -241,8 +242,8 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
disabled: codexBusy || !hasCanvas, disabled: codexBusy || !hasCanvas,
label: ( label: (
<div className="py-0.5"> <div className="py-0.5">
<div className="text-sm">稿</div> <div className="text-sm">{t("agent.skillManager.fromCanvas")}</div>
<div className="mt-0.5 text-xs" style={{ color: theme.node.muted }}>{codexBusy ? "Codex 运行结束后可用" : hasCanvas ? "整理当前页面的节点与生成流程" : "当前页面没有可用画布"}</div> <div className="mt-0.5 text-xs" style={{ color: theme.node.muted }}>{t(codexBusy ? "agent.skillManager.availableAfterRun" : hasCanvas ? "agent.skillManager.canvasDescription" : "agent.skillManager.canvasUnavailable")}</div>
</div> </div>
), ),
}, },
@@ -252,12 +253,11 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
icon: <FilePenLine className="size-4" />, icon: <FilePenLine className="size-4" />,
label: ( label: (
<div className="py-0.5"> <div className="py-0.5">
<div className="text-sm"></div> <div className="text-sm">{t("agent.skillManager.blankCreate")}</div>
<div className="mt-0.5 text-xs" style={{ color: theme.node.muted }}></div> <div className="mt-0.5 text-xs" style={{ color: theme.node.muted }}>{t("agent.skillManager.blankDescription")}</div>
</div> </div>
), ),
}, },
],
onClick: ({ key }) => { onClick: ({ key }) => {
if (key === "manual") { if (key === "manual") {
setDraft(null); setDraft(null);
@@ -272,40 +272,40 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
<div className="shrink-0 border-b px-4 py-3" style={{ borderColor: theme.node.stroke }}> <div className="shrink-0 border-b px-4 py-3" style={{ borderColor: theme.node.stroke }}>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<div className="text-sm font-semibold"> Skill</div> <div className="text-sm font-semibold">{t("agent.skillManager.localSkills")}</div>
<div className="mt-0.5 text-xs" style={{ color: theme.node.muted }}> Codex </div> <div className="mt-0.5 text-xs" style={{ color: theme.node.muted }}>{t("agent.skillManager.localDescription")}</div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Tooltip title="重新读取"> <Tooltip title={t("agent.skillManager.reload")}>
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" aria-label="重新读取 Skill" disabled={!connected || loading} icon={<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />} onClick={() => void refresh()} /> <Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" aria-label={t("agent.skillManager.reloadSkill")} disabled={!connected || loading} icon={<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />} onClick={() => void refresh()} />
</Tooltip> </Tooltip>
<Dropdown trigger={["click"]} placement="bottomRight" open={createMenuOpen} onOpenChange={setCreateMenuOpen} disabled={!connected || !clientId || Boolean(generatingSource)} menu={createMenu}> <Dropdown trigger={["click"]} placement="bottomRight" open={createMenuOpen} onOpenChange={setCreateMenuOpen} disabled={!connected || !clientId || Boolean(generatingSource)} menu={createMenu}>
<Button type="text" className="!h-8 !px-2" aria-haspopup="menu" aria-expanded={createMenuOpen} disabled={!connected || !clientId} loading={Boolean(generatingSource)} icon={<Plus className="size-4" />}> <Button type="text" className="!h-8 !px-2" aria-haspopup="menu" aria-expanded={createMenuOpen} disabled={!connected || !clientId} loading={Boolean(generatingSource)} icon={<Plus className="size-4" />}>
Skill <ChevronDown className="size-3.5 opacity-60" /> {t("agent.skillManager.createSkill")} <ChevronDown className="size-3.5 opacity-60" />
</Button> </Button>
</Dropdown> </Dropdown>
</div> </div>
</div> </div>
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
<Input aria-label="搜索 Skill" className="min-w-0 flex-1" allowClear disabled={!connected} value={query} onChange={(event) => setQuery(event.target.value)} prefix={<Search className="size-3.5" />} placeholder="搜索 Skill" /> <Input aria-label={t("agent.skills.search")} className="min-w-0 flex-1" allowClear disabled={!connected} value={query} onChange={(event) => setQuery(event.target.value)} prefix={<Search className="size-3.5" />} placeholder={t("agent.skills.search")} />
<Select<ScopeFilter> <Select<ScopeFilter>
size="small" size="small"
variant="borderless" variant="borderless"
aria-label="按来源筛选 Skill" aria-label={t("agent.skillManager.filterBySource")}
className="w-28 shrink-0" className="w-28 shrink-0"
disabled={!connected} disabled={!connected}
value={scope} value={scope}
onChange={setScope} onChange={setScope}
options={[{ value: "all", label: "全部来源" }, ...Object.entries(scopeLabels).map(([value, label]) => ({ value: value as AgentSkillScope, label }))]} options={[{ value: "all", label: t("agent.skillManager.scopes.all") }, ...(["repo", "user", "system", "admin"] as AgentSkillScope[]).map((value) => ({ value, label: t(`agent.skillManager.scopes.${value}`) }))]}
/> />
</div> </div>
{errors.length ? ( {errors.length ? (
<Button danger type="text" size="small" className="!mt-1 !h-7 !px-1 text-xs" icon={<CircleAlert className="size-3.5" />} onClick={() => setErrorsOpen(true)}>{errors.length} Skill </Button> <Button danger type="text" size="small" className="!mt-1 !h-7 !px-1 text-xs" icon={<CircleAlert className="size-3.5" />} onClick={() => setErrorsOpen(true)}>{t("agent.skillManager.loadErrors", { count: errors.length })}</Button>
) : null} ) : null}
</div> </div>
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto px-4"> <div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto px-4">
{loading && !loaded ? ( {loading && !loaded ? (
<div className="flex h-40 items-center justify-center gap-2 text-sm" style={{ color: theme.node.muted }}><LoaderCircle className="size-4 animate-spin" /> Skill</div> <div className="flex h-40 items-center justify-center gap-2 text-sm" style={{ color: theme.node.muted }}><LoaderCircle className="size-4 animate-spin" />{t("agent.skills.loading")}</div>
) : filteredSkills.length ? ( ) : filteredSkills.length ? (
<div className="divide-y" style={{ borderColor: theme.node.stroke }}> <div className="divide-y" style={{ borderColor: theme.node.stroke }}>
{filteredSkills.map((skill) => { {filteredSkills.map((skill) => {
@@ -318,25 +318,25 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{skill.interface?.displayName || skill.name}</span> <span className="truncate text-sm font-medium">{skill.interface?.displayName || skill.name}</span>
{!skill.managed ? <Tooltip title="外部 Skill 只能使用或启停"><LockKeyhole className="size-3.5 shrink-0" style={{ color: theme.node.faint }} /></Tooltip> : null} {!skill.managed ? <Tooltip title={t("agent.skillManager.externalReadonly")}><LockKeyhole className="size-3.5 shrink-0" style={{ color: theme.node.faint }} /></Tooltip> : null}
</div> </div>
<div className="mt-1 line-clamp-2 text-xs leading-5" style={{ color: theme.node.muted }}>{skill.interface?.shortDescription || skill.shortDescription || skill.description || "暂无说明"}</div> <div className="mt-1 line-clamp-2 text-xs leading-5" style={{ color: theme.node.muted }}>{skill.interface?.shortDescription || skill.shortDescription || skill.description || t("agent.skillManager.noDescription")}</div>
<Tooltip title={skill.path}> <Tooltip title={skill.path}>
<div className="mt-1.5 truncate text-[11px]" style={{ color: theme.node.faint }}>{scopeLabels[skill.scope] || skill.scope} · {skill.name}</div> <div className="mt-1.5 truncate text-[11px]" style={{ color: theme.node.faint }}>{t(`agent.skillManager.scopes.${skill.scope}`)} · {skill.name}</div>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<div className="mt-2 flex items-center justify-between gap-2 pl-7"> <div className="mt-2 flex items-center justify-between gap-2 pl-7">
<label className="inline-flex items-center gap-2 text-xs" style={{ color: theme.node.muted }}> <label className="inline-flex items-center gap-2 text-xs" style={{ color: theme.node.muted }}>
<Switch size="small" checked={skill.enabled} loading={busy} disabled={!connected || Boolean(busySkill) || Boolean(generatingSource)} onChange={(enabled) => void toggleEnabled(skill, enabled)} /> <Switch size="small" checked={skill.enabled} loading={busy} disabled={!connected || Boolean(busySkill) || Boolean(generatingSource)} onChange={(enabled) => void toggleEnabled(skill, enabled)} />
{skill.enabled ? "已启用" : "已停用"} {t(skill.enabled ? "agent.skillManager.enabled" : "agent.skillManager.disabled")}
</label> </label>
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
<Button type="text" size="small" disabled={!connected || !skill.enabled || Boolean(busySkill)} icon={selected ? <Check className="size-3.5" /> : <Sparkles className="size-3.5" />} onClick={() => useSkill(skill)}>{selected ? "已选择" : "使用"}</Button> <Button type="text" size="small" disabled={!connected || !skill.enabled || Boolean(busySkill)} icon={selected ? <Check className="size-3.5" /> : <Sparkles className="size-3.5" />} onClick={() => useSkill(skill)}>{t(selected ? "agent.skillManager.selected" : "agent.skillManager.use")}</Button>
{skill.managed ? ( {skill.managed ? (
<> <>
<Tooltip title="编辑"><Button type="text" shape="circle" size="small" aria-label={`编辑 ${skill.interface?.displayName || skill.name}`} disabled={!connected || Boolean(busySkill) || Boolean(generatingSource)} icon={<FilePenLine className="size-3.5" />} onClick={() => void openEdit(skill)} /></Tooltip> <Tooltip title={t("common.edit")}><Button type="text" shape="circle" size="small" aria-label={t("agent.skillManager.editNamed", { name: skill.interface?.displayName || skill.name })} disabled={!connected || Boolean(busySkill) || Boolean(generatingSource)} icon={<FilePenLine className="size-3.5" />} onClick={() => void openEdit(skill)} /></Tooltip>
<Tooltip title="删除"><Button danger type="text" shape="circle" size="small" aria-label={`删除 ${skill.interface?.displayName || skill.name}`} disabled={!connected || Boolean(busySkill) || Boolean(generatingSource)} icon={<Trash2 className="size-3.5" />} onClick={() => confirmDelete(skill)} /></Tooltip> <Tooltip title={t("common.delete")}><Button danger type="text" shape="circle" size="small" aria-label={t("agent.skillManager.deleteNamed", { name: skill.interface?.displayName || skill.name })} disabled={!connected || Boolean(busySkill) || Boolean(generatingSource)} icon={<Trash2 className="size-3.5" />} onClick={() => confirmDelete(skill)} /></Tooltip>
</> </>
) : null} ) : null}
</div> </div>
@@ -348,23 +348,23 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
) : ( ) : (
<div className="flex h-48 flex-col items-center justify-center text-center"> <div className="flex h-48 flex-col items-center justify-center text-center">
<Sparkles className="size-5" style={{ color: theme.node.faint }} /> <Sparkles className="size-5" style={{ color: theme.node.faint }} />
<div className="mt-3 text-sm font-medium">{!connected ? "连接 Agent 后查看 Skill" : skills.length ? "没有匹配的 Skill" : "还没有本地 Skill"}</div> <div className="mt-3 text-sm font-medium">{t(!connected ? "agent.skillManager.connectToView" : skills.length ? "agent.skillManager.noMatch" : "agent.skillManager.none")}</div>
<div className="mt-1 text-xs" style={{ color: theme.node.muted }}>{!connected ? "连接成功后会读取本机已安装的 Skill" : skills.length ? "换个关键词或来源试试" : "创建一个,或在本机安装后刷新"}</div> <div className="mt-1 text-xs" style={{ color: theme.node.muted }}>{t(!connected ? "agent.skillManager.connectDescription" : skills.length ? "agent.skillManager.tryAnotherFilter" : "agent.skillManager.createOrInstall")}</div>
</div> </div>
)} )}
</div> </div>
<Modal title={`${errors.length} 个 Skill 未能加载`} open={errorsOpen} footer={null} width={720} onCancel={() => setErrorsOpen(false)}> <Modal title={t("agent.skillManager.loadErrors", { count: errors.length })} open={errorsOpen} footer={null} width={720} onCancel={() => setErrorsOpen(false)}>
<div className="thin-scrollbar mt-4 max-h-[60vh] overflow-y-auto rounded-md border px-3 py-2 text-xs leading-5" style={{ borderColor: theme.node.stroke }}> <div className="thin-scrollbar mt-4 max-h-[60vh] overflow-y-auto rounded-md border px-3 py-2 text-xs leading-5" style={{ borderColor: theme.node.stroke }}>
{errors.map((error, index) => <div key={`${index}:${error}`} className="break-all py-1" style={{ color: theme.node.muted }}>{error}</div>)} {errors.map((error, index) => <div key={`${index}:${error}`} className="break-all py-1" style={{ color: theme.node.muted }}>{error}</div>)}
</div> </div>
</Modal> </Modal>
<Modal <Modal
title={editor?.mode === "edit" ? `编辑 ${editor.detail.interface?.displayName || editor.detail.name}` : "创建 Skill"} title={editor?.mode === "edit" ? t("agent.skillManager.editNamed", { name: editor.detail.interface?.displayName || editor.detail.name }) : t("agent.skillManager.createSkill")}
open={Boolean(editor)} open={Boolean(editor)}
okText={editor?.mode === "edit" ? "保存更改" : "创建 Skill"} okText={t(editor?.mode === "edit" ? "agent.skillManager.saveChanges" : "agent.skillManager.createSkill")}
cancelText="取消" cancelText={t("common.cancel")}
confirmLoading={saving} confirmLoading={saving}
width={680} width={680}
centered centered
@@ -378,17 +378,17 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
}} }}
onOk={() => void saveSkill()} onOk={() => void saveSkill()}
> >
<div className="mb-5 text-xs" style={{ color: theme.node.muted }}> Agent · <span className="font-mono">.agents/skills</span></div> <div className="mb-5 text-xs" style={{ color: theme.node.muted }}>{t("agent.skillManager.saveLocation")} · <span className="font-mono">.agents/skills</span></div>
<Form key={editor?.mode === "edit" ? editor.detail.revision : `create:${editor?.values?.name || "blank"}`} form={form} initialValues={editorValues} layout="vertical" requiredMark="optional" preserve={false}> <Form key={editor?.mode === "edit" ? editor.detail.revision : `create:${editor?.values?.name || "blank"}`} form={form} initialValues={editorValues} layout="vertical" requiredMark="optional" preserve={false}>
<div className="mb-3 text-xs font-medium" style={{ color: theme.node.muted }}></div> <div className="mb-3 text-xs font-medium" style={{ color: theme.node.muted }}>{t("agent.skillManager.basicInfo")}</div>
<div className="grid grid-cols-1 gap-x-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-x-4 sm:grid-cols-2">
<Form.Item name="name" label="Skill 标识" extra="用于文件夹名和 $skill-name 调用。" rules={[{ required: true, message: "请输入 Skill 标识" }, { max: 64, message: "Skill 标识不能超过 64 个字符" }, { pattern: skillNamePattern, message: "仅支持小写字母、数字和连字符,连字符不能连续或位于首尾" }]}> <Form.Item name="name" label={t("agent.skillManager.identifier")} extra={t("agent.skillManager.identifierExtra")} rules={[{ required: true, message: t("agent.skillManager.identifierRequired") }, { max: 64, message: t("agent.skillManager.identifierMax") }, { pattern: skillNamePattern, message: t("agent.skillManager.identifierPattern") }]}>
<Input maxLength={64} disabled={editor?.mode === "edit"} placeholder="例如 product-grid" /> <Input maxLength={64} disabled={editor?.mode === "edit"} placeholder={t("agent.skillManager.identifierPlaceholder")} />
</Form.Item> </Form.Item>
<Form.Item name="displayName" label="显示名称" rules={[{ max: 64, message: "显示名称不能超过 64 个字符" }]}><Input maxLength={64} placeholder="例如 产品九宫格生成" /></Form.Item> <Form.Item name="displayName" label={t("agent.skillManager.displayName")} rules={[{ max: 64, message: t("agent.skillManager.displayNameMax") }]}><Input maxLength={64} placeholder={t("agent.skillManager.displayNamePlaceholder")} /></Form.Item>
</div> </div>
<Form.Item name="description" label="何时使用" extra="说明这个 Skill 的能力和适用场景,Codex 会据此判断是否调用。" rules={[{ required: true, message: "请输入使用场景" }, { max: 1024, message: "使用场景不能超过 1024 个字符" }, { validator: (_, value) => typeof value === "string" && /[<>]/.test(value) ? Promise.reject(new Error("使用场景不能包含尖括号")) : Promise.resolve() }]}><Input.TextArea maxLength={1024} autoSize={{ minRows: 2, maxRows: 4 }} placeholder="例如:当用户需要基于商品信息规划并生成一组产品图时使用" /></Form.Item> <Form.Item name="description" label={t("agent.skillManager.whenToUse")} extra={t("agent.skillManager.whenToUseExtra")} rules={[{ required: true, message: t("agent.skillManager.whenToUseRequired") }, { max: 1024, message: t("agent.skillManager.whenToUseMax") }, { validator: (_, value) => typeof value === "string" && /[<>]/.test(value) ? Promise.reject(new Error(t("agent.skillManager.noAngleBrackets"))) : Promise.resolve() }]}><Input.TextArea maxLength={1024} autoSize={{ minRows: 2, maxRows: 4 }} placeholder={t("agent.skillManager.whenToUsePlaceholder")} /></Form.Item>
<Form.Item name="instructions" label="执行说明" extra="按实际执行顺序写清步骤、约束和输出要求。" rules={[{ required: true, message: "请输入执行说明" }]}><Input.TextArea className="!leading-6" autoSize={{ minRows: 6, maxRows: 10 }} placeholder="写清楚执行步骤、必要检查和最终输出" /></Form.Item> <Form.Item name="instructions" label={t("agent.skillManager.instructions")} extra={t("agent.skillManager.instructionsExtra")} rules={[{ required: true, message: t("agent.skillManager.instructionsRequired") }]}><Input.TextArea className="!leading-6" autoSize={{ minRows: 6, maxRows: 10 }} placeholder={t("agent.skillManager.instructionsPlaceholder")} /></Form.Item>
<Collapse <Collapse
ghost ghost
size="small" size="small"
@@ -398,11 +398,11 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
items={[{ items={[{
key: "advanced", key: "advanced",
forceRender: true, forceRender: true,
label: <span className="text-sm font-medium"></span>, label: <span className="text-sm font-medium">{t("agent.skillManager.advanced")}</span>,
children: ( children: (
<> <>
<Form.Item name="shortDescription" label="卡片短说明" extra="填写时控制在 25–64 个字符,便于快速浏览。" rules={[{ min: 25, message: "卡片短说明不能少于 25 个字符" }, { max: 64, message: "卡片短说明不能超过 64 个字符" }]}><Input maxLength={64} showCount placeholder="可选,用于列表展示" /></Form.Item> <Form.Item name="shortDescription" label={t("agent.skillManager.shortDescription")} extra={t("agent.skillManager.shortDescriptionExtra")} rules={[{ min: 25, message: t("agent.skillManager.shortDescriptionMin") }, { max: 64, message: t("agent.skillManager.shortDescriptionMax") }]}><Input maxLength={64} showCount placeholder={t("agent.skillManager.shortDescriptionPlaceholder")} /></Form.Item>
<Form.Item name="defaultPrompt" label="默认提示词" extra="填写时必须准确包含 $skill-name,例如 $product-grid。" rules={[{ max: 1024, message: "默认提示词不能超过 1024 个字符" }]}><Input.TextArea maxLength={1024} autoSize={{ minRows: 2, maxRows: 4 }} placeholder="可选,选择 Skill 时预填到输入框" /></Form.Item> <Form.Item name="defaultPrompt" label={t("agent.skillManager.defaultPrompt")} extra={t("agent.skillManager.defaultPromptExtra")} rules={[{ max: 1024, message: t("agent.skillManager.defaultPromptMax") }]}><Input.TextArea maxLength={1024} autoSize={{ minRows: 2, maxRows: 4 }} placeholder={t("agent.skillManager.defaultPromptPlaceholder")} /></Form.Item>
</> </>
), ),
}]} }]}
+120 -117
View File
@@ -3,7 +3,9 @@ import { useNavigate, useSearchParams } from "react-router-dom";
import { App, Button, Tooltip } from "antd"; import { App, Button, Tooltip } from "antd";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Bot, History, MessageSquare, PanelRightClose, PlugZap, Plus, Sparkles, Terminal } from "lucide-react"; import { Bot, History, MessageSquare, PanelRightClose, PlugZap, Plus, Sparkles, Terminal } from "lucide-react";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { imageMetadata } from "@/lib/canvas/canvas-node-factory"; import { imageMetadata } from "@/lib/canvas/canvas-node-factory";
import { fitNodeSize } from "@/lib/canvas/canvas-node-size"; import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
@@ -69,7 +71,7 @@ const DEFAULT_AGENT_URL = "http://127.0.0.1:17371";
const AGENT_PROTOCOL_VERSION = 5; const AGENT_PROTOCOL_VERSION = 5;
const HISTORY_RETRY_DELAYS_MS = [0, 150, 350, 700, 1200]; const HISTORY_RETRY_DELAYS_MS = [0, 150, 350, 700, 1200];
const AGENT_REASONING_EFFORTS = new Set<AgentReasoningEffort>(["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]); const AGENT_REASONING_EFFORTS = new Set<AgentReasoningEffort>(["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]);
const AGENT_REASONING_LABELS: Record<AgentReasoningEffort, string> = { minimal: "最低", low: "轻度", medium: "中", high: "高", xhigh: "极高", max: "最高", ultra: "Ultra" }; const rt = (key: string, options?: Record<string, unknown>) => i18n.t(`agent.runtime.${key}`, options);
type AgentWorkspace = { workspacePath: string; activeThreadId?: string }; type AgentWorkspace = { workspacePath: string; activeThreadId?: string };
type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; conversation?: AgentConversationState; data?: AgentThreadSummary[] }; type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; conversation?: AgentConversationState; data?: AgentThreadSummary[] };
@@ -95,29 +97,30 @@ function agentErrorState(error: unknown) {
function conversationBootstrapView(conversation: AgentConversationState) { function conversationBootstrapView(conversation: AgentConversationState) {
const mcpStartupStatuses: Record<string, AgentBootstrapStatus> = Object.fromEntries(Object.entries(conversation.mcpStatuses).map(([name, item]) => { const mcpStartupStatuses: Record<string, AgentBootstrapStatus> = Object.fromEntries(Object.entries(conversation.mcpStatuses).map(([name, item]) => {
const view: AgentBootstrapStatus = item.status === "starting" const view: AgentBootstrapStatus = item.status === "starting"
? { key: `mcp:${name}:starting`, text: `正在启动 MCP${name}`, detail: "正在建立工具连接并读取可用工具列表", status: "running" } ? { key: `mcp:${name}:starting`, text: rt("mcpStarting", { name }), detail: rt("mcpConnecting"), status: "running" }
: item.status === "ready" : item.status === "ready"
? { key: `mcp:${name}:ready`, text: `MCP 已就绪:${name}`, detail: "工具列表加载完成,可以开始对话", status: "ready" } ? { key: `mcp:${name}:ready`, text: rt("mcpReadyNamed", { name }), detail: rt("toolsReady"), status: "ready" }
: { key: `mcp:${name}:${item.status}`, text: item.status === "failed" ? `MCP 启动失败:${name}` : `MCP 启动已取消:${name}`, detail: item.error || "工具服务未能完成初始化", status: "error" }; : { key: `mcp:${name}:${item.status}`, text: rt(item.status === "failed" ? "mcpFailedNamed" : "mcpCanceledNamed", { name }), detail: item.error || rt("toolInitFailed"), status: "error" };
return [name, view]; return [name, view];
})); }));
const services = Object.values(mcpStartupStatuses); const services = Object.values(mcpStartupStatuses);
const pending = services.filter((item) => item.status === "running").length; const pending = services.filter((item) => item.status === "running").length;
const bootstrapStatus: AgentBootstrapStatus | null = conversation.status === "idle" || conversation.status === "preparing" const bootstrapStatus: AgentBootstrapStatus | null = conversation.status === "idle" || conversation.status === "preparing"
? services.length ? services.length
? { key: "mcp:starting", text: "正在启动 MCP 服务", detail: pending ? `还有 ${pending} 个工具服务正在初始化` : "正在确认工具服务状态", status: "running" } ? { key: "mcp:starting", text: rt("mcpServicesStarting"), detail: pending ? rt("toolServicesPending", { count: pending }) : rt("checkingToolServices"), status: "running" }
: { key: "codex:preparing", text: "正在初始化 Codex 对话", detail: "正在创建会话并启动画布工具服务", status: "running" } : { key: "codex:preparing", text: rt("conversationInitializing"), detail: rt("conversationCreating"), status: "running" }
: conversation.status === "warning" : conversation.status === "warning"
? { key: "mcp:warning", text: "部分 MCP 服务初始化失败", detail: "其余工具已就绪,可以开始对话", status: "error" } ? { key: "mcp:warning", text: rt("someMcpFailed"), detail: rt("remainingToolsReady"), status: "error" }
: conversation.status === "failed" : conversation.status === "failed"
? { key: "codex:prepare_failed", text: "Codex 对话初始化失败", detail: conversation.error || "无法创建 Codex 会话", status: "error" } ? { key: "codex:prepare_failed", text: rt("conversationInitFailed"), detail: conversation.error || rt("conversationCreateFailed"), status: "error" }
: conversation.status === "ready" : conversation.status === "ready"
? { key: "mcp:ready", text: `${services.length} 个 MCP 服务已完成初始化`, detail: "工具列表加载完成,可以开始对话", status: "ready" } ? { key: "mcp:ready", text: rt("mcpServicesReady", { count: services.length }), detail: rt("toolsReady"), status: "ready" }
: null; : null;
return { bootstrapStatus, mcpStartupStatuses }; return { bootstrapStatus, mcpStartupStatuses };
} }
export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?: boolean; headless?: boolean; autoConnect?: boolean }) { export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?: boolean; headless?: boolean; autoConnect?: boolean }) {
const { t } = useTranslation();
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { message, modal } = App.useApp(); const { message, modal } = App.useApp();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@@ -307,7 +310,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
liveTurnKeysRef.current.clear(); liveTurnKeysRef.current.clear();
} }
} catch (error) { } catch (error) {
addEventLog("读取历史失败", error); addEventLog(rt("historyReadFailed"), error);
} finally { } finally {
if (sequence === loadThreadsSequenceRef.current && !threadOperationRef.current) setAgentState({ loadingThreads: false }); if (sequence === loadThreadsSequenceRef.current && !threadOperationRef.current) setAgentState({ loadingThreads: false });
} }
@@ -348,7 +351,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
eventQueue = eventQueue.then(async () => { eventQueue = eventQueue.then(async () => {
if (isCurrentConnection()) await task(); if (isCurrentConnection()) await task();
}).catch((error) => { }).catch((error) => {
if (isCurrentConnection()) addEventLog("同步会话失败", error); if (isCurrentConnection()) addEventLog(rt("conversationSyncFailed"), error);
}); });
}; };
const source = new EventSource(`${endpoint}/events?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`); const source = new EventSource(`${endpoint}/events?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`);
@@ -356,13 +359,13 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
if (!isCurrentConnection()) return; if (!isCurrentConnection()) return;
const hello = parseEventData<AgentHelloEvent>(event); const hello = parseEventData<AgentHelloEvent>(event);
if (hello?.protocolVersion !== AGENT_PROTOCOL_VERSION) { if (hello?.protocolVersion !== AGENT_PROTOCOL_VERSION) {
const text = "本地 Agent 版本过旧,请重启 Canvas Agent 后重新连接"; const text = rt("agentOutdated");
protocolRejected = true; protocolRejected = true;
source.close(); source.close();
connectedRef.current = false; connectedRef.current = false;
setAgentState({ enabled: false, connected: false, waiting: false, sending: false, activity: "需要重启 Agent", connectError: text, silentConnect: false, pendingTool: null, pendingApprovals: [] }); setAgentState({ enabled: false, connected: false, waiting: false, sending: false, activity: rt("restartRequired"), connectError: text, silentConnect: false, pendingTool: null, pendingApprovals: [] });
useAgentSkillStore.getState().reset(); useAgentSkillStore.getState().reset();
addEventLog("Agent 版本不匹配", text, hello); addEventLog(rt("versionMismatch"), text, hello);
if (!headless) message.error(text); if (!headless) message.error(text);
return; return;
} }
@@ -383,7 +386,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
connectedRef.current = true; connectedRef.current = true;
setAgentState({ setAgentState({
connected: true, connected: true,
activity: pendingApprovals.length ? "等待权限确认" : busy ? "Codex 正在运行" : "已连接", activity: pendingApprovals.length ? rt("awaitingApproval") : busy ? rt("codexRunning") : rt("connected"),
waiting: busy, waiting: busy,
sending: false, sending: false,
connectError: "", connectError: "",
@@ -393,7 +396,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
messages, messages,
pendingApprovals, pendingApprovals,
}); });
if (!headless) message.success("本地 Agent 已连接"); if (!headless) message.success(rt("localAgentConnected"));
void postState(endpoint, token, clientId, canvasContextRef.current?.snapshot || null); void postState(endpoint, token, clientId, canvasContextRef.current?.snapshot || null);
if (document.visibilityState === "visible" && document.hasFocus()) void activateAgentClient(endpoint, token, clientId); if (document.visibilityState === "visible" && document.hasFocus()) void activateAgentClient(endpoint, token, clientId);
if (!busy && !nextThreadId && (!hello?.conversation || hello.conversation.status === "idle")) { if (!busy && !nextThreadId && (!hello?.conversation || hello.conversation.status === "idle")) {
@@ -402,7 +405,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
.catch((error) => { .catch((error) => {
const state = agentErrorState(error); const state = agentErrorState(error);
if (state) applyConversationState(state); if (state) applyConversationState(state);
addEventLog("Codex 对话初始化失败", error); addEventLog(rt("conversationInitFailed"), error);
}); });
} }
}); });
@@ -419,7 +422,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const activeTurnId = busy ? turnId : ""; const activeTurnId = busy ? turnId : "";
const messages = activeTurnId ? bindPendingTurnMessages(current.messages, current.activeThreadId, activeTurnId) : current.messages; const messages = activeTurnId ? bindPendingTurnMessages(current.messages, current.activeThreadId, activeTurnId) : current.messages;
setAgentState({ setAgentState({
activity: busy ? "Codex 正在运行" : current.activity === "处理失败" ? "处理失败" : "完成", activity: busy ? rt("codexRunning") : current.activity === rt("processingFailed") ? rt("processingFailed") : rt("completed"),
waiting: busy, waiting: busy,
sending: false, sending: false,
activeTurnId, activeTurnId,
@@ -437,8 +440,8 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
if (!isCurrentConnection()) return; if (!isCurrentConnection()) return;
const data = parseEventData<AgentPendingApproval>(event); const data = parseEventData<AgentPendingApproval>(event);
if (!data || !isCurrentThreadEvent(data)) return; if (!data || !isCurrentThreadEvent(data)) return;
setAgentState({ pendingApprovals: [...useAgentStore.getState().pendingApprovals.filter((item) => item.requestId !== data.requestId), data], activity: "等待权限确认" }); setAgentState({ pendingApprovals: [...useAgentStore.getState().pendingApprovals.filter((item) => item.requestId !== data.requestId), data], activity: rt("awaitingApproval") });
addEventLog("等待权限确认", data.reason || data.method, data); addEventLog(rt("awaitingApproval"), data.reason || data.method, data);
}); });
source.addEventListener("codex_approval_resolved", (event) => { source.addEventListener("codex_approval_resolved", (event) => {
if (!isCurrentConnection()) return; if (!isCurrentConnection()) return;
@@ -449,7 +452,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const pendingApprovals = current.pendingApprovals.filter((item) => item.requestId !== data.requestId); const pendingApprovals = current.pendingApprovals.filter((item) => item.requestId !== data.requestId);
setAgentState({ pendingApprovals, activity: approvalActivity(pendingApprovals, current.waiting, current.activity) }); setAgentState({ pendingApprovals, activity: approvalActivity(pendingApprovals, current.waiting, current.activity) });
const decision = data.decision || approval?.deciding; const decision = data.decision || approval?.deciding;
if (approval && decision) addEventLog(decision === "accept" || decision === "acceptForSession" ? "已批准权限" : "已取消权限", approval.reason || approval.method, approval); if (approval && decision) addEventLog(rt(decision === "accept" || decision === "acceptForSession" ? "approvalGranted" : "approvalCanceled"), approval.reason || approval.method, approval);
}); });
source.addEventListener("agent_event", (event) => { source.addEventListener("agent_event", (event) => {
const data = parseEventData<AgentEventPayload>(event); const data = parseEventData<AgentEventPayload>(event);
@@ -464,26 +467,26 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const data = parseEventData<AgentBootstrapEvent>(event); const data = parseEventData<AgentBootstrapEvent>(event);
if (!data?.type) return; if (!data?.type) return;
if (data.type === "codex.preparing") { if (data.type === "codex.preparing") {
addEventLog("正在初始化 Codex 对话", "正在创建会话并启动画布工具服务", data); addEventLog(rt("conversationInitializing"), rt("conversationCreating"), data);
return; return;
} }
if (data.type === "codex.prepare_failed") { if (data.type === "codex.prepare_failed") {
addEventLog("Codex 对话初始化失败", data.error, data); addEventLog(rt("conversationInitFailed"), data.error, data);
return; return;
} }
if (data.type === "mcp.complete") { if (data.type === "mcp.complete") {
addEventLog("MCP 状态确认完成", "已读取 Codex 返回的完整工具服务清单", data); addEventLog(rt("mcpStatusComplete"), rt("mcpListRead"), data);
return; return;
} }
if (!data.name || !data.status) return; if (!data.name || !data.status) return;
const label = data.name; const label = data.name;
const status = data.status === "starting" const status = data.status === "starting"
? { text: `正在启动 MCP${label}`, detail: "正在建立工具连接并读取可用工具列表", status: "running" as const } ? { text: rt("mcpStarting", { name: label }), detail: rt("mcpConnecting"), status: "running" as const }
: data.status === "ready" : data.status === "ready"
? { text: `MCP 已就绪:${label}`, detail: "工具列表加载完成,可以开始对话", status: "ready" as const } ? { text: rt("mcpReadyNamed", { name: label }), detail: rt("toolsReady"), status: "ready" as const }
: data.status === "failed" : data.status === "failed"
? { text: `MCP 启动失败:${label}`, detail: data.error || "工具服务未能完成初始化", status: "error" as const } ? { text: rt("mcpFailedNamed", { name: label }), detail: data.error || rt("toolInitFailed"), status: "error" as const }
: { text: `MCP 启动已取消:${label}`, detail: "工具服务初始化已取消", status: "error" as const }; : { text: rt("mcpCanceledNamed", { name: label }), detail: rt("toolInitCanceled"), status: "error" as const };
addEventLog(status.text, status.detail, data); addEventLog(status.text, status.detail, data);
}); });
source.addEventListener("conversation_changed", (event) => { source.addEventListener("conversation_changed", (event) => {
@@ -542,7 +545,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
source.addEventListener("agent_log", (event) => { source.addEventListener("agent_log", (event) => {
if (!isCurrentConnection()) return; if (!isCurrentConnection()) return;
const text = parseEventData<{ text?: unknown }>(event)?.text; const text = parseEventData<{ text?: unknown }>(event)?.text;
addEventLog("日志", text, text); addEventLog(rt("log"), text, text);
}); });
source.addEventListener("skills_changed", (event) => { source.addEventListener("skills_changed", (event) => {
if (!isCurrentConnection()) return; if (!isCurrentConnection()) return;
@@ -562,16 +565,16 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
if (disposed || protocolRejected) return; if (disposed || protocolRejected) return;
const wasConnected = connectedRef.current; const wasConnected = connectedRef.current;
const silent = useAgentStore.getState().silentConnect && !wasConnected; const silent = useAgentStore.getState().silentConnect && !wasConnected;
const text = wasConnected ? "本地 Agent 连接失败或已断开" : "连接失败,请检查地址和 token"; const text = rt(wasConnected ? "connectionLostDescription" : "connectionFailedDescription");
if (!errorLoggedRef.current || wasConnected) { if (!errorLoggedRef.current || wasConnected) {
addEventLog(wasConnected ? "连接断开" : "连接失败", text); addEventLog(rt(wasConnected ? "connectionLost" : "connectionFailed"), text);
if (!headless && !silent) message.error(text); if (!headless && !silent) message.error(text);
} }
errorLoggedRef.current = true; errorLoggedRef.current = true;
connectedRef.current = false; connectedRef.current = false;
pendingToolRef.current = null; pendingToolRef.current = null;
setAgentState({ setAgentState({
activity: wasConnected ? "连接断开" : "连接失败", activity: rt(wasConnected ? "connectionLost" : "connectionFailed"),
connected: false, connected: false,
waiting: false, waiting: false,
sending: false, sending: false,
@@ -624,7 +627,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
localStorage.setItem("canvas-agent-model", current.model); localStorage.setItem("canvas-agent-model", current.model);
localStorage.setItem("canvas-agent-reasoning-effort", nextEffort); localStorage.setItem("canvas-agent-reasoning-effort", nextEffort);
setAgentState({ models, model: current.model, reasoningEffort: nextEffort }); setAgentState({ models, model: current.model, reasoningEffort: nextEffort });
}).catch((error) => addEventLog("读取模型列表失败", error)); }).catch((error) => addEventLog(rt("modelListFailed"), error));
}, [connected, endpoint, setAgentState, token]); }, [connected, endpoint, setAgentState, token]);
useEffect(() => { useEffect(() => {
@@ -650,22 +653,22 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const currentState = useAgentStore.getState(); const currentState = useAgentStore.getState();
if (!currentState.connected || !requestPrompt || currentState.sending || currentState.waiting || currentState.loadingThreads || !["ready", "warning"].includes(currentState.conversation.status)) return; if (!currentState.connected || !requestPrompt || currentState.sending || currentState.waiting || currentState.loadingThreads || !["ready", "warning"].includes(currentState.conversation.status)) return;
if (attachmentPayloadBytes(files) > MAX_ATTACHMENT_PAYLOAD_BYTES) { if (attachmentPayloadBytes(files) > MAX_ATTACHMENT_PAYLOAD_BYTES) {
addMessage({ role: "error", title: "图片过大", text: "图片附件超过 30MB,请删减后再发送。" }); addMessage({ role: "error", title: rt("imageTooLarge"), text: rt("imagePayloadTooLarge") });
return; return;
} }
const messageId = createId(); const messageId = createId();
const userText = text || `发送了 ${files.length} 张图片`; const userText = text || rt("imagesSent", { count: files.length });
loadThreadsSequenceRef.current += 1; loadThreadsSequenceRef.current += 1;
const currentBeforeSend = useAgentStore.getState(); const currentBeforeSend = useAgentStore.getState();
const requestThreadId = currentBeforeSend.activeThreadId; const requestThreadId = currentBeforeSend.activeThreadId;
setAgentState({ prompt: "", attachments: [], activity: "发送中", sending: true, loadingThreads: false, activeTurnId: "", messages: currentBeforeSend.messages }); setAgentState({ prompt: "", attachments: [], activity: rt("sending"), sending: true, loadingThreads: false, activeTurnId: "", messages: currentBeforeSend.messages });
addMessage({ id: messageId, itemId: "synthetic:user", clientMessageId: messageId, threadId: requestThreadId, turnId: "", role: "user", text: userText, historyText: requestPrompt, attachments: files }); addMessage({ id: messageId, itemId: "synthetic:user", clientMessageId: messageId, threadId: requestThreadId, turnId: "", role: "user", text: userText, historyText: requestPrompt, attachments: files });
let threadId = requestThreadId; let threadId = requestThreadId;
try { try {
if (files.length) await savePendingAgentUserMessage({ id: messageId, role: "user", text: userText, historyText: requestPrompt, attachments: files }); if (files.length) await savePendingAgentUserMessage({ id: messageId, role: "user", text: userText, historyText: requestPrompt, attachments: files });
const modelName = models.find((item) => item.model === model)?.displayName || model || "默认模型"; const modelName = models.find((item) => item.model === model)?.displayName || model || rt("defaultModel");
const effortName = reasoningEffort ? AGENT_REASONING_LABELS[reasoningEffort] : "默认强度"; const effortName = reasoningEffort ? i18n.t(`agent.composer.effort.${reasoningEffort}`) : rt("defaultEffort");
addEventLog("发送任务", `${modelName} · ${effortName}${selectedSkill ? ` · Skill ${selectedSkill.name}` : ""}${files.length ? ` · 附件 ${files.length}` : ""} · ${compactText(text) || "仅附件"}`); addEventLog(rt("sendTask"), `${modelName} · ${effortName}${selectedSkill ? ` · Skill ${selectedSkill.name}` : ""}${files.length ? ` · ${rt("attachmentCount", { count: files.length })}` : ""} · ${compactText(text) || rt("attachmentsOnly")}`);
const accepted = await fetchAgentJson<AgentTurnResponse>(endpoint, token, "/agent/codex/turn", { const accepted = await fetchAgentJson<AgentTurnResponse>(endpoint, token, "/agent/codex/turn", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
@@ -685,12 +688,12 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
}), }),
}); });
threadId = accepted.threadId || threadId; threadId = accepted.threadId || threadId;
if (!threadId) throw new Error("启动对话失败"); if (!threadId) throw new Error(rt("startConversationFailed"));
if (selectedSkill) clearSkillSelection(selectedSkillRevision); if (selectedSkill) clearSkillSelection(selectedSkillRevision);
if (files.length) { if (files.length) {
const latestMessage = useAgentStore.getState().messages.find((item) => item.clientMessageId === messageId); const latestMessage = useAgentStore.getState().messages.find((item) => item.clientMessageId === messageId);
const acceptedThreadId = latestMessage?.threadId || threadId; const acceptedThreadId = latestMessage?.threadId || threadId;
await bindPendingAgentUserMessage(acceptedThreadId, messageId, latestMessage?.turnId || "").catch((error) => addEventLog("保存附件历史失败", error)); await bindPendingAgentUserMessage(acceptedThreadId, messageId, latestMessage?.turnId || "").catch((error) => addEventLog(rt("attachmentHistoryFailed"), error));
} }
files.forEach((item) => { files.forEach((item) => {
URL.revokeObjectURL(item.url); URL.revokeObjectURL(item.url);
@@ -698,7 +701,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
}); });
} catch (error) { } catch (error) {
if (files.length) await deletePendingAgentUserMessage(messageId).catch(() => undefined); if (files.length) await deletePendingAgentUserMessage(messageId).catch(() => undefined);
const text = error instanceof Error ? error.message : "发送失败"; const text = error instanceof Error ? error.message : rt("sendFailed");
const response = error instanceof AgentApiError ? error.response as { code?: string; state?: AgentConversationState } : undefined; const response = error instanceof AgentApiError ? error.response as { code?: string; state?: AgentConversationState } : undefined;
if (response?.state) applyConversationState(response.state); if (response?.state) applyConversationState(response.state);
const stale = response?.code === "CONVERSATION_STALE"; const stale = response?.code === "CONVERSATION_STALE";
@@ -713,28 +716,28 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const restoreDraft = state.prompt || state.attachments.length ? {} : { prompt, attachments: files }; const restoreDraft = state.prompt || state.attachments.length ? {} : { prompt, attachments: files };
if (ownsCurrentThread) { if (ownsCurrentThread) {
setAgentState({ setAgentState({
activity: stale ? "会话已同步" : busy ? "Codex 正在运行" : "发送失败", activity: rt(stale ? "conversationSynced" : busy ? "codexRunning" : "sendFailed"),
sending: false, sending: false,
messages: removeFailedPending(state.messages), messages: removeFailedPending(state.messages),
...restoreDraft, ...restoreDraft,
}); });
addMessage({ threadId: state.activeThreadId, turnId: "", role: "error", title: stale ? "会话已同步" : busy ? "任务仍在运行" : "发送失败", text }); addMessage({ threadId: state.activeThreadId, turnId: "", role: "error", title: rt(stale ? "conversationSynced" : busy ? "taskStillRunning" : "sendFailed"), text });
} else { } else {
setAgentState({ sending: false, messages: removeFailedPending(state.messages), ...restoreDraft }); setAgentState({ sending: false, messages: removeFailedPending(state.messages), ...restoreDraft });
} }
addEventLog("发送失败", error); addEventLog(rt("sendFailed"), error);
} }
}; };
const stopTurn = async () => { const stopTurn = async () => {
if (!connected || (!sending && !waiting)) return; if (!connected || (!sending && !waiting)) return;
setAgentState({ activity: "停止中" }); setAgentState({ activity: rt("stopping") });
try { try {
await interruptCodexTurn(endpoint, token, useAgentStore.getState().activeThreadId || undefined); await interruptCodexTurn(endpoint, token, useAgentStore.getState().activeThreadId || undefined);
addEventLog("停止任务", "任务已停止"); addEventLog(rt("stopTask"), rt("taskStopped"));
} catch (error) { } catch (error) {
setAgentState({ activity: "停止失败" }); setAgentState({ activity: rt("stopFailed") });
addEventLog("停止失败", error); addEventLog(rt("stopFailed"), error);
} }
}; };
@@ -758,12 +761,12 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
URL.revokeObjectURL(item.url); URL.revokeObjectURL(item.url);
attachmentUrlsRef.current.delete(item.url); attachmentUrlsRef.current.delete(item.url);
}); });
addMessage({ role: "error", title: "图片过大", text: "图片附件最多约 30MB。" }); addMessage({ role: "error", title: rt("imageTooLarge"), text: rt("imageLimit") });
return; return;
} }
if (next.length) setAgentState({ attachments: merged }); if (next.length) setAgentState({ attachments: merged });
} catch (error) { } catch (error) {
addMessage({ role: "error", title: "图片读取失败", text: error instanceof Error ? error.message : "图片读取失败" }); addMessage({ role: "error", title: rt("imageReadFailed"), text: error instanceof Error ? error.message : rt("imageReadFailed") });
} }
}; };
@@ -779,12 +782,12 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const handleToolCall = async (endpoint: string, token: string, payload: AgentPendingToolCall) => { const handleToolCall = async (endpoint: string, token: string, payload: AgentPendingToolCall) => {
if (confirmToolsRef.current && isCanvasWriteTool(payload.name)) { if (confirmToolsRef.current && isCanvasWriteTool(payload.name)) {
if (pendingToolRef.current) { if (pendingToolRef.current) {
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: "仍有待确认的画布工具调用" }); await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: rt("pendingCanvasTool") });
return; return;
} }
pendingToolRef.current = payload; pendingToolRef.current = payload;
setAgentState({ pendingTool: payload }); setAgentState({ pendingTool: payload });
addEventLog("等待确认", payload, payload); addEventLog(rt("awaitingConfirmation"), payload, payload);
return; return;
} }
await runToolCall(endpoint, token, payload); await runToolCall(endpoint, token, payload);
@@ -796,9 +799,9 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
addEventLog(toolName(payload.name), payload, payload); addEventLog(toolName(payload.name), payload, payload);
const result = await runSiteTool(payload.name, payload.input || {}, navigate, { canvasSnapshot: canvasContextRef.current?.snapshot || null }); const result = await runSiteTool(payload.name, payload.input || {}, navigate, { canvasSnapshot: canvasContextRef.current?.snapshot || null });
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result }); await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result });
addEventLog(`${toolName(payload.name)}完成`, result, result); addEventLog(rt("toolCompleted", { tool: toolName(payload.name) }), result, result);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "工具执行失败"; const message = error instanceof Error ? error.message : rt("toolExecutionFailed");
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message }); await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message });
} }
return; return;
@@ -814,31 +817,31 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
result = { ok: true, path }; result = { ok: true, path };
} else if (payload.name === "canvas_apply_ops") { } else if (payload.name === "canvas_apply_ops") {
const context = canvasContextRef.current; const context = canvasContextRef.current;
if (!context) throw new Error("当前不在画布页,请先用 site_navigate 打开画布"); if (!context) throw new Error(rt("openCanvasFirst"));
result = context.applyOps(appliedOps); result = context.applyOps(appliedOps);
void postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot); void postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot);
} else if (payload.name === "canvas_create_attachment_nodes") { } else if (payload.name === "canvas_create_attachment_nodes") {
const context = canvasContextRef.current; const context = canvasContextRef.current;
if (!context) throw new Error("当前不在画布页,请先用 site_navigate 打开画布"); if (!context) throw new Error(rt("openCanvasFirst"));
appliedOps = await attachmentNodeOps(endpoint, token, clientIdRef.current, payload.input?.nodes); appliedOps = await attachmentNodeOps(endpoint, token, clientIdRef.current, payload.input?.nodes);
result = context.applyOps(appliedOps); result = context.applyOps(appliedOps);
await postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot); await postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot);
} else { } else {
const snapshot = canvasContextRef.current?.snapshot; const snapshot = canvasContextRef.current?.snapshot;
if (!snapshot) throw new Error("当前不在画布页,请先用 site_navigate 打开画布"); if (!snapshot) throw new Error(rt("openCanvasFirst"));
result = snapshot; result = snapshot;
} }
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result }); await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result });
addEventLog(`${toolName(payload.name)}完成`, result, result); addEventLog(rt("toolCompleted", { tool: toolName(payload.name) }), result, result);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "画布操作失败"; const message = error instanceof Error ? error.message : rt("canvasOperationFailed");
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message }); await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message });
} }
}; };
const rejectPendingTool = async () => { const rejectPendingTool = async () => {
if (!pendingTool) return; if (!pendingTool) return;
await postToolResult(endpoint, token, clientIdRef.current, { requestId: pendingTool.requestId, error: "用户取消了画布工具调用" }); await postToolResult(endpoint, token, clientIdRef.current, { requestId: pendingTool.requestId, error: rt("canvasToolCanceled") });
pendingToolRef.current = null; pendingToolRef.current = null;
setAgentState({ pendingTool: null }); setAgentState({ pendingTool: null });
}; };
@@ -855,11 +858,11 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const current = useAgentStore.getState(); const current = useAgentStore.getState();
const pending = current.pendingApprovals.find((item) => item.requestId === approval.requestId); const pending = current.pendingApprovals.find((item) => item.requestId === approval.requestId);
if (!pending || pending.deciding) return; if (!pending || pending.deciding) return;
setAgentState({ pendingApprovals: current.pendingApprovals.map((item) => item.requestId === approval.requestId ? { ...item, deciding: decision } : item), activity: "正在提交权限决定" }); setAgentState({ pendingApprovals: current.pendingApprovals.map((item) => item.requestId === approval.requestId ? { ...item, deciding: decision } : item), activity: rt("submittingApproval") });
try { try {
await postCodexApproval(endpoint, token, approval.requestId, decision); await postCodexApproval(endpoint, token, approval.requestId, decision);
const latest = useAgentStore.getState(); const latest = useAgentStore.getState();
if (latest.pendingApprovals.some((item) => item.requestId === approval.requestId)) setAgentState({ activity: "等待 Codex 确认权限" }); if (latest.pendingApprovals.some((item) => item.requestId === approval.requestId)) setAgentState({ activity: rt("waitingCodexApproval") });
} catch (error) { } catch (error) {
const latest = useAgentStore.getState(); const latest = useAgentStore.getState();
const expired = error instanceof Error && error.message.includes("审批请求已失效"); const expired = error instanceof Error && error.message.includes("审批请求已失效");
@@ -869,8 +872,8 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
: latest.pendingApprovals.map((item) => item.requestId === approval.requestId ? { ...item, deciding: undefined } : item); : latest.pendingApprovals.map((item) => item.requestId === approval.requestId ? { ...item, deciding: undefined } : item);
setAgentState({ pendingApprovals, activity: approvalActivity(pendingApprovals, latest.waiting, latest.activity) }); setAgentState({ pendingApprovals, activity: approvalActivity(pendingApprovals, latest.waiting, latest.activity) });
if (resolved) return; if (resolved) return;
addEventLog("权限审批失败", error); addEventLog(rt("approvalFailed"), error);
message.error(error instanceof Error ? error.message : "权限审批失败"); message.error(error instanceof Error ? error.message : rt("approvalFailed"));
} }
}; };
@@ -881,18 +884,18 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
}; };
if (nextMode !== "full") return apply(); if (nextMode !== "full") return apply();
modal.confirm({ modal.confirm({
title: "启用完全访问权限", title: rt("enableFullAccess"),
content: "Codex 将不受沙箱限制,可访问互联网及本机任意文件。请仅在信任当前任务时使用。", content: rt("fullAccessDescription"),
okText: "启用完全访问", okText: rt("enableFullAccessAction"),
okType: "danger", okType: "danger",
cancelText: "取消", cancelText: t("common.cancel"),
onOk: apply, onOk: apply,
}); });
}; };
const toggleAgentConnection = async ({ silent = false }: { silent?: boolean } = {}) => { const toggleAgentConnection = async ({ silent = false }: { silent?: boolean } = {}) => {
if (enabled) { if (enabled) {
clearAgentSession({ enabled: false, connected: false, activity: "离线", connectError: "" }); clearAgentSession({ enabled: false, connected: false, activity: rt("offline"), connectError: "" });
return; return;
} }
const urlToken = searchParams.get("agentToken") || ""; const urlToken = searchParams.get("agentToken") || "";
@@ -901,7 +904,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const nextEndpoint = (urlEndpoint || discovered?.url || endpoint || DEFAULT_AGENT_URL).trim().replace(/\/$/, ""); const nextEndpoint = (urlEndpoint || discovered?.url || endpoint || DEFAULT_AGENT_URL).trim().replace(/\/$/, "");
const nextToken = (urlToken || token.trim() || discovered?.token || "").trim(); const nextToken = (urlToken || token.trim() || discovered?.token || "").trim();
if (!nextEndpoint) { if (!nextEndpoint) {
const text = "请填写本地 Agent 地址"; const text = rt("addressRequired");
if (!silent) { if (!silent) {
setAgentState({ connectError: text }); setAgentState({ connectError: text });
if (!headless) message.warning(text); if (!headless) message.warning(text);
@@ -909,7 +912,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
return; return;
} }
if (!nextToken) { if (!nextToken) {
const text = "没有发现本地 Agent,请先在 Codex 使用插件或手动启动 Canvas Agent"; const text = rt("agentNotFound");
if (!silent) { if (!silent) {
setAgentState({ connectError: text }); setAgentState({ connectError: text });
if (!headless) message.warning(text); if (!headless) message.warning(text);
@@ -920,7 +923,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const parsed = new URL(nextEndpoint); const parsed = new URL(nextEndpoint);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("invalid protocol"); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("invalid protocol");
} catch { } catch {
const text = "本地 Agent 地址格式不正确"; const text = rt("invalidAddress");
if (!silent) { if (!silent) {
setAgentState({ connectError: text }); setAgentState({ connectError: text });
if (!headless) message.warning(text); if (!headless) message.warning(text);
@@ -928,7 +931,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
return; return;
} }
errorLoggedRef.current = false; errorLoggedRef.current = false;
setAgentState({ url: nextEndpoint, token: nextToken, enabled: true, connected: false, silentConnect: silent, activity: "连接中", connectError: "", activeTab: "setup" }); setAgentState({ url: nextEndpoint, token: nextToken, enabled: true, connected: false, silentConnect: silent, activity: rt("connecting"), connectError: "", activeTab: "setup" });
}; };
useEffect(() => { useEffect(() => {
@@ -986,17 +989,17 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
if (!current.connected || current.sending || current.waiting || current.loadingThreads || ["preparing", "running"].includes(current.conversation.status)) return; if (!current.connected || current.sending || current.waiting || current.loadingThreads || ["preparing", "running"].includes(current.conversation.status)) return;
const operation = beginThreadOperation(); const operation = beginThreadOperation();
clearSkillSelection(); clearSkillSelection();
setAgentState({ activeTab: "chat", activity: "正在新建对话" }); setAgentState({ activeTab: "chat", activity: rt("creatingConversation") });
try { try {
const result = await fetchAgentJson<AgentWorkspaceResponse>(endpoint, token, "/agent/codex/threads/reset", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ clientId: clientIdRef.current, permissionMode }) }); const result = await fetchAgentJson<AgentWorkspaceResponse>(endpoint, token, "/agent/codex/threads/reset", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ clientId: clientIdRef.current, permissionMode }) });
if (threadOperationRef.current !== operation) return; if (threadOperationRef.current !== operation) return;
if (result.conversation) applyConversationState(result.conversation); if (result.conversation) applyConversationState(result.conversation);
setAgentState({ activeTab: "chat", activity: "新对话" }); setAgentState({ activeTab: "chat", activity: rt("newConversation") });
} catch (error) { } catch (error) {
const state = agentErrorState(error); const state = agentErrorState(error);
if (state) applyConversationState(state); if (state) applyConversationState(state);
addEventLog("新建对话失败", error); addEventLog(rt("newConversationFailed"), error);
message.error(error instanceof Error ? error.message : "新建对话失败"); message.error(error instanceof Error ? error.message : rt("newConversationFailed"));
await loadThreads(); await loadThreads();
} finally { } finally {
finishThreadOperation(operation); finishThreadOperation(operation);
@@ -1011,12 +1014,12 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const result = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ permissionMode, clientId: clientIdRef.current }) }); const result = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ permissionMode, clientId: clientIdRef.current }) });
if (result.conversation) applyConversationState(result.conversation); if (result.conversation) applyConversationState(result.conversation);
await loadThreads(); await loadThreads();
if (useAgentStore.getState().activeThreadId === threadId) setAgentState({ activeTab: "chat", activity: "已恢复会话" }); if (useAgentStore.getState().activeThreadId === threadId) setAgentState({ activeTab: "chat", activity: rt("conversationResumed") });
} catch (error) { } catch (error) {
const state = agentErrorState(error); const state = agentErrorState(error);
if (state) applyConversationState(state); if (state) applyConversationState(state);
addEventLog("恢复对话失败", error); addEventLog(rt("resumeConversationFailed"), error);
message.error(error instanceof Error ? error.message : "恢复对话失败"); message.error(error instanceof Error ? error.message : rt("resumeConversationFailed"));
await loadThreads(); await loadThreads();
} finally { } finally {
finishThreadOperation(operation); finishThreadOperation(operation);
@@ -1035,14 +1038,14 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
} }
void deleteAgentThreadMessages(deletedThreadIds).catch(() => undefined); void deleteAgentThreadMessages(deletedThreadIds).catch(() => undefined);
await loadThreads(); await loadThreads();
message.success(`已删除 ${deletedThreadIds.length} 条记录`); message.success(rt("recordsDeleted", { count: deletedThreadIds.length }));
} catch (error) { } catch (error) {
if (deletedThreadIds.length) { if (deletedThreadIds.length) {
void deleteAgentThreadMessages(deletedThreadIds).catch(() => undefined); void deleteAgentThreadMessages(deletedThreadIds).catch(() => undefined);
} }
await loadThreads(); await loadThreads();
addEventLog("删除对话失败", error); addEventLog(rt("deleteConversationFailed"), error);
message.error(error instanceof Error ? error.message : "删除对话失败"); message.error(error instanceof Error ? error.message : rt("deleteConversationFailed"));
} finally { } finally {
finishThreadOperation(operation); finishThreadOperation(operation);
} }
@@ -1050,11 +1053,11 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const confirmDeleteThreads = (threadIds: string[]) => { const confirmDeleteThreads = (threadIds: string[]) => {
modal.confirm({ modal.confirm({
title: `删除 ${threadIds.length} 条对话记录`, title: rt("deleteConversations", { count: threadIds.length }),
content: "删除后无法恢复,确定继续吗?", content: rt("deleteConversationsDescription"),
okText: "删除", okText: t("common.delete"),
okType: "danger", okType: "danger",
cancelText: "取消", cancelText: t("common.cancel"),
onOk: () => deleteThreads(threadIds), onOk: () => deleteThreads(threadIds),
}); });
}; };
@@ -1091,7 +1094,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const activityItems = { ...(current?.activityItems || {}) }; const activityItems = { ...(current?.activityItems || {}) };
const previous = activityItems[item.id] || ""; const previous = activityItems[item.id] || "";
activityItems[item.id] = isDelta ? `${previous === activityPlaceholder("reasoning") ? "" : previous}${text}` : text; activityItems[item.id] = isDelta ? `${previous === activityPlaceholder("reasoning") ? "" : previous}${text}` : text;
upsertActivityMessage({ ...scoped, title: "思考摘要", text: reasoningActivityText(activityItems), activityItems, detail: activityDetail(current?.detail || scoped.detail, "reasoning", "inProgress") }); upsertActivityMessage({ ...scoped, title: i18n.t("agent.events.reasoning"), text: reasoningActivityText(activityItems), activityItems, detail: activityDetail(current?.detail || scoped.detail, "reasoning", "inProgress") });
return; return;
} }
const scoped = scopeEventChatItem(event, activityDeltaFallback(item, text), item.id); const scoped = scopeEventChatItem(event, activityDeltaFallback(item, text), item.id);
@@ -1125,7 +1128,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const previous = activityItems[itemId] || ""; const previous = activityItems[itemId] || "";
const incoming = normalizeText(item.text); const incoming = normalizeText(item.text);
activityItems[itemId] = incoming === "已完成分析" && previous && previous !== activityPlaceholder("reasoning") ? previous : incoming; activityItems[itemId] = incoming === "已完成分析" && previous && previous !== activityPlaceholder("reasoning") ? previous : incoming;
upsertActivityMessage({ ...scoped, title: "思考摘要", text: reasoningActivityText(activityItems, incoming), activityItems }); upsertActivityMessage({ ...scoped, title: i18n.t("agent.events.reasoning"), text: reasoningActivityText(activityItems, incoming), activityItems });
return; return;
} }
upsertActivityMessage(scopeEventChatItem(event, { ...item, id: itemId }, itemId)); upsertActivityMessage(scopeEventChatItem(event, { ...item, id: itemId }, itemId));
@@ -1167,8 +1170,8 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const current = state.messages.find((message) => message.id === item.id); const current = state.messages.find((message) => message.id === item.id);
if (current && !normalizeText(value)) return; if (current && !normalizeText(value)) return;
upsertActivityMessage(item); upsertActivityMessage(item);
setAgentState({ activity: "处理失败", pendingApprovals: [] }); setAgentState({ activity: rt("processingFailed"), pendingApprovals: [] });
if (log) addEventLog("处理失败", error.text, value); if (log) addEventLog(rt("processingFailed"), error.text, value);
}; };
const handleAgentEvent = async (event: AgentEventPayload) => { const handleAgentEvent = async (event: AgentEventPayload) => {
@@ -1243,7 +1246,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const result = context.applyOps(ops); const result = context.applyOps(ops);
void postState(endpoint, token, clientIdRef.current, result); void postState(endpoint, token, clientIdRef.current, result);
} }
addEventLog("导入生成图片", context ? "已添加到发起任务的画布" : "图片已生成"); addEventLog(rt("importGeneratedImages"), rt(context ? "addedToSourceCanvas" : "imageGenerated"));
} }
} }
if (activity && event.item?.id) { if (activity && event.item?.id) {
@@ -1281,7 +1284,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
setAgentState({ messages: currentMessages.map((message, itemIndex) => itemIndex === index ? { ...message, text: isDelta ? `${message.text}${text}` : mergeStreamText(message.text, text) } : message) }); setAgentState({ messages: currentMessages.map((message, itemIndex) => itemIndex === index ? { ...message, text: isDelta ? `${message.text}${text}` : mergeStreamText(message.text, text) } : message) });
}; };
const connectionStatus = connectError ? "连接失败" : connected ? "已连接" : enabled ? "连接中" : "未连接"; const connectionStatus = t(connectError ? "agent.status.failed" : connected ? "agent.status.connected" : enabled ? "agent.status.connecting" : "agent.status.disconnected");
const connectionStatusColor = connectError ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme.node.muted; const connectionStatusColor = connectError ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme.node.muted;
const content = ( const content = (
<> <>
@@ -1294,18 +1297,18 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
<Bot className="size-4" /> <Bot className="size-4" />
</span> </span>
<div className="hidden text-base font-semibold leading-5 @min-[560px]:block">Agent</div> <div className="hidden text-base font-semibold leading-5 @min-[560px]:block">Agent</div>
<Tooltip title={`连接设置 · ${connectionStatus}`} placement="bottom"> <Tooltip title={t("agent.panel.connectionSettings", { status: connectionStatus })} placement="bottom">
<Button size="small" type="text" className="!h-8 !w-8 !min-w-8 !px-0 @min-[560px]:!w-auto @min-[560px]:!min-w-0 @min-[560px]:!px-[7px]" aria-label={`连接设置,当前${connectionStatus}`} icon={<PlugZap className="size-3.5" style={{ color: connectionStatusColor }} />} onClick={() => setAgentState({ activeTab: "setup" })}> <Button size="small" type="text" className="!h-8 !w-8 !min-w-8 !px-0 @min-[560px]:!w-auto @min-[560px]:!min-w-0 @min-[560px]:!px-[7px]" aria-label={t("agent.panel.connectionSettingsLabel", { status: connectionStatus })} icon={<PlugZap className="size-3.5" style={{ color: connectionStatusColor }} />} onClick={() => setAgentState({ activeTab: "setup" })}>
<span className="hidden @min-[560px]:inline">{connectionStatus}</span> <span className="hidden @min-[560px]:inline">{connectionStatus}</span>
</Button> </Button>
</Tooltip> </Tooltip>
</div> </div>
} }
items={[ items={[
{ value: "chat", label: "对话", icon: <MessageSquare className="size-3.5" /> }, { value: "chat", label: t("agent.panel.chat"), icon: <MessageSquare className="size-3.5" /> },
{ value: "history", label: "历史", icon: <History className="size-3.5" />, count: threads.length }, { value: "history", label: t("agent.panel.history"), icon: <History className="size-3.5" />, count: threads.length },
{ value: "skills", label: "技能", icon: <Sparkles className="size-3.5" />, count: skillCount }, { value: "skills", label: t("agent.panel.skills"), icon: <Sparkles className="size-3.5" />, count: skillCount },
{ value: "log", label: "日志", icon: <Terminal className="size-3.5" />, count: eventLogs.length }, { value: "log", label: t("agent.panel.logs"), icon: <Terminal className="size-3.5" />, count: eventLogs.length },
]} ]}
onChange={(activeTab) => { onChange={(activeTab) => {
setAgentState({ activeTab }); setAgentState({ activeTab });
@@ -1313,13 +1316,13 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
}} }}
right={ right={
<> <>
<Tooltip title="新对话" placement="bottom"> <Tooltip title={t("agent.history.newThread")} placement="bottom">
<Button size="small" type="text" className="!h-8 !w-8 !min-w-8 !px-0 @min-[560px]:!w-auto @min-[560px]:!min-w-0 @min-[560px]:!px-[7px]" aria-label="新对话" disabled={!connected || loadingThreads || sending || waiting || conversationBusy} icon={<Plus className="size-3.5" />} onClick={startNewThread}> <Button size="small" type="text" className="!h-8 !w-8 !min-w-8 !px-0 @min-[560px]:!w-auto @min-[560px]:!min-w-0 @min-[560px]:!px-[7px]" aria-label={t("agent.history.newThread")} disabled={!connected || loadingThreads || sending || waiting || conversationBusy} icon={<Plus className="size-3.5" />} onClick={startNewThread}>
<span className="hidden @min-[560px]:inline"></span> <span className="hidden @min-[560px]:inline">{t("agent.history.newThread")}</span>
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip title="收起对话"> <Tooltip title={t("agent.panel.collapse")}>
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" aria-label="收起 Agent 面板" style={{ color: theme.node.muted }} icon={<PanelRightClose className="size-4" />} onClick={closePanel} /> <Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" aria-label={t("agent.panel.collapseLabel")} style={{ color: theme.node.muted }} icon={<PanelRightClose className="size-4" />} onClick={closePanel} />
</Tooltip> </Tooltip>
</> </>
} }
@@ -1374,10 +1377,10 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
disabled={!connected || !conversationReady || loadingThreads} disabled={!connected || !conversationReady || loadingThreads}
sending={sending || waiting} sending={sending || waiting}
placeholder={conversation.status === "idle" || conversation.status === "preparing" placeholder={conversation.status === "idle" || conversation.status === "preparing"
? "MCP 初始化中,完成后即可发送" ? t("agent.panel.mcpInitializing")
: conversation.status === "failed" : conversation.status === "failed"
? "Codex 对话初始化失败,请新建或恢复对话" ? t("agent.panel.initFailed")
: "询问 Codex,或让它操作网站/画布"} : t("agent.panel.placeholder")}
theme={theme} theme={theme}
onPromptChange={(prompt) => setAgentState({ prompt })} onPromptChange={(prompt) => setAgentState({ prompt })}
onSubmit={sendPrompt} onSubmit={sendPrompt}
@@ -1480,23 +1483,23 @@ function scopeEventChatItem(event: AgentEventPayload, item: AgentChatItem, itemI
} }
function approvalActivity(pendingApprovals: AgentPendingApproval[], waiting: boolean, fallback: string) { function approvalActivity(pendingApprovals: AgentPendingApproval[], waiting: boolean, fallback: string) {
if (pendingApprovals.length) return "等待权限确认"; if (pendingApprovals.length) return rt("awaitingApproval");
return waiting ? "Codex 正在运行" : fallback; return waiting ? rt("codexRunning") : fallback;
} }
async function attachmentNodeOps(endpoint: string, token: string, clientId: string, value: unknown): Promise<CanvasAgentOp[]> { async function attachmentNodeOps(endpoint: string, token: string, clientId: string, value: unknown): Promise<CanvasAgentOp[]> {
const nodes = Array.isArray(value) ? value : []; const nodes = Array.isArray(value) ? value : [];
if (!nodes.length) throw new Error("没有可添加的图片附件"); if (!nodes.length) throw new Error(rt("noImageAttachments"));
return await Promise.all( return await Promise.all(
nodes.map(async (value) => { nodes.map(async (value) => {
const item = value as { id?: unknown; attachmentId?: unknown; title?: unknown; position?: unknown }; const item = value as { id?: unknown; attachmentId?: unknown; title?: unknown; position?: unknown };
const id = String(item.id || ""); const id = String(item.id || "");
const attachmentId = String(item.attachmentId || ""); const attachmentId = String(item.attachmentId || "");
if (!id || !attachmentId) throw new Error("图片附件节点参数无效"); if (!id || !attachmentId) throw new Error(rt("invalidAttachmentNode"));
const res = await fetch(`${endpoint}/agent/attachments/${encodeURIComponent(attachmentId)}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`); const res = await fetch(`${endpoint}/agent/attachments/${encodeURIComponent(attachmentId)}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`);
if (!res.ok) { if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null; const body = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(body?.error || "读取图片附件失败"); throw new Error(body?.error || rt("attachmentReadFailed"));
} }
const image = await uploadImage(await res.blob()); const image = await uploadImage(await res.blob());
const size = fitNodeSize(image.width, image.height); const size = fitNodeSize(image.width, image.height);
@@ -1505,7 +1508,7 @@ async function attachmentNodeOps(endpoint: string, token: string, clientId: stri
type: "add_node" as const, type: "add_node" as const,
id, id,
nodeType: "image" as const, nodeType: "image" as const,
title: String(item.title || "参考图"), title: String(item.title || rt("referenceImage")),
position: { x: Number(position.x) || 0, y: Number(position.y) || 0 }, position: { x: Number(position.x) || 0, y: Number(position.y) || 0 },
width: size.width, width: size.width,
height: size.height, height: size.height,
@@ -1530,11 +1533,11 @@ async function importGeneratedImages(endpoint: string, token: string, item: Agen
const response = source.startsWith("data:image/") const response = source.startsWith("data:image/")
? await fetch(source) ? await fetch(source)
: await fetch(`${endpoint}/agent/local-image?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: source }) }); : await fetch(`${endpoint}/agent/local-image?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: source }) });
if (!response.ok) throw new Error("读取 Codex 生成图片失败"); if (!response.ok) throw new Error(rt("generatedImageReadFailed"));
const blob = await response.blob(); const blob = await response.blob();
const upload = await uploadImage(blob); const upload = await uploadImage(blob);
const dataUrl = await readDataUrl(blob); const dataUrl = await readDataUrl(blob);
const name = source.startsWith("/") ? source.split("/").at(-1) || `生成图片 ${index + 1}` : `生成图片 ${index + 1}`; const name = source.startsWith("/") ? source.split("/").at(-1) || rt("generatedImageName", { index: index + 1 }) : rt("generatedImageName", { index: index + 1 });
return { upload, name, attachment: { id: createId(), name, type: blob.type || upload.mimeType, size: blob.size, width: upload.width, height: upload.height, url: upload.url, dataUrl } }; return { upload, name, attachment: { id: createId(), name, type: blob.type || upload.mimeType, size: blob.size, width: upload.width, height: upload.height, url: upload.url, dataUrl } };
}), }),
); );
@@ -1554,7 +1557,7 @@ function readDataUrl(file: Blob) {
return new Promise<string>((resolve, reject) => { return new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || "")); reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("读取图片失败")); reader.onerror = () => reject(reader.error || new Error(rt("imageReadFailed")));
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
} }
+8 -6
View File
@@ -1,4 +1,5 @@
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { ImageSettingsTheme } from "@/components/image-settings-panel"; import { ImageSettingsTheme } from "@/components/image-settings-panel";
import { audioFormatOptions, audioSpeedLabel, audioVoiceOptions, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation"; import { audioFormatOptions, audioSpeedLabel, audioVoiceOptions, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation";
@@ -18,6 +19,7 @@ type AudioSettingsPanelProps = {
}; };
export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[320px] space-y-4 rounded-2xl px-1 py-0.5" }: AudioSettingsPanelProps) { export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[320px] space-y-4 rounded-2xl px-1 py-0.5" }: AudioSettingsPanelProps) {
const { t } = useTranslation();
const voice = normalizeAudioVoiceValue(config.audioVoice); const voice = normalizeAudioVoiceValue(config.audioVoice);
const format = normalizeAudioFormatValue(config.audioFormat); const format = normalizeAudioFormatValue(config.audioFormat);
const speed = normalizeAudioSpeedValue(config.audioSpeed); const speed = normalizeAudioSpeedValue(config.audioSpeed);
@@ -25,8 +27,8 @@ export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle =
return ( return (
<ImageSettingsTheme theme={theme}> <ImageSettingsTheme theme={theme}>
<div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}> <div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
{showTitle ? <div className="text-lg font-semibold"></div> : null} {showTitle ? <div className="text-lg font-semibold">{t("settingsPanels.audio.title")}</div> : null}
<SettingGroup title="声音" color={theme.node.muted}> <SettingGroup title={t("settingsPanels.audio.voice")} color={theme.node.muted}>
<div className="grid grid-cols-3 gap-2.5"> <div className="grid grid-cols-3 gap-2.5">
{audioVoiceOptions.map((item) => ( {audioVoiceOptions.map((item) => (
<OptionPill key={item.value} selected={voice === item.value} theme={theme} onClick={() => onConfigChange("audioVoice", item.value)}> <OptionPill key={item.value} selected={voice === item.value} theme={theme} onClick={() => onConfigChange("audioVoice", item.value)}>
@@ -35,7 +37,7 @@ export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle =
))} ))}
</div> </div>
</SettingGroup> </SettingGroup>
<SettingGroup title="格式" color={theme.node.muted}> <SettingGroup title={t("settingsPanels.audio.format")} color={theme.node.muted}>
<div className="grid grid-cols-3 gap-2.5"> <div className="grid grid-cols-3 gap-2.5">
{audioFormatOptions.map((item) => ( {audioFormatOptions.map((item) => (
<OptionPill key={item.value} selected={format === item.value} theme={theme} onClick={() => onConfigChange("audioFormat", item.value)}> <OptionPill key={item.value} selected={format === item.value} theme={theme} onClick={() => onConfigChange("audioFormat", item.value)}>
@@ -44,7 +46,7 @@ export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle =
))} ))}
</div> </div>
</SettingGroup> </SettingGroup>
<SettingGroup title="语速" color={theme.node.muted}> <SettingGroup title={t("settingsPanels.audio.speed")} color={theme.node.muted}>
<div className="grid grid-cols-4 gap-2.5"> <div className="grid grid-cols-4 gap-2.5">
{speedOptions.map((value) => ( {speedOptions.map((value) => (
<OptionPill key={value} selected={speed === value} theme={theme} onClick={() => onConfigChange("audioSpeed", value)}> <OptionPill key={value} selected={speed === value} theme={theme} onClick={() => onConfigChange("audioSpeed", value)}>
@@ -65,10 +67,10 @@ export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle =
onMouseDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}
/> />
</SettingGroup> </SettingGroup>
<SettingGroup title="声音指令" color={theme.node.muted}> <SettingGroup title={t("settingsPanels.audio.instructions")} color={theme.node.muted}>
<textarea <textarea
value={config.audioInstructions || ""} value={config.audioInstructions || ""}
placeholder="例如:自然、温暖、适合旁白。" placeholder={t("settingsPanels.audio.instructionsPlaceholder")}
className="thin-scrollbar h-20 w-full resize-none rounded-xl border bg-transparent px-3 py-2 text-sm leading-5 outline-none" className="thin-scrollbar h-20 w-full resize-none rounded-xl border bg-transparent px-3 py-2 text-sm leading-5 outline-none"
style={{ borderColor: theme.node.stroke, color: theme.node.text }} style={{ borderColor: theme.node.stroke, color: theme.node.text }}
onChange={(event) => onConfigChange("audioInstructions", event.target.value)} onChange={(event) => onConfigChange("audioInstructions", event.target.value)}
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Empty, Input, Modal, Pagination, Tag } from "antd"; import { Empty, Input, Modal, Pagination, Tag } from "antd";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAssetStore, type Asset } from "@/stores/use-asset-store"; import { useAssetStore, type Asset } from "@/stores/use-asset-store";
@@ -15,8 +16,9 @@ type Props = {
}; };
export function AssetPickerModal({ open, onInsert, onClose }: Props) { export function AssetPickerModal({ open, onInsert, onClose }: Props) {
const { t } = useTranslation();
return ( return (
<Modal title="选择资产" open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}> <Modal title={t("canvas.assetPicker.title")} open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
<MyAssetsTab onInsert={onInsert} /> <MyAssetsTab onInsert={onInsert} />
</Modal> </Modal>
); );
@@ -24,14 +26,10 @@ export function AssetPickerModal({ open, onInsert, onClose }: Props) {
const PAGE_SIZE = 8; const PAGE_SIZE = 8;
const kindOptions = [ const kindOptions = ["all", "text", "image", "video"];
{ label: "全部", value: "all" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
{ label: "视频", value: "video" },
];
function PickerCard({ title, kind, cover, onClick }: { title: string; kind: string; cover: string; onClick: () => void }) { function PickerCard({ title, kind, cover, onClick }: { title: string; kind: string; cover: string; onClick: () => void }) {
const { t } = useTranslation();
return ( return (
<button <button
type="button" type="button"
@@ -46,15 +44,16 @@ function PickerCard({ title, kind, cover, onClick }: { title: string; kind: stri
<div className="p-2.5"> <div className="p-2.5">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="line-clamp-1 text-xs font-medium text-stone-800 dark:text-stone-200">{title}</span> <span className="line-clamp-1 text-xs font-medium text-stone-800 dark:text-stone-200">{title}</span>
<Tag className="m-0 shrink-0 text-[10px]">{kind === "image" ? "图片" : kind === "video" ? "视频" : "文本"}</Tag> <Tag className="m-0 shrink-0 text-[10px]">{t(`assets.kinds.${kind}`)}</Tag>
</div> </div>
</div> </div>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-sm font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100"></div> <div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-sm font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100">{t("canvas.assetPicker.insert")}</div>
</button> </button>
); );
} }
function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) { function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
const { t } = useTranslation();
const assets = useAssetStore((state) => state.assets); const assets = useAssetStore((state) => state.assets);
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [kindFilter, setKindFilter] = useState("all"); const [kindFilter, setKindFilter] = useState("all");
@@ -90,7 +89,7 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
className="w-56" className="w-56"
size="small" size="small"
prefix={<Search className="size-3.5 text-stone-400" />} prefix={<Search className="size-3.5 text-stone-400" />}
placeholder="搜索资产" placeholder={t("canvas.assetPicker.search")}
value={keyword} value={keyword}
allowClear allowClear
onChange={(e) => { onChange={(e) => {
@@ -99,17 +98,17 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
}} }}
/> />
<div className="flex gap-1.5"> <div className="flex gap-1.5">
{kindOptions.map((opt) => ( {kindOptions.map((option) => (
<Tag.CheckableTag <Tag.CheckableTag
key={opt.value} key={option}
checked={kindFilter === opt.value} checked={kindFilter === option}
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")} className={cn("prompt-filter-tag", kindFilter === option && "is-active")}
onChange={() => { onChange={() => {
setPage(1); setPage(1);
setKindFilter(opt.value); setKindFilter(option);
}} }}
> >
{opt.label} {option === "all" ? t("common.all") : t(`assets.kinds.${option}`)}
</Tag.CheckableTag> </Tag.CheckableTag>
))} ))}
</div> </div>
@@ -122,7 +121,7 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
))} ))}
</div> </div>
) : ( ) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有资产" className="py-12" /> <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("canvas.assetPicker.empty")} className="py-12" />
)} )}
{filtered.length > PAGE_SIZE && ( {filtered.length > PAGE_SIZE && (
@@ -2,7 +2,9 @@ import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent, MouseEvent, PointerEvent } from "react"; import type { CSSProperties, KeyboardEvent, MouseEvent, PointerEvent } from "react";
import { Button, Image } from "antd"; import { Button, Image } from "antd";
import { FileText, Image as ImageIcon, Music2, Video, X } from "lucide-react"; import { FileText, Image as ImageIcon, Music2, Video, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
import type { NodeGenerationInput } from "./canvas-node-generation"; import type { NodeGenerationInput } from "./canvas-node-generation";
@@ -25,6 +27,7 @@ type MentionState = {
export const CONFIG_REFERENCE_PATTERN = /@\[node:([^\]]+)\]/g; export const CONFIG_REFERENCE_PATTERN = /@\[node:([^\]]+)\]/g;
export function CanvasConfigComposer({ value, inputs, onChange, onClose }: CanvasConfigComposerProps) { export function CanvasConfigComposer({ value, inputs, onChange, onClose }: CanvasConfigComposerProps) {
const { t } = useTranslation();
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const editorRef = useRef<HTMLDivElement>(null); const editorRef = useRef<HTMLDivElement>(null);
const composingRef = useRef(false); const composingRef = useRef(false);
@@ -115,13 +118,13 @@ export function CanvasConfigComposer({ value, inputs, onChange, onClose }: Canva
> >
<div className="mb-2 flex items-center justify-between gap-2"> <div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-baseline gap-2"> <div className="flex min-w-0 items-baseline gap-2">
<div className="shrink-0 text-xs font-semibold"></div> <div className="shrink-0 text-xs font-semibold">{t("canvas.composer.title")}</div>
<div className="truncate text-[11px] opacity-55">@ </div> <div className="truncate text-[11px] opacity-55">{t("canvas.composer.description")}</div>
</div> </div>
<Button size="small" type="text" className="!h-7 !w-7 !min-w-7 !p-0" icon={<X className="size-3.5" />} onClick={onClose} /> <Button size="small" type="text" className="!h-7 !w-7 !min-w-7 !p-0" icon={<X className="size-3.5" />} onClick={onClose} />
</div> </div>
<div className="relative rounded-xl"> <div className="relative rounded-xl">
{!value.trim() ? <div className="pointer-events-none absolute left-3 top-2 text-sm leading-7" style={{ color: theme.node.placeholder }}> @ </div> : null} {!value.trim() ? <div className="pointer-events-none absolute left-3 top-2 text-sm leading-7" style={{ color: theme.node.placeholder }}>{t("canvas.composer.placeholder")}</div> : null}
<div <div
ref={editorRef} ref={editorRef}
contentEditable contentEditable
@@ -173,7 +176,7 @@ export function CanvasConfigComposer({ value, inputs, onChange, onClose }: Canva
/> />
{mention && candidates.length ? <MentionMenu inputs={candidates} allInputs={inputs} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null} {mention && candidates.length ? <MentionMenu inputs={candidates} allInputs={inputs} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null}
</div> </div>
{imagePreview ? <Image src={imagePreview} alt="引用图片预览" style={{ display: "none" }} preview={{ visible: true, src: imagePreview, onVisibleChange: (visible) => !visible && setImagePreview(null) }} /> : null} {imagePreview ? <Image src={imagePreview} alt={t("canvas.composer.imagePreview")} style={{ display: "none" }} preview={{ visible: true, src: imagePreview, onVisibleChange: (visible) => !visible && setImagePreview(null) }} /> : null}
</div> </div>
); );
@@ -360,10 +363,7 @@ function parseComposerTokens(value: string): Token[] {
function resourceLabel(input: NodeGenerationInput, inputs: NodeGenerationInput[]) { function resourceLabel(input: NodeGenerationInput, inputs: NodeGenerationInput[]) {
const sameTypeInputs = inputs.filter((item) => item.type === input.type); const sameTypeInputs = inputs.filter((item) => item.type === input.type);
const index = Math.max(0, sameTypeInputs.findIndex((item) => item.nodeId === input.nodeId)); const index = Math.max(0, sameTypeInputs.findIndex((item) => item.nodeId === input.nodeId));
if (input.type === "image") return `图片${index + 1}`; return i18n.t(`canvas.composer.resources.${input.type}`, { index: index + 1 });
if (input.type === "video") return `视频${index + 1}`;
if (input.type === "audio") return `音频${index + 1}`;
return `文本${index + 1}`;
} }
function chipStyle(theme: (typeof canvasThemes)[keyof typeof canvasThemes]): CSSProperties { function chipStyle(theme: (typeof canvasThemes)[keyof typeof canvasThemes]): CSSProperties {
@@ -1,6 +1,7 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Square, Video } from "lucide-react"; import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Square, Video } from "lucide-react";
import { Button, Segmented } from "antd"; import { Button, Segmented } from "antd";
import { useTranslation } from "react-i18next";
import { ModelPicker } from "@/components/model-picker"; import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, resolveModelForCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { defaultConfig, resolveModelForCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
@@ -23,6 +24,7 @@ type CanvasConfigNodePanelProps = {
}; };
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigChange, onGenerate, onStop, onComposerToggle }: CanvasConfigNodePanelProps) { export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigChange, onGenerate, onStop, onComposerToggle }: CanvasConfigNodePanelProps) {
const { t } = useTranslation();
const globalConfig = useEffectiveConfig(); const globalConfig = useEffectiveConfig();
const openConfigDialog = useConfigStore((state) => state.openConfigDialog); const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
@@ -36,7 +38,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
return ( return (
<div className="flex h-full w-full cursor-move flex-col px-3 pb-3 pt-7 text-sm" style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}> <div className="flex h-full w-full cursor-move flex-col px-3 pb-3 pt-7 text-sm" style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
<div className="mb-2 flex items-center justify-between gap-3"> <div className="mb-2 flex items-center justify-between gap-3">
<div className="shrink-0 text-sm font-semibold"></div> <div className="shrink-0 text-sm font-semibold">{t("canvas.configNode.title")}</div>
<div className="cursor-default" onMouseDown={(event) => event.stopPropagation()}> <div className="cursor-default" onMouseDown={(event) => event.stopPropagation()}>
<Segmented <Segmented
size="small" size="small"
@@ -49,7 +51,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
label: ( label: (
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<ImageIcon className="size-3.5" /> <ImageIcon className="size-3.5" />
{t("canvas.configNode.image")}
</span> </span>
), ),
}, },
@@ -58,7 +60,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
label: ( label: (
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<MessageSquare className="size-3.5" /> <MessageSquare className="size-3.5" />
{t("canvas.configNode.text")}
</span> </span>
), ),
}, },
@@ -67,7 +69,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
label: ( label: (
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<Video className="size-3.5" /> <Video className="size-3.5" />
{t("canvas.configNode.video")}
</span> </span>
), ),
}, },
@@ -76,7 +78,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
label: ( label: (
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<Music2 className="size-3.5" /> <Music2 className="size-3.5" />
{t("canvas.configNode.audio")}
</span> </span>
), ),
}, },
@@ -86,13 +88,13 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
</div> </div>
<div className="mb-2 flex flex-wrap gap-1.5"> <div className="mb-2 flex flex-wrap gap-1.5">
<InputChip label="提示词" value={`${inputSummary.textCount}`} style={chipStyle} /> <InputChip label={t("canvas.configNode.prompt")} value={t("canvas.configNode.items", { count: inputSummary.textCount })} style={chipStyle} />
<InputChip label="参考图" value={`${inputSummary.imageCount}`} style={chipStyle} /> <InputChip label={t("canvas.configNode.references")} value={t("canvas.configNode.images", { count: inputSummary.imageCount })} style={chipStyle} />
<InputChip label="参考视频" value={`${inputSummary.videoCount}`} style={chipStyle} /> <InputChip label={t("canvas.configNode.videoReferences")} value={t("canvas.configNode.items", { count: inputSummary.videoCount })} style={chipStyle} />
<InputChip label="参考音频" value={`${inputSummary.audioCount}`} style={chipStyle} /> <InputChip label={t("canvas.configNode.audioReferences")} value={t("canvas.configNode.items", { count: inputSummary.audioCount })} style={chipStyle} />
<button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]" style={chipStyle} onMouseDown={(event) => event.stopPropagation()} onClick={onComposerToggle}> <button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]" style={chipStyle} onMouseDown={(event) => event.stopPropagation()} onClick={onComposerToggle}>
<Settings2 className="size-3.5" /> <Settings2 className="size-3.5" />
{t("canvas.configNode.compose")}
</button> </button>
</div> </div>
@@ -122,12 +124,12 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
<> <>
<LoaderCircle className="size-4 animate-spin" /> <LoaderCircle className="size-4 animate-spin" />
<Square className="size-3.5 fill-current" /> <Square className="size-3.5 fill-current" />
<span></span> <span>{t("canvas.configNode.stop")}</span>
</> </>
) : ( ) : (
<> <>
<Play className="size-4" /> <Play className="size-4" />
<span></span> <span>{t("canvas.configNode.generate")}</span>
</> </>
)} )}
</span> </span>
@@ -1,12 +1,14 @@
import { useEffect } from "react"; import { useEffect } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
import type { ContextMenuState } from "@/types/canvas"; import type { ContextMenuState } from "@/types/canvas";
export function CanvasNodeContextMenu({ menu, onClose, onDuplicate, onDelete }: { menu: ContextMenuState; onClose: () => void; onDuplicate: () => void; onDelete: () => void }) { export function CanvasNodeContextMenu({ menu, onClose, onDuplicate, onDelete }: { menu: ContextMenuState; onClose: () => void; onDuplicate: () => void; onDelete: () => void }) {
const { t } = useTranslation();
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
useEffect(() => { useEffect(() => {
@@ -25,8 +27,8 @@ export function CanvasNodeContextMenu({ menu, onClose, onDuplicate, onDelete }:
style={{ left: menu.x, top: menu.y, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} style={{ left: menu.x, top: menu.y, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onPointerDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}
> >
{menu.type === "node" ? <MenuButton icon={<Plus className="size-4" />} label="复制" onClick={onDuplicate} /> : null} {menu.type === "node" ? <MenuButton icon={<Plus className="size-4" />} label={t("canvas.controls.duplicate")} onClick={onDuplicate} /> : null}
<MenuButton icon={<Trash2 className="size-4" />} label="删除" onClick={onDelete} danger /> <MenuButton icon={<Trash2 className="size-4" />} label={t("canvas.controls.delete")} onClick={onDelete} danger />
</div> </div>
); );
} }
@@ -1,5 +1,6 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { ImageIcon, List, Music2, Settings2, Video, X } from "lucide-react"; import { ImageIcon, List, Music2, Settings2, Video, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
@@ -21,6 +22,7 @@ export function ConnectionCreateMenu({
onClose: () => void; onClose: () => void;
}) { }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { t } = useTranslation();
return ( return (
<div <div
className="absolute z-[120] w-[300px] rounded-[18px] border p-3 shadow-2xl backdrop-blur" className="absolute z-[120] w-[300px] rounded-[18px] border p-3 shadow-2xl backdrop-blur"
@@ -31,18 +33,18 @@ export function ConnectionCreateMenu({
> >
<div className="mb-2 flex items-center justify-between px-1"> <div className="mb-2 flex items-center justify-between px-1">
<span className="text-sm font-medium" style={{ color: theme.node.muted }}> <span className="text-sm font-medium" style={{ color: theme.node.muted }}>
{t("canvas.createMenu.fromNode")}
</span> </span>
<button type="button" className="grid size-7 place-items-center rounded-lg text-base opacity-55 transition hover:bg-white/10 hover:opacity-100" onClick={onClose} aria-label="关闭"> <button type="button" className="grid size-7 place-items-center rounded-lg text-base opacity-55 transition hover:bg-white/10 hover:opacity-100" onClick={onClose} aria-label={t("canvas.createMenu.close")}>
× ×
</button> </button>
</div> </div>
<div className="grid gap-1"> <div className="grid gap-1">
<ConnectionCreateOption theme={theme} icon={<List className="size-5" />} title="文本生成" description="脚本、广告词、品牌文案" onClick={() => onCreate(CanvasNodeType.Text)} /> <ConnectionCreateOption theme={theme} icon={<List className="size-5" />} title={t("canvas.createMenu.text")} description={t("canvas.createMenu.textDescription")} onClick={() => onCreate(CanvasNodeType.Text)} />
<ConnectionCreateOption theme={theme} icon={<ImageIcon className="size-5" />} title="图片生成" onClick={() => onCreate(CanvasNodeType.Image)} /> <ConnectionCreateOption theme={theme} icon={<ImageIcon className="size-5" />} title={t("canvas.createMenu.image")} onClick={() => onCreate(CanvasNodeType.Image)} />
<ConnectionCreateOption theme={theme} icon={<Video className="size-5" />} title="视频生成" onClick={() => onCreate(CanvasNodeType.Video)} /> <ConnectionCreateOption theme={theme} icon={<Video className="size-5" />} title={t("canvas.createMenu.video")} onClick={() => onCreate(CanvasNodeType.Video)} />
<ConnectionCreateOption theme={theme} icon={<Music2 className="size-5" />} title="音频参考" onClick={() => onCreate(CanvasNodeType.Audio)} /> <ConnectionCreateOption theme={theme} icon={<Music2 className="size-5" />} title={t("canvas.createMenu.audio")} onClick={() => onCreate(CanvasNodeType.Audio)} />
<ConnectionCreateOption theme={theme} icon={<Settings2 className="size-5" />} title="配置节点" description="模型、尺寸、数量和输入顺序" onClick={() => onCreate(CanvasNodeType.Config)} /> <ConnectionCreateOption theme={theme} icon={<Settings2 className="size-5" />} title={t("canvas.createMenu.config")} description={t("canvas.createMenu.configDescription")} onClick={() => onCreate(CanvasNodeType.Config)} />
</div> </div>
</div> </div>
); );
@@ -75,6 +77,7 @@ export function ConnectionCreateOption({ theme, icon, title, description, onClic
export function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; onCreate: (type: string) => void; onClose: () => void }) { export function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; onCreate: (type: string) => void; onClose: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { t } = useTranslation();
useNodeRegistryVersion(); useNodeRegistryVersion();
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const definitions = listNodeDefinitions().filter((def) => def.showInCreateMenu !== false); const definitions = listNodeDefinitions().filter((def) => def.showInCreateMenu !== false);
@@ -96,9 +99,9 @@ export function NodeCreateMenu({ position, onCreate, onClose }: { position: Posi
> >
<div className="mb-2 flex items-center justify-between px-1"> <div className="mb-2 flex items-center justify-between px-1">
<span className="text-sm font-medium" style={{ color: theme.node.muted }}> <span className="text-sm font-medium" style={{ color: theme.node.muted }}>
{t("canvas.createMenu.select")}
</span> </span>
<button type="button" className="grid size-7 place-items-center rounded-lg opacity-55 transition hover:opacity-100" onClick={onClose} aria-label="关闭"> <button type="button" className="grid size-7 place-items-center rounded-lg opacity-55 transition hover:opacity-100" onClick={onClose} aria-label={t("canvas.createMenu.close")}>
<X className="size-4" /> <X className="size-4" />
</button> </button>
</div> </div>
@@ -1,10 +1,12 @@
import { Button, Modal } from "antd"; import { Button, Modal } from "antd";
import { useTranslation } from "react-i18next";
import { useAssetStore } from "@/stores/use-asset-store"; import { useAssetStore } from "@/stores/use-asset-store";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store"; import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
export function CanvasDeleteProjectsDialog() { export function CanvasDeleteProjectsDialog() {
const { t } = useTranslation();
const ids = useCanvasUiStore((state) => state.deleteProjectIds); const ids = useCanvasUiStore((state) => state.deleteProjectIds);
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds); const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const removeSelectedIds = useCanvasUiStore((state) => state.removeSelectedProjectIds); const removeSelectedIds = useCanvasUiStore((state) => state.removeSelectedProjectIds);
@@ -19,20 +21,20 @@ export function CanvasDeleteProjectsDialog() {
return ( return (
<Modal <Modal
title="删除画布?" title={t("canvas.project.deleteTitle")}
open={ids.length > 0} open={ids.length > 0}
centered centered
onCancel={() => setDeleteIds([])} onCancel={() => setDeleteIds([])}
footer={ footer={
<> <>
<Button onClick={() => setDeleteIds([])}></Button> <Button onClick={() => setDeleteIds([])}>{t("common.cancel")}</Button>
<Button danger type="primary" onClick={confirm}> <Button danger type="primary" onClick={confirm}>
{t("common.delete")}
</Button> </Button>
</> </>
} }
> >
<p className="text-sm text-stone-500"> {ids.length} 线</p> <p className="text-sm text-stone-500">{t("canvas.project.deleteDescription", { count: ids.length })}</p>
</Modal> </Modal>
); );
} }
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Settings2 } from "lucide-react"; import { Settings2 } from "lucide-react";
import { Button } from "antd"; import { Button } from "antd";
import { useTranslation } from "react-i18next";
import { ImageSettingsPanel, imageQualityLabel, imageSizeLabel } from "@/components/image-settings-panel"; import { ImageSettingsPanel, imageQualityLabel, imageSizeLabel } from "@/components/image-settings-panel";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
@@ -20,6 +21,7 @@ type CanvasImageSettingsPopoverProps = {
}; };
export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, placement = "topLeft" }: CanvasImageSettingsPopoverProps) { export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, placement = "topLeft" }: CanvasImageSettingsPopoverProps) {
const { t } = useTranslation();
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const buttonRef = useRef<HTMLSpanElement>(null); const buttonRef = useRef<HTMLSpanElement>(null);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
@@ -63,7 +65,7 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang
<span ref={buttonRef} className="inline-flex min-w-0"> <span ref={buttonRef} className="inline-flex min-w-0">
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} onClick={() => updateOpen(!open)}> <Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} onClick={() => updateOpen(!open)}>
<span className="truncate"> <span className="truncate">
{imageQualityLabel(quality)} · {imageSizeLabel(activeSize)} · {count} {imageQualityLabel(quality)} · {imageSizeLabel(activeSize)} · {t("canvas.controls.images", { count })}
</span> </span>
</Button> </Button>
</span> </span>
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Button, Card, Checkbox, Form, Modal, Space, Switch, Tag, Tooltip, Typography, theme as antdTheme } from "antd"; import { Button, Card, Checkbox, Form, Modal, Space, Switch, Tag, Tooltip, Typography, theme as antdTheme } from "antd";
import { Ellipsis, Image as ImageIcon, Settings2 } from "lucide-react"; import { Ellipsis, Image as ImageIcon, Settings2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ImageQuickToolId } from "./canvas-image-toolbar-tools"; import type { ImageQuickToolId } from "./canvas-image-toolbar-tools";
@@ -48,6 +49,7 @@ export function ImageToolSettingsModal({
onCancel: () => void; onCancel: () => void;
onSave: () => void; onSave: () => void;
}) { }) {
const { t } = useTranslation();
const { token } = antdTheme.useToken(); const { token } = antdTheme.useToken();
const previewToolbarRef = useRef<HTMLDivElement>(null); const previewToolbarRef = useRef<HTMLDivElement>(null);
const scrollbarTrackRef = useRef<HTMLInputElement>(null); const scrollbarTrackRef = useRef<HTMLInputElement>(null);
@@ -56,7 +58,7 @@ export function ImageToolSettingsModal({
const selectedTools = tools.filter((tool) => selected.has(tool.id)); const selectedTools = tools.filter((tool) => selected.has(tool.id));
const previewTools: PreviewTool[] = [ const previewTools: PreviewTool[] = [
...selectedTools, ...selectedTools,
{ id: "more", title: "配置快捷工具", label: "更多", icon: <Ellipsis className="size-4" />, active: true }, { id: "more", title: t("canvas.imageTools.configure"), label: t("canvas.imageTools.more"), icon: <Ellipsis className="size-4" />, active: true },
]; ];
const syncPreviewScroll = useCallback(() => { const syncPreviewScroll = useCallback(() => {
@@ -121,7 +123,7 @@ export function ImageToolSettingsModal({
return ( return (
<Modal <Modal
title="自定义工具栏" title={t("canvas.imageTools.customize")}
open={open} open={open}
centered centered
width={760} width={760}
@@ -130,20 +132,20 @@ export function ImageToolSettingsModal({
footer={ footer={
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span></span> <span>{t("canvas.imageTools.showLabels")}</span>
<Switch checked={showLabels} onChange={onShowLabelsChange} /> <Switch checked={showLabels} onChange={onShowLabelsChange} />
</div> </div>
<Space> <Space>
<Button onClick={onCancel}></Button> <Button onClick={onCancel}>{t("common.cancel")}</Button>
<Button type="primary" onClick={onSave}> <Button type="primary" onClick={onSave}>
{t("common.save")}
</Button> </Button>
</Space> </Space>
</div> </div>
} }
> >
<Typography.Paragraph type="secondary" className="!mb-4"> <Typography.Paragraph type="secondary" className="!mb-4">
使 {t("canvas.imageTools.description")}
</Typography.Paragraph> </Typography.Paragraph>
<Card <Card
@@ -151,7 +153,7 @@ export function ImageToolSettingsModal({
title={ title={
<Space size={6}> <Space size={6}>
<Settings2 className="size-4" /> <Settings2 className="size-4" />
{t("canvas.imageTools.preview")}
</Space> </Space>
} }
className="mb-4" className="mb-4"
@@ -172,7 +174,7 @@ export function ImageToolSettingsModal({
style={{ background: token.colorFillAlter, borderColor: token.colorBorderSecondary, color: token.colorTextSecondary }} style={{ background: token.colorFillAlter, borderColor: token.colorBorderSecondary, color: token.colorTextSecondary }}
> >
<ImageIcon className="mb-2 size-8" /> <ImageIcon className="mb-2 size-8" />
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary">{t("canvas.imageTools.imageNode")}</Typography.Text>
</div> </div>
<input <input
ref={scrollbarTrackRef} ref={scrollbarTrackRef}
@@ -194,7 +196,7 @@ export function ImageToolSettingsModal({
className="!mb-4" className="!mb-4"
label={ label={
<Space size={8}> <Space size={8}>
<span></span> <span>{t("canvas.imageTools.quickTools")}</span>
<Tag className="m-0"> <Tag className="m-0">
{selectedTools.length}/{tools.length} {selectedTools.length}/{tools.length}
</Tag> </Tag>
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { Brush, Camera, Copy, FileText, Grid2x2, Lock, LockOpen, Maximize2, Scissors, Sparkles, Upload, ZoomIn } from "lucide-react"; import { Brush, Camera, Copy, FileText, Grid2x2, Lock, LockOpen, Maximize2, Scissors, Sparkles, Upload, ZoomIn } from "lucide-react";
import type { CanvasNodeData } from "@/types/canvas"; import type { CanvasNodeData } from "@/types/canvas";
import i18n from "@/i18n";
export type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "maskEdit" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "view"; export type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "maskEdit" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "view";
export type ImageQuickToolId = "info" | "delete" | "saveAsset" | "download" | "edit" | ImageNodeActionToolId; export type ImageQuickToolId = "info" | "delete" | "saveAsset" | "download" | "edit" | ImageNodeActionToolId;
@@ -23,7 +24,6 @@ export type ImageToolHandlers = {
export type ImageToolDefinition = { export type ImageToolDefinition = {
id: ImageNodeActionToolId; id: ImageNodeActionToolId;
defaultVisible: boolean; defaultVisible: boolean;
panelLabel: string;
label: string | ((node: CanvasNodeData) => string); label: string | ((node: CanvasNodeData) => string);
title: string | ((node: CanvasNodeData) => string); title: string | ((node: CanvasNodeData) => string);
icon: (node: CanvasNodeData) => ReactNode; icon: (node: CanvasNodeData) => ReactNode;
@@ -44,36 +44,32 @@ export const imageToolDefinitions: ImageToolDefinition[] = [
{ {
id: "copyPrompt", id: "copyPrompt",
defaultVisible: true, defaultVisible: true,
panelLabel: "复制提示词", label: () => i18n.t("canvas.imageTools.copyPrompt"),
label: "复制提示词", title: () => i18n.t("canvas.imageTools.copyPromptTitle"),
title: "复制生成该图片的提示词",
icon: () => <Copy className="size-4" />, icon: () => <Copy className="size-4" />,
run: (node, handlers) => handlers.onCopyPrompt(node), run: (node, handlers) => handlers.onCopyPrompt(node),
}, },
{ {
id: "reversePrompt", id: "reversePrompt",
defaultVisible: true, defaultVisible: true,
panelLabel: "反推提示词", label: () => i18n.t("canvas.imageTools.reversePrompt"),
label: "反推提示词", title: () => i18n.t("canvas.imageTools.reversePromptTitle"),
title: "创建反推提示词的文本和配置节点",
icon: () => <FileText className="size-4" />, icon: () => <FileText className="size-4" />,
run: (node, handlers) => handlers.onReversePrompt(node), run: (node, handlers) => handlers.onReversePrompt(node),
}, },
{ {
id: "replace", id: "replace",
defaultVisible: true, defaultVisible: true,
panelLabel: "替换图片", label: () => i18n.t("canvas.imageTools.replace"),
label: "替换图片", title: () => i18n.t("canvas.imageTools.replace"),
title: "替换图片",
icon: () => <Upload className="size-4" />, icon: () => <Upload className="size-4" />,
run: (node, handlers) => handlers.onUpload(node), run: (node, handlers) => handlers.onUpload(node),
}, },
{ {
id: "resize", id: "resize",
defaultVisible: false, defaultVisible: false,
panelLabel: "锁比例", label: (node) => i18n.t(node.metadata?.freeResize ? "canvas.imageTools.free" : "canvas.imageTools.locked"),
label: (node) => (node.metadata?.freeResize ? "自由比例" : "锁比例"), title: (node) => i18n.t(node.metadata?.freeResize ? "canvas.imageTools.lockTitle" : "canvas.imageTools.freeTitle"),
title: (node) => (node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"),
icon: (node) => (node.metadata?.freeResize ? <LockOpen className="size-4" /> : <Lock className="size-4" />), icon: (node) => (node.metadata?.freeResize ? <LockOpen className="size-4" /> : <Lock className="size-4" />),
active: (node) => Boolean(node.metadata?.freeResize), active: (node) => Boolean(node.metadata?.freeResize),
run: (node, handlers) => handlers.onToggleFreeResize(node), run: (node, handlers) => handlers.onToggleFreeResize(node),
@@ -81,63 +77,56 @@ export const imageToolDefinitions: ImageToolDefinition[] = [
{ {
id: "maskEdit", id: "maskEdit",
defaultVisible: true, defaultVisible: true,
panelLabel: "局部编辑", label: () => i18n.t("canvas.imageTools.mask"),
label: "局部编辑", title: () => i18n.t("canvas.imageTools.maskTitle"),
title: "添加蒙版遮罩后局部修改",
icon: () => <Brush className="size-4" />, icon: () => <Brush className="size-4" />,
run: (node, handlers) => handlers.onMaskEdit(node), run: (node, handlers) => handlers.onMaskEdit(node),
}, },
{ {
id: "crop", id: "crop",
defaultVisible: true, defaultVisible: true,
panelLabel: "裁剪", label: () => i18n.t("canvas.imageTools.crop"),
label: "裁剪", title: () => i18n.t("canvas.imageTools.cropTitle"),
title: "裁剪并生成新节点",
icon: () => <Scissors className="size-4" />, icon: () => <Scissors className="size-4" />,
run: (node, handlers) => handlers.onCrop(node), run: (node, handlers) => handlers.onCrop(node),
}, },
{ {
id: "split", id: "split",
defaultVisible: true, defaultVisible: true,
panelLabel: "切图", label: () => i18n.t("canvas.imageTools.split"),
label: "切图", title: () => i18n.t("canvas.imageTools.splitTitle"),
title: "按行列切分图片",
icon: () => <Grid2x2 className="size-4" />, icon: () => <Grid2x2 className="size-4" />,
run: (node, handlers) => handlers.onSplit(node), run: (node, handlers) => handlers.onSplit(node),
}, },
{ {
id: "upscale", id: "upscale",
defaultVisible: true, defaultVisible: true,
panelLabel: "放大", label: () => i18n.t("canvas.imageTools.upscale"),
label: "放大", title: () => i18n.t("canvas.imageTools.upscaleTitle"),
title: "放大图片分辨率",
icon: () => <ZoomIn className="size-4" />, icon: () => <ZoomIn className="size-4" />,
run: (node, handlers) => handlers.onUpscale(node), run: (node, handlers) => handlers.onUpscale(node),
}, },
{ {
id: "superResolve", id: "superResolve",
defaultVisible: false, defaultVisible: false,
panelLabel: "超分", label: () => i18n.t("canvas.imageTools.superResolve"),
label: "超分", title: () => i18n.t("canvas.imageTools.superResolveTitle"),
title: "AI 超分",
icon: () => <Sparkles className="size-4" />, icon: () => <Sparkles className="size-4" />,
run: (node, handlers) => handlers.onSuperResolve(node), run: (node, handlers) => handlers.onSuperResolve(node),
}, },
{ {
id: "angle", id: "angle",
defaultVisible: false, defaultVisible: false,
panelLabel: "多角度", label: () => i18n.t("canvas.imageTools.angle"),
label: "多角度", title: () => i18n.t("canvas.imageTools.angleTitle"),
title: "生成角度",
icon: () => <Camera className="size-4" />, icon: () => <Camera className="size-4" />,
run: (node, handlers) => handlers.onAngle(node), run: (node, handlers) => handlers.onAngle(node),
}, },
{ {
id: "view", id: "view",
defaultVisible: true, defaultVisible: true,
panelLabel: "查看大图", label: () => i18n.t("canvas.imageTools.view"),
label: "查看大图", title: () => i18n.t("canvas.imageTools.viewTitle"),
title: "查看图片详情",
icon: () => <Maximize2 className="size-4" />, icon: () => <Maximize2 className="size-4" />,
run: (node, handlers) => handlers.onViewImage(node), run: (node, handlers) => handlers.onViewImage(node),
}, },
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button, Modal, Segmented, Slider } from "antd"; import { Button, Modal, Segmented, Slider } from "antd";
import { RotateCcw, WandSparkles } from "lucide-react"; import { RotateCcw, WandSparkles } from "lucide-react";
import { useTranslation } from "react-i18next";
export type CanvasImageAngleParams = { export type CanvasImageAngleParams = {
horizontalAngle: number; horizontalAngle: number;
@@ -17,6 +18,7 @@ const defaultParams: CanvasImageAngleParams = {
}; };
export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageAngleParams) => void }) { export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageAngleParams) => void }) {
const { t } = useTranslation();
const [params, setParams] = useState(defaultParams); const [params, setParams] = useState(defaultParams);
useEffect(() => { useEffect(() => {
@@ -29,8 +31,8 @@ export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: { d
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={860} centered destroyOnHidden> <Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={860} centered destroyOnHidden>
<div className="space-y-5"> <div className="space-y-5">
<div> <div>
<h2 className="text-xl font-semibold">AI </h2> <h2 className="text-xl font-semibold">{t("canvas.editors.angleTitle")}</h2>
<p className="mt-1 text-sm opacity-60"></p> <p className="mt-1 text-sm opacity-60">{t("canvas.editors.angleDescription")}</p>
</div> </div>
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_360px]"> <div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_360px]">
<div className="flex min-h-[300px] flex-col justify-between rounded-xl border p-4"> <div className="flex min-h-[300px] flex-col justify-between rounded-xl border p-4">
@@ -41,21 +43,21 @@ export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: { d
</div> </div>
</div> </div>
<Button className="w-fit" icon={<RotateCcw className="size-4" />} onClick={() => setParams(defaultParams)}> <Button className="w-fit" icon={<RotateCcw className="size-4" />} onClick={() => setParams(defaultParams)}>
{t("canvas.editors.reset")}
</Button> </Button>
</div> </div>
<div className="space-y-6 py-2"> <div className="space-y-6 py-2">
<AngleSlider label="左右角度" value={params.horizontalAngle} min={-60} max={60} step={1} suffix="deg" onChange={(value) => update("horizontalAngle", value)} /> <AngleSlider label={t("canvas.editors.horizontal")} value={params.horizontalAngle} min={-60} max={60} step={1} suffix="deg" onChange={(value) => update("horizontalAngle", value)} />
<AngleSlider label="俯仰角度" value={params.pitchAngle} min={-45} max={45} step={1} suffix="deg" onChange={(value) => update("pitchAngle", value)} /> <AngleSlider label={t("canvas.editors.pitch")} value={params.pitchAngle} min={-45} max={45} step={1} suffix="deg" onChange={(value) => update("pitchAngle", value)} />
<AngleSlider label="镜头距离" value={params.cameraDistance} min={1} max={10} step={0.1} onChange={(value) => update("cameraDistance", value)} /> <AngleSlider label={t("canvas.editors.distance")} value={params.cameraDistance} min={1} max={10} step={0.1} onChange={(value) => update("cameraDistance", value)} />
<div className="grid grid-cols-[88px_1fr_72px] items-center gap-4"> <div className="grid grid-cols-[88px_1fr_72px] items-center gap-4">
<span className="font-medium opacity-75">广</span> <span className="font-medium opacity-75">{t("canvas.editors.lens")}</span>
<Segmented <Segmented
className="w-fit" className="w-fit"
value={params.wideAngle ? "wide" : "standard"} value={params.wideAngle ? "wide" : "standard"}
options={[ options={[
{ label: "标准", value: "standard" }, { label: t("canvas.editors.standard"), value: "standard" },
{ label: "广角", value: "wide" }, { label: t("canvas.editors.wide"), value: "wide" },
]} ]}
onChange={(value) => update("wideAngle", value === "wide")} onChange={(value) => update("wideAngle", value === "wide")}
/> />
@@ -64,7 +66,7 @@ export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: { d
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="primary" size="large" icon={<WandSparkles className="size-4" />} onClick={() => onConfirm(params)}> <Button type="primary" size="large" icon={<WandSparkles className="size-4" />} onClick={() => onConfirm(params)}>
AI {t("canvas.editors.aiGenerate")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, Modal, Segmented, Tooltip } from "antd"; import { Button, Modal, Segmented, Tooltip } from "antd";
import { Check, X, ZoomIn, ZoomOut } from "lucide-react"; import { Check, X, ZoomIn, ZoomOut } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useImageEditorViewport } from "@/components/canvas/use-image-editor-viewport"; import { useImageEditorViewport } from "@/components/canvas/use-image-editor-viewport";
import { readImageMeta } from "@/lib/image-utils"; import { readImageMeta } from "@/lib/image-utils";
@@ -18,17 +19,8 @@ type ResizeHandle = "n" | "e" | "s" | "w" | "ne" | "nw" | "se" | "sw";
const handles: ResizeHandle[] = ["nw", "n", "ne", "e", "se", "s", "sw", "w"]; const handles: ResizeHandle[] = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
const minSize = 0.06; const minSize = 0.06;
const defaultCrop = { x: 0.12, y: 0.12, width: 0.76, height: 0.76 }; const defaultCrop = { x: 0.12, y: 0.12, width: 0.76, height: 0.76 };
const ratioOptions = [
{ label: "自由", value: "free" },
{ label: "固定", value: "fixed" },
{ label: "原图", value: "original" },
{ label: "1:1", value: "1:1" },
{ label: "4:3", value: "4:3" },
{ label: "16:9", value: "16:9" },
{ label: "9:16", value: "9:16" },
];
export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (crop: CanvasImageCropRect) => void }) { export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (crop: CanvasImageCropRect) => void }) {
const { t } = useTranslation();
const [crop, setCrop] = useState<CanvasImageCropRect>(defaultCrop); const [crop, setCrop] = useState<CanvasImageCropRect>(defaultCrop);
const [ratioPreset, setRatioPreset] = useState("free"); const [ratioPreset, setRatioPreset] = useState("free");
const [fixedRatio, setFixedRatio] = useState<number | null>(null); const [fixedRatio, setFixedRatio] = useState<number | null>(null);
@@ -77,7 +69,7 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da
}; };
return ( return (
<Modal title="裁剪图片" open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden transitionName="" maskTransitionName=""> <Modal title={t("canvas.editors.cropTitle")} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden transitionName="" maskTransitionName="">
<div className="space-y-4"> <div className="space-y-4">
<div <div
ref={viewport.viewportRef} ref={viewport.viewportRef}
@@ -102,7 +94,7 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da
className="absolute size-3 rounded-full border border-black bg-white" className="absolute size-3 rounded-full border border-black bg-white"
style={handleStyle(handle)} style={handleStyle(handle)}
onPointerDown={(event) => startDrag("resize", event, handle)} onPointerDown={(event) => startDrag("resize", event, handle)}
aria-label="调整裁剪框" aria-label={t("canvas.editors.adjustCrop")}
/> />
))} ))}
</div> </div>
@@ -111,31 +103,34 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da
</div> </div>
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
<Tooltip title="缩小"> <Tooltip title={t("canvas.editors.zoomOut")}>
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label="缩小" onClick={viewport.zoomOut} /> <Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label={t("canvas.editors.zoomOut")} onClick={viewport.zoomOut} />
</Tooltip> </Tooltip>
<button type="button" className="min-w-14 text-center text-xs font-semibold tabular-nums opacity-70" onClick={viewport.resetZoom}> <button type="button" className="min-w-14 text-center text-xs font-semibold tabular-nums opacity-70" onClick={viewport.resetZoom}>
{Math.round(viewport.zoom * 100)}% {Math.round(viewport.zoom * 100)}%
</button> </button>
<Tooltip title="放大"> <Tooltip title={t("canvas.editors.zoomIn")}>
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label="放大" onClick={viewport.zoomIn} /> <Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label={t("canvas.editors.zoomIn")} onClick={viewport.zoomIn} />
</Tooltip> </Tooltip>
<span className="ml-2 text-xs opacity-55"> · +</span> <span className="ml-2 text-xs opacity-55">{t("canvas.editors.cropHint")}</span>
</div> </div>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2"> <div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2">
<div className="flex flex-wrap items-center gap-3 text-sm opacity-80"> <div className="flex flex-wrap items-center gap-3 text-sm opacity-80">
<span> {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"}</span> <span>{t("canvas.editors.cropSize", { size: cropSize ? `${cropSize.width} x ${cropSize.height}` : t("canvas.editors.unknown") })}</span>
<span> {cropSize ? formatRatio(cropSize.width, cropSize.height) : "未知"}</span> <span>{t("canvas.editors.ratio", { ratio: cropSize ? formatRatio(cropSize.width, cropSize.height) : t("canvas.editors.unknown") })}</span>
{image ? ( {image ? (
<span> <span>{t("canvas.editors.original", { width: image.width, height: image.height })}</span>
{image.width} x {image.height}
</span>
) : null} ) : null}
</div> </div>
<Segmented <Segmented
size="small" size="small"
options={ratioOptions} options={[
{ label: t("canvas.editors.free"), value: "free" },
{ label: t("canvas.editors.fixed"), value: "fixed" },
{ label: t("canvas.editors.originalMode"), value: "original" },
...["1:1", "4:3", "16:9", "9:16"],
]}
value={ratioPreset} value={ratioPreset}
onChange={(value) => { onChange={(value) => {
const preset = String(value); const preset = String(value);
@@ -150,12 +145,12 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da
</div> </div>
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<Button onClick={() => setCrop(defaultCrop)}></Button> <Button onClick={() => setCrop(defaultCrop)}>{t("canvas.editors.reset")}</Button>
<Button icon={<X className="size-4" />} onClick={onClose}> <Button icon={<X className="size-4" />} onClick={onClose}>
{t("canvas.editors.cancel")}
</Button> </Button>
<Button type="primary" icon={<Check className="size-4" />} onClick={() => onConfirm(crop)}> <Button type="primary" icon={<Check className="size-4" />} onClick={() => onConfirm(crop)}>
{t("canvas.editors.confirmCrop")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1,4 +1,5 @@
import type { AiTextMessage } from "@/services/api/image"; import type { AiTextMessage } from "@/services/api/image";
import i18n from "@/i18n";
import { imageReferenceLabel } from "@/lib/image-reference-prompt"; import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import { seedanceReferenceLabel } from "@/lib/seedance-video"; import { seedanceReferenceLabel } from "@/lib/seedance-video";
import type { ReferenceImage } from "@/types/image"; import type { ReferenceImage } from "@/types/image";
@@ -154,7 +155,7 @@ function generationLabel(type: NodeGenerationInput["type"], index: number) {
if (type === "image") return imageReferenceLabel(index); if (type === "image") return imageReferenceLabel(index);
if (type === "video") return seedanceReferenceLabel("video", index); if (type === "video") return seedanceReferenceLabel("video", index);
if (type === "audio") return seedanceReferenceLabel("audio", index); if (type === "audio") return seedanceReferenceLabel("audio", index);
return `文本${index + 1}`; return i18n.t("canvas.composer.resources.text", { index: index + 1 });
} }
function readReferenceImage(node: CanvasNodeData): ReferenceImage | null { function readReferenceImage(node: CanvasNodeData): ReferenceImage | null {
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useEffect, useMemo, useState, type ReactNode } from "react";
import { App, Modal, Segmented, Tooltip } from "antd"; import { App, Modal, Segmented, Tooltip } from "antd";
import { Download, Ellipsis, FolderPlus, Image as ImageIcon, Info, MessageSquare, Minus, Music2, Pencil, Plus, RefreshCw, Settings2, Trash2, Upload, Video } from "lucide-react"; import { Download, Ellipsis, FolderPlus, Image as ImageIcon, Info, MessageSquare, Minus, Music2, Pencil, Plus, RefreshCw, Settings2, Trash2, Upload, Video } from "lucide-react";
import { useTranslation } from "react-i18next";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils"; import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
import { useCopyText } from "@/hooks/use-copy-text"; import { useCopyText } from "@/hooks/use-copy-text";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
@@ -82,6 +84,7 @@ export function CanvasNodeHoverToolbar({
const [draftShowImageToolLabels, setDraftShowImageToolLabels] = useState(true); const [draftShowImageToolLabels, setDraftShowImageToolLabels] = useState(true);
const [imageToolSettingsOpen, setImageToolSettingsOpen] = useState(false); const [imageToolSettingsOpen, setImageToolSettingsOpen] = useState(false);
const { message } = App.useApp(); const { message } = App.useApp();
const { t } = useTranslation();
const copyText = useCopyText(); const copyText = useCopyText();
useEffect(() => { useEffect(() => {
@@ -120,10 +123,10 @@ export function CanvasNodeHoverToolbar({
const copyImagePrompt = (target: CanvasNodeData) => { const copyImagePrompt = (target: CanvasNodeData) => {
const prompt = target.metadata?.prompt?.trim(); const prompt = target.metadata?.prompt?.trim();
if (!prompt) { if (!prompt) {
message.warning("暂无可复制的提示词"); message.warning(t("canvas.nodeToolbar.noPrompt"));
return; return;
} }
copyText(prompt, "提示词已复制"); copyText(prompt, t("common.promptCopied"));
}; };
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onMaskEdit, onCrop, onSplit, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt }); const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onMaskEdit, onCrop, onSplit, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt });
@@ -135,22 +138,22 @@ export function CanvasNodeHoverToolbar({
} }
const baseToolbarTools: ToolbarTool[] = [ const baseToolbarTools: ToolbarTool[] = [
{ id: "info", title: "查看节点信息", label: "信息", icon: <Info className="size-4" />, onClick: () => onInfo(node) }, { id: "info", title: t("canvas.nodeToolbar.infoTitle"), label: t("canvas.nodeToolbar.info"), icon: <Info className="size-4" />, onClick: () => onInfo(node) },
{ id: "delete", title: "移除节点", label: "删除", icon: <Trash2 className="size-4" />, onClick: () => onDelete(node), danger: true }, { id: "delete", title: t("canvas.nodeToolbar.removeTitle"), label: t("common.delete"), icon: <Trash2 className="size-4" />, onClick: () => onDelete(node), danger: true },
]; ];
const nodeToolbarTools: ToolbarTool[] = [ const nodeToolbarTools: ToolbarTool[] = [
...(canRetry ? [{ id: "retry", title: "重新生成", label: "重试", icon: <RefreshCw className="size-4" />, onClick: () => onRetry(node) }] : []), ...(canRetry ? [{ id: "retry", title: t("canvas.nodeToolbar.retryTitle"), label: t("canvas.node.retry"), icon: <RefreshCw className="size-4" />, onClick: () => onRetry(node) }] : []),
...(hasImage || hasVideo || isText ? [{ id: "saveAsset", title: "加入我的资产", label: "存资产", icon: <FolderPlus className="size-4" />, onClick: () => onSaveAsset(node) }] : []), ...(hasImage || hasVideo || isText ? [{ id: "saveAsset", title: t("common.addToAssets"), label: t("canvas.nodeToolbar.saveAsset"), icon: <FolderPlus className="size-4" />, onClick: () => onSaveAsset(node) }] : []),
...(hasImage || hasVideo || hasAudio ? [{ id: "download", title: hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片", label: "下载", icon: <Download className="size-4" />, onClick: () => onDownload(node) }] : []), ...(hasImage || hasVideo || hasAudio ? [{ id: "download", title: t(hasAudio ? "canvas.nodeToolbar.downloadAudio" : hasVideo ? "canvas.nodeToolbar.downloadVideo" : "canvas.nodeToolbar.downloadImage"), label: t("common.download"), icon: <Download className="size-4" />, onClick: () => onDownload(node) }] : []),
...(canOpenDialog ? [{ id: "edit", title: "编辑", label: "编辑", icon: <MessageSquare className="size-4" />, onClick: () => onToggleDialog(node) }] : []), ...(canOpenDialog ? [{ id: "edit", title: t("common.edit"), label: t("common.edit"), icon: <MessageSquare className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
...(isText ? [{ id: "editText", title: "编辑文本", label: "编辑文字", icon: <Pencil className="size-4" />, onClick: () => onEditText(node) }] : []), ...(isText ? [{ id: "editText", title: t("canvas.nodeToolbar.editTextTitle"), label: t("canvas.nodeToolbar.editText"), icon: <Pencil className="size-4" />, onClick: () => onEditText(node) }] : []),
...(isText ? [{ id: "generateImage", title: "用文本生图", label: "生图", icon: <ImageIcon className="size-4" />, onClick: () => onGenerateImage(node) }] : []), ...(isText ? [{ id: "generateImage", title: t("canvas.node.generateImage"), label: t("canvas.node.generate"), icon: <ImageIcon className="size-4" />, onClick: () => onGenerateImage(node) }] : []),
...(isConfig ? [{ id: "config", title: "生成配置", label: "生成配置", icon: <Settings2 className="size-4" />, onClick: () => onToggleDialog(node) }] : []), ...(isConfig ? [{ id: "config", title: t("canvas.configNode.title"), label: t("canvas.configNode.title"), icon: <Settings2 className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
...(isText ? [{ id: "decreaseFont", title: "减小字号", label: "缩小", icon: <Minus className="size-4" />, onClick: () => onDecreaseFont(node) }] : []), ...(isText ? [{ id: "decreaseFont", title: t("canvas.nodeToolbar.decreaseFont"), label: t("canvas.nodeToolbar.zoomOut"), icon: <Minus className="size-4" />, onClick: () => onDecreaseFont(node) }] : []),
...(isText ? [{ id: "increaseFont", title: "增大字号", label: "放大", icon: <Plus className="size-4" />, onClick: () => onIncreaseFont(node) }] : []), ...(isText ? [{ id: "increaseFont", title: t("canvas.nodeToolbar.increaseFont"), label: t("canvas.nodeToolbar.zoomIn"), icon: <Plus className="size-4" />, onClick: () => onIncreaseFont(node) }] : []),
...(isImage && !hasImage ? [{ id: "uploadImage", title: "上传图片", label: "上传图片", icon: <Upload className="size-4" />, onClick: () => onUpload(node) }] : []), ...(isImage && !hasImage ? [{ id: "uploadImage", title: t("canvas.nodeToolbar.uploadImage"), label: t("canvas.nodeToolbar.uploadImage"), icon: <Upload className="size-4" />, onClick: () => onUpload(node) }] : []),
...(isVideo ? [{ id: "uploadVideo", title: hasVideo ? "替换视频" : "上传视频", label: hasVideo ? "替换视频" : "上传视频", icon: <Video className="size-4" />, onClick: () => onUpload(node) }] : []), ...(isVideo ? [{ id: "uploadVideo", title: t(hasVideo ? "canvas.nodeToolbar.replaceVideo" : "canvas.nodeToolbar.uploadVideo"), label: t(hasVideo ? "canvas.nodeToolbar.replaceVideo" : "canvas.nodeToolbar.uploadVideo"), icon: <Video className="size-4" />, onClick: () => onUpload(node) }] : []),
...(isAudio ? [{ id: "uploadAudio", title: hasAudio ? "替换音频" : "上传音频", label: hasAudio ? "替换音频" : "上传音频", icon: <Music2 className="size-4" />, onClick: () => onUpload(node) }] : []), ...(isAudio ? [{ id: "uploadAudio", title: t(hasAudio ? "canvas.nodeToolbar.replaceAudio" : "canvas.nodeToolbar.uploadAudio"), label: t(hasAudio ? "canvas.nodeToolbar.replaceAudio" : "canvas.nodeToolbar.uploadAudio"), icon: <Music2 className="size-4" />, onClick: () => onUpload(node) }] : []),
...(hasImage ? imageTools.map((tool) => ({ id: tool.id, title: tool.title, label: tool.label, icon: tool.icon, active: tool.active, onClick: tool.onClick })) : []), ...(hasImage ? imageTools.map((tool) => ({ id: tool.id, title: tool.title, label: tool.label, icon: tool.icon, active: tool.active, onClick: tool.onClick })) : []),
]; ];
const toolbarTools = hasImage ? [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : [...baseToolbarTools, ...nodeToolbarTools, ...extraTools]; const toolbarTools = hasImage ? [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : [...baseToolbarTools, ...nodeToolbarTools, ...extraTools];
@@ -193,7 +196,7 @@ export function CanvasNodeHoverToolbar({
{toolbarTools.map((tool) => ( {toolbarTools.map((tool) => (
<ToolbarAction key={tool.id} {...tool} showLabel={showImageToolLabels} /> <ToolbarAction key={tool.id} {...tool} showLabel={showImageToolLabels} />
))} ))}
{hasImage ? <ToolbarAction id="more" title="配置快捷工具" label="更多" icon={<Ellipsis className="size-4" />} active={imageToolSettingsOpen} onClick={openImageToolSettings} showLabel={showImageToolLabels} /> : null} {hasImage ? <ToolbarAction id="more" title={t("canvas.imageTools.configure")} label={t("canvas.imageTools.more")} icon={<Ellipsis className="size-4" />} active={imageToolSettingsOpen} onClick={openImageToolSettings} showLabel={showImageToolLabels} /> : null}
</div> </div>
{hasImage ? ( {hasImage ? (
<ImageToolSettingsModal <ImageToolSettingsModal
@@ -213,6 +216,7 @@ export function CanvasNodeHoverToolbar({
export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeData | null; open: boolean; onClose: () => void }) { export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeData | null; open: boolean; onClose: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { t } = useTranslation();
const [view, setView] = useState<"info" | "json">("info"); const [view, setView] = useState<"info" | "json">("info");
const imageBytes = node?.type === CanvasNodeType.Image && node.metadata?.content ? getDataUrlByteSize(node.metadata.content) : 0; const imageBytes = node?.type === CanvasNodeType.Image && node.metadata?.content ? getDataUrlByteSize(node.metadata.content) : 0;
const batchCount = node?.type === CanvasNodeType.Image ? node.metadata?.batchChildIds?.length || 0 : 0; const batchCount = node?.type === CanvasNodeType.Image ? node.metadata?.batchChildIds?.length || 0 : 0;
@@ -236,13 +240,13 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
const title = ( const title = (
<div className="flex items-center justify-between gap-4 pr-12"> <div className="flex items-center justify-between gap-4 pr-12">
<span></span> <span>{t("canvas.nodeToolbar.nodeInfo")}</span>
<Segmented <Segmented
size="small" size="small"
value={view} value={view}
onChange={(value) => setView(value as "info" | "json")} onChange={(value) => setView(value as "info" | "json")}
options={[ options={[
{ label: "信息", value: "info" }, { label: t("canvas.nodeToolbar.info"), value: "info" },
{ label: "JSON", value: "json" }, { label: "JSON", value: "json" },
]} ]}
/> />
@@ -256,14 +260,14 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
{view === "info" ? ( {view === "info" ? (
<div className="thin-scrollbar h-full space-y-3 overflow-auto pr-1"> <div className="thin-scrollbar h-full space-y-3 overflow-auto pr-1">
<InfoRow label="ID" value={node.id} /> <InfoRow label="ID" value={node.id} />
<InfoRow label="名称" value={node.title || "未命名节点"} /> <InfoRow label={t("canvas.nodeToolbar.name")} value={node.title || t("canvas.node.untitled")} />
<InfoRow label="类型" value={node.type === CanvasNodeType.Text ? "文本" : node.type === CanvasNodeType.Image ? "图片" : node.type === CanvasNodeType.Video ? "视频" : node.type === CanvasNodeType.Audio ? "音频" : node.type === CanvasNodeType.Group ? "组" : "生成配置"} /> <InfoRow label={t("canvas.nodeToolbar.type")} value={node.type === CanvasNodeType.Group ? t("canvas.node.group") : node.type === CanvasNodeType.Config ? t("canvas.configNode.title") : [CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio, CanvasNodeType.Text].includes(node.type as CanvasNodeType) ? t(`assets.kinds.${node.type}`) : getNodeDefinition(node.type)?.title || node.type} />
<InfoRow label="尺寸" value={`${Math.round(node.width)} x ${Math.round(node.height)}`} /> <InfoRow label={t("canvas.nodeToolbar.size")} value={`${Math.round(node.width)} x ${Math.round(node.height)}`} />
<InfoRow label="位置" value={`${Math.round(node.position.x)}, ${Math.round(node.position.y)}`} /> <InfoRow label={t("canvas.nodeToolbar.position")} value={`${Math.round(node.position.x)}, ${Math.round(node.position.y)}`} />
<InfoRow label="状态" value={node.metadata?.status || "idle"} /> <InfoRow label={t("canvas.nodeToolbar.status")} value={node.metadata?.status || "idle"} />
{batchCount > 1 ? <InfoRow label="图片组" value={`${batchCount}`} /> : null} {batchCount > 1 ? <InfoRow label={t("canvas.nodeToolbar.imageGroup")} value={t("canvas.configNode.images", { count: batchCount })} /> : null}
{node.metadata?.prompt ? <InfoRow label="提示词" value={node.metadata.prompt} /> : null} {node.metadata?.prompt ? <InfoRow label={t("canvas.configNode.prompt")} value={node.metadata.prompt} /> : null}
{imageBytes ? <InfoRow label="图片大小" value={formatBytes(imageBytes)} /> : null} {imageBytes ? <InfoRow label={t("canvas.nodeToolbar.imageSize")} value={formatBytes(imageBytes)} /> : null}
{node.metadata?.errorDetails ? ( {node.metadata?.errorDetails ? (
<div className="rounded-lg border p-3 text-red-400" style={{ borderColor: theme.node.stroke }}> <div className="rounded-lg border p-3 text-red-400" style={{ borderColor: theme.node.stroke }}>
{node.metadata.errorDetails} {node.metadata.errorDetails}
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPoi
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Button, Input, Modal, Slider, Tooltip } from "antd"; import { Button, Input, Modal, Slider, Tooltip } from "antd";
import { Brush, Eraser, Redo2, RotateCcw, Undo2, WandSparkles, X, ZoomIn, ZoomOut } from "lucide-react"; import { Brush, Eraser, Redo2, RotateCcw, Undo2, WandSparkles, X, ZoomIn, ZoomOut } from "lucide-react";
import { useTranslation } from "react-i18next";
import { readImageMeta } from "@/lib/image-utils"; import { readImageMeta } from "@/lib/image-utils";
import { useImageEditorViewport } from "@/components/canvas/use-image-editor-viewport"; import { useImageEditorViewport } from "@/components/canvas/use-image-editor-viewport";
@@ -20,6 +21,7 @@ const defaultBrushSize = 100;
const maskFillColor = "rgba(37, 99, 235, .38)"; const maskFillColor = "rgba(37, 99, 235, .38)";
export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (payload: CanvasImageMaskEditPayload) => void }) { export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (payload: CanvasImageMaskEditPayload) => void }) {
const { t } = useTranslation();
const maskCanvasRef = useRef<HTMLCanvasElement>(null); const maskCanvasRef = useRef<HTMLCanvasElement>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(null); const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const drawingRef = useRef<{ active: boolean; stroke: MaskStroke | null }>({ active: false, stroke: null }); const drawingRef = useRef<{ active: boolean; stroke: MaskStroke | null }>({ active: false, stroke: null });
@@ -201,9 +203,9 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
const submit = () => { const submit = () => {
const nextPrompt = prompt.trim(); const nextPrompt = prompt.trim();
const canvas = maskCanvasRef.current; const canvas = maskCanvasRef.current;
if (!nextPrompt) return setError("请输入修改要求"); if (!nextPrompt) return setError(t("canvas.editors.maskPromptRequired"));
if (!canvas) return; if (!canvas) return;
if (!canvasHasPaint(canvas)) return setError("请先涂抹局部区域"); if (!canvasHasPaint(canvas)) return setError(t("canvas.editors.maskRequired"));
onConfirm({ prompt: nextPrompt, maskDataUrl: buildEditMask(canvas) }); onConfirm({ prompt: nextPrompt, maskDataUrl: buildEditMask(canvas) });
}; };
@@ -257,55 +259,55 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
<div className="flex min-h-[360px] flex-col gap-5"> <div className="flex min-h-[360px] flex-col gap-5">
<div> <div>
<h2 className="text-xl font-semibold"></h2> <h2 className="text-xl font-semibold">{t("canvas.editors.maskTitle")}</h2>
<div className="mt-2 text-sm opacity-60">{image ? `${image.width} x ${image.height}px` : "读取中"}</div> <div className="mt-2 text-sm opacity-60">{image ? `${image.width} x ${image.height}px` : t("canvas.editors.loading")}</div>
<div className="mt-2 text-xs leading-5 opacity-55"> · + · Alt+/ · Ctrl/Cmd+Z · Ctrl/Cmd+Shift+Z </div> <div className="mt-2 text-xs leading-5 opacity-55">{t("canvas.editors.maskHint")}</div>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<Button type={mode === "paint" ? "primary" : "default"} icon={<Brush className="size-4" />} onClick={() => setMode("paint")}> <Button type={mode === "paint" ? "primary" : "default"} icon={<Brush className="size-4" />} onClick={() => setMode("paint")}>
{t("canvas.editors.brush")}
</Button> </Button>
<Button type={mode === "erase" ? "primary" : "default"} icon={<Eraser className="size-4" />} onClick={() => setMode("erase")}> <Button type={mode === "erase" ? "primary" : "default"} icon={<Eraser className="size-4" />} onClick={() => setMode("erase")}>
{t("canvas.editors.erase")}
</Button> </Button>
</div> </div>
<div className="flex items-center justify-between rounded-lg border border-black/10 px-2 py-1 dark:border-white/10"> <div className="flex items-center justify-between rounded-lg border border-black/10 px-2 py-1 dark:border-white/10">
<Tooltip title="撤回局部涂抹 (Ctrl/Cmd+Z)"> <Tooltip title={t("canvas.editors.undoMaskTitle")}>
<Button type="text" icon={<Undo2 className="size-4" />} disabled={!historySize} aria-label="撤回局部涂抹" onClick={undoMask} /> <Button type="text" icon={<Undo2 className="size-4" />} disabled={!historySize} aria-label={t("canvas.editors.undoMask")} onClick={undoMask} />
</Tooltip> </Tooltip>
<Tooltip title="重做局部涂抹 (Ctrl/Cmd+Shift+Z)"> <Tooltip title={t("canvas.editors.redoMaskTitle")}>
<Button type="text" icon={<Redo2 className="size-4" />} disabled={!redoSize} aria-label="重做局部涂抹" onClick={redoMask} /> <Button type="text" icon={<Redo2 className="size-4" />} disabled={!redoSize} aria-label={t("canvas.editors.redoMask")} onClick={redoMask} />
</Tooltip> </Tooltip>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Tooltip title="缩小"> <Tooltip title={t("canvas.editors.zoomOut")}>
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label="缩小" onClick={viewport.zoomOut} /> <Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label={t("canvas.editors.zoomOut")} onClick={viewport.zoomOut} />
</Tooltip> </Tooltip>
<button type="button" className="min-w-14 text-center text-xs font-semibold tabular-nums opacity-70" onClick={viewport.resetZoom}> <button type="button" className="min-w-14 text-center text-xs font-semibold tabular-nums opacity-70" onClick={viewport.resetZoom}>
{Math.round(viewport.zoom * 100)}% {Math.round(viewport.zoom * 100)}%
</button> </button>
<Tooltip title="放大"> <Tooltip title={t("canvas.editors.zoomIn")}>
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label="放大" onClick={viewport.zoomIn} /> <Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label={t("canvas.editors.zoomIn")} onClick={viewport.zoomIn} />
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="font-medium opacity-75"></span> <span className="font-medium opacity-75">{t("canvas.editors.brushSize")}</span>
<span className="font-semibold">{brushSize}px</span> <span className="font-semibold">{brushSize}px</span>
</div> </div>
<Slider min={8} max={160} step={2} value={brushSize} onChange={setBrushSize} /> <Slider min={8} max={160} step={2} value={brushSize} onChange={setBrushSize} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="text-sm font-medium opacity-75"></div> <div className="text-sm font-medium opacity-75">{t("canvas.editors.editInstructions")}</div>
<Input.TextArea <Input.TextArea
rows={6} rows={6}
value={prompt} value={prompt}
status={error && !prompt.trim() ? "error" : undefined} status={error && !prompt.trim() ? "error" : undefined}
placeholder="例如:把选中区域改成金属材质,保持原图光影" placeholder={t("canvas.editors.maskPlaceholder")}
onChange={(event) => { onChange={(event) => {
setPrompt(event.target.value); setPrompt(event.target.value);
setError(""); setError("");
@@ -316,14 +318,14 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
<div className="mt-auto flex items-center justify-between gap-2"> <div className="mt-auto flex items-center justify-between gap-2">
<Button icon={<RotateCcw className="size-4" />} onClick={resetMask}> <Button icon={<RotateCcw className="size-4" />} onClick={resetMask}>
{t("canvas.editors.reset")}
</Button> </Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button icon={<X className="size-4" />} onClick={onClose}> <Button icon={<X className="size-4" />} onClick={onClose}>
{t("canvas.editors.cancel")}
</Button> </Button>
<Button type="primary" icon={<WandSparkles className="size-4" />} onClick={submit}> <Button type="primary" icon={<WandSparkles className="size-4" />} onClick={submit}>
AI {t("canvas.editors.aiEdit")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ArrowUp, LoaderCircle, Square } from "lucide-react"; import { ArrowUp, LoaderCircle, Square } from "lucide-react";
import { Button } from "antd"; import { Button } from "antd";
import { useTranslation } from "react-i18next";
import { ModelPicker } from "@/components/model-picker"; import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, resolveModelForCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { defaultConfig, resolveModelForCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
@@ -30,6 +31,7 @@ type CanvasNodePromptPanelProps = {
}; };
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange, modeOverride }: CanvasNodePromptPanelProps) { export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange, modeOverride }: CanvasNodePromptPanelProps) {
const { t } = useTranslation();
const globalConfig = useEffectiveConfig(); const globalConfig = useEffectiveConfig();
const openConfigDialog = useConfigStore((state) => state.openConfigDialog); const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
@@ -73,7 +75,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
onSubmit={submit} onSubmit={submit}
className="thin-scrollbar h-40 w-full cursor-text resize-none rounded-xl px-3 py-2 text-sm leading-5 outline-none" className="thin-scrollbar h-40 w-full cursor-text resize-none rounded-xl px-3 py-2 text-sm leading-5 outline-none"
style={{ background: "transparent", color: theme.node.text }} style={{ background: "transparent", color: theme.node.text }}
placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)} placeholder={t(`canvas.promptPanel.${mode === "image" && hasImageContent ? "editImage" : mode === "text" && hasTextContent ? "editText" : mode}`)}
/> />
<div className="mt-2 flex min-w-0 items-center justify-between gap-2"> <div className="mt-2 flex min-w-0 items-center justify-between gap-2">
@@ -114,14 +116,14 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
danger={isRunning} danger={isRunning}
disabled={!isRunning && !prompt.trim()} disabled={!isRunning && !prompt.trim()}
onClick={() => (isRunning ? onStop(node.id) : submit())} onClick={() => (isRunning ? onStop(node.id) : submit())}
aria-label={isRunning ? "停止生成" : "生成"} aria-label={t(isRunning ? "canvas.promptPanel.stopGeneration" : "canvas.promptPanel.generate")}
> >
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
{isRunning ? ( {isRunning ? (
<> <>
<LoaderCircle className="size-4 animate-spin" /> <LoaderCircle className="size-4 animate-spin" />
<Square className="size-3.5 fill-current" /> <Square className="size-3.5 fill-current" />
<span className="text-xs font-medium"></span> <span className="text-xs font-medium">{t("canvas.promptPanel.stop")}</span>
</> </>
) : ( ) : (
<ArrowUp className="size-4" /> <ArrowUp className="size-4" />
@@ -157,13 +159,6 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
}; };
} }
function promptPlaceholder(mode: CanvasNodeGenerationMode, hasImageContent: boolean, hasTextContent: boolean) {
if (mode === "video") return "描述要生成的视频内容";
if (mode === "audio") return "描述要生成的音频内容";
if (mode === "image") return hasImageContent ? "请输入你想要把这张图修改成什么" : "描述要生成的图片内容";
return hasTextContent ? "请输入你想要将本段文本修改成什么" : "请输入你想要生成的文本内容";
}
function videoConfigPatch(key: keyof AiConfig, value: string) { function videoConfigPatch(key: keyof AiConfig, value: string) {
if (key === "videoSeconds") return { seconds: value }; if (key === "videoSeconds") return { seconds: value };
if (key === "videoGenerateAudio") return { generateAudio: value }; if (key === "videoGenerateAudio") return { generateAudio: value };
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, InputNumber, Modal, Tooltip } from "antd"; import { Button, InputNumber, Modal, Tooltip } from "antd";
import { Grid2x2, ListRestart, PanelTop, Redo2, Rows3, Trash2, Undo2, ZoomIn, ZoomOut } from "lucide-react"; import { Grid2x2, ListRestart, PanelTop, Redo2, Rows3, Trash2, Undo2, ZoomIn, ZoomOut } from "lucide-react";
import { useTranslation } from "react-i18next";
import { readImageMeta } from "@/lib/image-utils"; import { readImageMeta } from "@/lib/image-utils";
import type { ImageSplitParams } from "@/lib/canvas/canvas-image-data"; import type { ImageSplitParams } from "@/lib/canvas/canvas-image-data";
@@ -13,6 +14,7 @@ const maxGridSize = 12;
type ActiveLine = { axis: "horizontal" | "vertical"; index: number } | null; type ActiveLine = { axis: "horizontal" | "vertical"; index: number } | null;
export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageSplitParams) => void }) { export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageSplitParams) => void }) {
const { t } = useTranslation();
const [params, setParams] = useState(defaultParams); const [params, setParams] = useState(defaultParams);
const [image, setImage] = useState<{ width: number; height: number } | null>(null); const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const [active, setActive] = useState<ActiveLine>(null); const [active, setActive] = useState<ActiveLine>(null);
@@ -149,9 +151,9 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden transitionName="" maskTransitionName=""> <Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden transitionName="" maskTransitionName="">
<div className="space-y-5" data-canvas-no-zoom> <div className="space-y-5" data-canvas-no-zoom>
<div> <div>
<h2 className="text-xl font-semibold"></h2> <h2 className="text-xl font-semibold">{t("canvas.editors.splitTitle")}</h2>
<p className="mt-1 text-sm opacity-60"> {total} </p> <p className="mt-1 text-sm opacity-60">{t("canvas.editors.splitDescription", { count: total })}</p>
<p className="mt-2 text-xs leading-5 opacity-55"> · + · Delete 线 · Ctrl/Cmd+Z · Ctrl/Cmd+Shift+Z </p> <p className="mt-2 text-xs leading-5 opacity-55">{t("canvas.editors.splitHint")}</p>
</div> </div>
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_280px]"> <div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_280px]">
<div className="rounded-xl border p-4"> <div className="rounded-xl border p-4">
@@ -171,54 +173,54 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
</div> </div>
<div className="mt-3 flex items-center justify-between text-sm"> <div className="mt-3 flex items-center justify-between text-sm">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Tooltip title="撤回切图调整 (Ctrl/Cmd+Z)"> <Tooltip title={t("canvas.editors.undoSplitTitle")}>
<Button type="text" icon={<Undo2 className="size-4" />} disabled={!historySize} aria-label="撤回切图调整" onClick={undoSplit} /> <Button type="text" icon={<Undo2 className="size-4" />} disabled={!historySize} aria-label={t("canvas.editors.undoSplit")} onClick={undoSplit} />
</Tooltip> </Tooltip>
<Tooltip title="重做切图调整 (Ctrl/Cmd+Shift+Z)"> <Tooltip title={t("canvas.editors.redoSplitTitle")}>
<Button type="text" icon={<Redo2 className="size-4" />} disabled={!redoSize} aria-label="重做切图调整" onClick={redoSplit} /> <Button type="text" icon={<Redo2 className="size-4" />} disabled={!redoSize} aria-label={t("canvas.editors.redoSplit")} onClick={redoSplit} />
</Tooltip> </Tooltip>
<Tooltip title="缩小"> <Tooltip title={t("canvas.editors.zoomOut")}>
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label="缩小" onClick={viewport.zoomOut} /> <Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label={t("canvas.editors.zoomOut")} onClick={viewport.zoomOut} />
</Tooltip> </Tooltip>
<button type="button" className="min-w-14 text-center text-xs font-semibold tabular-nums opacity-70" onClick={viewport.resetZoom}> <button type="button" className="min-w-14 text-center text-xs font-semibold tabular-nums opacity-70" onClick={viewport.resetZoom}>
{Math.round(viewport.zoom * 100)}% {Math.round(viewport.zoom * 100)}%
</button> </button>
<Tooltip title="放大"> <Tooltip title={t("canvas.editors.zoomIn")}>
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label="放大" onClick={viewport.zoomIn} /> <Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label={t("canvas.editors.zoomIn")} onClick={viewport.zoomIn} />
</Tooltip> </Tooltip>
</div> </div>
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : "读取中"}</span> <span className="font-semibold">{image ? `${image.width} x ${image.height} px` : t("canvas.editors.loading")}</span>
</div> </div>
</div> </div>
<div className="space-y-5 py-2"> <div className="space-y-5 py-2">
<NumberField label="行数" value={rows} onChange={(value) => update("rows", value)} /> <NumberField label={t("canvas.editors.rows")} value={rows} onChange={(value) => update("rows", value)} />
<NumberField label="列数" value={columns} onChange={(value) => update("columns", value)} /> <NumberField label={t("canvas.editors.columns")} value={columns} onChange={(value) => update("columns", value)} />
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<Button icon={<Rows3 className="size-4" />} onClick={() => addLine("horizontal")}> <Button icon={<Rows3 className="size-4" />} onClick={() => addLine("horizontal")}>
线 {t("canvas.editors.horizontalLine")}
</Button> </Button>
<Button icon={<PanelTop className="size-4 rotate-90" />} onClick={() => addLine("vertical")}> <Button icon={<PanelTop className="size-4 rotate-90" />} onClick={() => addLine("vertical")}>
线 {t("canvas.editors.verticalLine")}
</Button> </Button>
<Button icon={<Trash2 className="size-4" />} disabled={!active} onClick={deleteLine}> <Button icon={<Trash2 className="size-4" />} disabled={!active} onClick={deleteLine}>
线 {t("canvas.editors.deleteLine")}
</Button> </Button>
<Button icon={<ListRestart className="size-4" />} onClick={resetLines}> <Button icon={<ListRestart className="size-4" />} onClick={resetLines}>
线 {t("canvas.editors.resetLines")}
</Button> </Button>
</div> </div>
<div className="rounded-xl border px-4 py-3 text-sm"> <div className="rounded-xl border px-4 py-3 text-sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="opacity-60"></span> <span className="opacity-60">{t("canvas.editors.pieceCount")}</span>
<span className="font-semibold">{total} </span> <span className="font-semibold">{t("canvas.editors.pieces", { count: total })}</span>
</div> </div>
<div className="mt-2 flex items-center justify-between"> <div className="mt-2 flex items-center justify-between">
<span className="opacity-60"></span> <span className="opacity-60">{t("canvas.editors.averageSize")}</span>
<span className="font-semibold">{pieceSize ? `${pieceSize.width} x ${pieceSize.height}` : "未知"}</span> <span className="font-semibold">{pieceSize ? `${pieceSize.width} x ${pieceSize.height}` : t("canvas.editors.unknown")}</span>
</div> </div>
</div> </div>
<Button type="primary" size="large" className="w-full" icon={<Grid2x2 className="size-4" />} onClick={() => onConfirm(confirmParams)}> <Button type="primary" size="large" className="w-full" icon={<Grid2x2 className="size-4" />} onClick={() => onConfirm(confirmParams)}>
{t("canvas.editors.generateChildren")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1,17 +1,14 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Button, Modal, Segmented } from "antd"; import { Button, Modal, Segmented } from "antd";
import { ImagePlus } from "lucide-react"; import { ImagePlus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { readImageMeta } from "@/lib/image-utils"; import { readImageMeta } from "@/lib/image-utils";
import { MAX_UPSCALE_LONG_EDGE, resolveUpscaleSize, type ImageUpscaleAlgorithm, type ImageUpscaleParams } from "@/lib/canvas/canvas-image-data"; import { MAX_UPSCALE_LONG_EDGE, resolveUpscaleSize, type ImageUpscaleAlgorithm, type ImageUpscaleParams } from "@/lib/canvas/canvas-image-data";
export type CanvasImageUpscaleParams = ImageUpscaleParams; export type CanvasImageUpscaleParams = ImageUpscaleParams;
const algorithms: Array<{ value: ImageUpscaleAlgorithm; title: string; description: string }> = [ const algorithms: ImageUpscaleAlgorithm[] = ["high", "bilinear", "nearest"];
{ value: "high", title: "高清插值", description: "适合照片和细节图" },
{ value: "bilinear", title: "双线性", description: "平滑、速度快" },
{ value: "nearest", title: "最近邻", description: "适合像素风格" },
];
const targetOptions = [ const targetOptions = [
{ label: "1K", value: 1024 }, { label: "1K", value: 1024 },
@@ -25,6 +22,7 @@ const defaultParams: CanvasImageUpscaleParams = {
}; };
export function CanvasNodeUpscaleDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageUpscaleParams) => void }) { export function CanvasNodeUpscaleDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageUpscaleParams) => void }) {
const { t } = useTranslation();
const [params, setParams] = useState<CanvasImageUpscaleParams>(defaultParams); const [params, setParams] = useState<CanvasImageUpscaleParams>(defaultParams);
const [image, setImage] = useState<{ width: number; height: number } | null>(null); const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const sourceLongEdge = image ? Math.max(image.width, image.height) : 0; const sourceLongEdge = image ? Math.max(image.width, image.height) : 0;
@@ -53,7 +51,7 @@ export function CanvasNodeUpscaleDialog({ dataUrl, open, onClose, onConfirm }: {
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={820} centered destroyOnHidden> <Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={820} centered destroyOnHidden>
<div className="space-y-5"> <div className="space-y-5">
<div> <div>
<h2 className="text-xl font-semibold"></h2> <h2 className="text-xl font-semibold">{t("canvas.editors.upscaleTitle")}</h2>
</div> </div>
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_360px]"> <div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_360px]">
<div className="rounded-xl border p-4"> <div className="rounded-xl border p-4">
@@ -61,32 +59,32 @@ export function CanvasNodeUpscaleDialog({ dataUrl, open, onClose, onConfirm }: {
<img src={dataUrl} alt="" className="max-h-[320px] max-w-full rounded-lg object-contain shadow-xl" draggable={false} /> <img src={dataUrl} alt="" className="max-h-[320px] max-w-full rounded-lg object-contain shadow-xl" draggable={false} />
</div> </div>
<div className="mt-3 flex items-center justify-between text-sm"> <div className="mt-3 flex items-center justify-between text-sm">
<span className="opacity-60"></span> <span className="opacity-60">{t("canvas.editors.source")}</span>
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : "读取中"}</span> <span className="font-semibold">{image ? `${image.width} x ${image.height} px` : t("canvas.editors.loading")}</span>
</div> </div>
</div> </div>
<div className="space-y-6 py-2"> <div className="space-y-6 py-2">
<div className="space-y-2"> <div className="space-y-2">
<div className="font-medium opacity-75"></div> <div className="font-medium opacity-75">{t("canvas.editors.targetPixels")}</div>
<Segmented <Segmented
block block
value={params.targetLongEdge} value={params.targetLongEdge}
options={targetOptions.map((option) => ({ label: `${option.label} · ${option.value}px`, value: option.value, disabled: Boolean(image && sourceLongEdge >= option.value) }))} options={targetOptions.map((option) => ({ label: `${option.label} · ${option.value}px`, value: option.value, disabled: Boolean(image && sourceLongEdge >= option.value) }))}
onChange={(value) => setParams((current) => ({ ...current, targetLongEdge: Number(value) }))} onChange={(value) => setParams((current) => ({ ...current, targetLongEdge: Number(value) }))}
/> />
{image && !canUpscale ? <div className="text-xs font-medium text-[#ef4444]">{reachedMax ? "图片已达到 4K,无需放大" : "图片已达到当前目标像素,无需放大"}</div> : null} {image && !canUpscale ? <div className="text-xs font-medium text-[#ef4444]">{reachedMax ? t("canvas.editors.maxReached") : t("canvas.editors.targetReached")}</div> : null}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="font-medium opacity-75"></div> <div className="font-medium opacity-75">{t("canvas.editors.algorithm")}</div>
<Segmented <Segmented
block block
value={params.algorithm} value={params.algorithm}
options={algorithms.map((item) => ({ options={algorithms.map((algorithm) => ({
value: item.value, value: algorithm,
label: ( label: (
<span className="flex min-h-12 flex-col justify-center text-left leading-5"> <span className="flex min-h-12 flex-col justify-center text-left leading-5">
<span className="font-medium">{item.title}</span> <span className="font-medium">{t(`canvas.editors.${algorithm}`)}</span>
<span className="text-xs opacity-55">{item.description}</span> <span className="text-xs opacity-55">{t(`canvas.editors.${algorithm}Description`)}</span>
</span> </span>
), ),
}))} }))}
@@ -95,15 +93,15 @@ export function CanvasNodeUpscaleDialog({ dataUrl, open, onClose, onConfirm }: {
</div> </div>
<div className="rounded-xl border px-4 py-3 text-sm"> <div className="rounded-xl border px-4 py-3 text-sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="opacity-60"></span> <span className="opacity-60">{t("canvas.editors.outputSize")}</span>
<span className="font-semibold">{outputSize ? `${outputSize.width} x ${outputSize.height} px` : "未知"}</span> <span className="font-semibold">{outputSize ? `${outputSize.width} x ${outputSize.height} px` : t("canvas.editors.unknown")}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="primary" size="large" icon={<ImagePlus className="size-4" />} disabled={!canUpscale} onClick={() => onConfirm(params)}> <Button type="primary" size="large" icon={<ImagePlus className="size-4" />} disabled={!canUpscale} onClick={() => onConfirm(params)}>
{t("canvas.editors.upscale")}
</Button> </Button>
</div> </div>
</div> </div>
+32 -21
View File
@@ -11,6 +11,7 @@ import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textare
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas"; import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin"; import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { useTranslation } from "react-i18next";
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
const selectionBlue = "#2f80ff"; const selectionBlue = "#2f80ff";
@@ -120,6 +121,7 @@ export const CanvasNode = React.memo(function CanvasNode({
onContextMenu, onContextMenu,
}: CanvasNodeProps) { }: CanvasNodeProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { t } = useTranslation();
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const definition = getNodeDefinition(data.type); const definition = getNodeDefinition(data.type);
const pluginContext = useMemo<CanvasNodeContext | null>(() => (pluginHost ? buildNodeContext(pluginHost, data, theme, scale, isSelected) : null), [pluginHost, data, theme, scale, isSelected]); const pluginContext = useMemo<CanvasNodeContext | null>(() => (pluginHost ? buildNodeContext(pluginHost, data, theme, scale, isSelected) : null), [pluginHost, data, theme, scale, isSelected]);
@@ -167,11 +169,11 @@ export const CanvasNode = React.memo(function CanvasNode({
}, [isEditingTitle]); }, [isEditingTitle]);
const finishTitleEditing = useCallback(() => { const finishTitleEditing = useCallback(() => {
const title = titleDraft.trim() || data.title || "未命名节点"; const title = titleDraft.trim() || data.title || t("canvas.node.untitled");
setTitleDraft(title); setTitleDraft(title);
setIsEditingTitle(false); setIsEditingTitle(false);
if (title !== data.title) onTitleChange(data.id, title); if (title !== data.title) onTitleChange(data.id, title);
}, [data.id, data.title, onTitleChange, titleDraft]); }, [data.id, data.title, onTitleChange, t, titleDraft]);
useEffect(() => { useEffect(() => {
if (!isEditingTitle) return; if (!isEditingTitle) return;
@@ -342,13 +344,13 @@ export const CanvasNode = React.memo(function CanvasNode({
type="button" type="button"
className="block max-w-full truncate border-b border-dashed border-transparent px-0 py-0.5 text-left text-xs font-medium opacity-75 transition hover:border-current hover:opacity-100" className="block max-w-full truncate border-b border-dashed border-transparent px-0 py-0.5 text-left text-xs font-medium opacity-75 transition hover:border-current hover:opacity-100"
style={{ color: theme.node.text }} style={{ color: theme.node.text }}
title="双击修改节点名称" title={t("canvas.node.renameHint")}
onDoubleClick={(event) => { onDoubleClick={(event) => {
event.stopPropagation(); event.stopPropagation();
setIsEditingTitle(true); setIsEditingTitle(true);
}} }}
> >
{data.title || "未命名节点"} {data.title || t("canvas.node.untitled")}
</button> </button>
)} )}
</div> </div>
@@ -466,15 +468,16 @@ const nodeContentRenderers = {
} satisfies Record<CanvasNodeType, (props: NodeContentRendererProps) => ReactNode>; } satisfies Record<CanvasNodeType, (props: NodeContentRendererProps) => ReactNode>;
function GroupNodeContent({ node, theme, groupChildCount }: NodeContentRendererProps) { function GroupNodeContent({ node, theme, groupChildCount }: NodeContentRendererProps) {
const { t } = useTranslation();
return ( return (
<div className="pointer-events-none flex h-full w-full flex-col p-4"> <div className="pointer-events-none flex h-full w-full flex-col p-4">
<div className="flex items-center gap-2 text-sm font-semibold" style={{ color: theme.node.text }}> <div className="flex items-center gap-2 text-sm font-semibold" style={{ color: theme.node.text }}>
<span className="grid size-8 place-items-center rounded-xl" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}> <span className="grid size-8 place-items-center rounded-xl" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
<Group className="size-4" /> <Group className="size-4" />
</span> </span>
<span></span> <span>{t("canvas.node.group")}</span>
<span className="ml-auto rounded-full px-2 py-1 text-[11px] font-medium" style={{ background: theme.node.fill, color: theme.node.muted }}> <span className="ml-auto rounded-full px-2 py-1 text-[11px] font-medium" style={{ background: theme.node.fill, color: theme.node.muted }}>
{groupChildCount} {t("canvas.node.nodeCount", { count: groupChildCount })}
</span> </span>
</div> </div>
<div className="mt-3 flex-1 rounded-2xl border border-dashed" style={{ borderColor: theme.node.stroke, background: `${theme.node.fill}55` }} /> <div className="mt-3 flex-1 rounded-2xl border border-dashed" style={{ borderColor: theme.node.stroke, background: `${theme.node.fill}55` }} />
@@ -483,18 +486,20 @@ function GroupNodeContent({ node, theme, groupChildCount }: NodeContentRendererP
} }
function LoadingContent({ theme }: Pick<NodeContentRendererProps, "theme">) { function LoadingContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
const { t } = useTranslation();
return ( return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.activeStroke }}> <div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.activeStroke }}>
<div className="size-10 animate-spin rounded-full border-2" style={{ borderColor: theme.node.stroke, borderTopColor: theme.node.activeStroke }} /> <div className="size-10 animate-spin rounded-full border-2" style={{ borderColor: theme.node.stroke, borderTopColor: theme.node.activeStroke }} />
<span className="text-[10px] tracking-[0.2em]"></span> <span className="text-[10px] tracking-[0.2em]">{t("canvas.node.generating")}</span>
</div> </div>
); );
} }
function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "node" | "theme" | "onRetry">) { function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "node" | "theme" | "onRetry">) {
const { t } = useTranslation();
return ( return (
<div className="flex max-w-[260px] flex-col items-center gap-3 px-5 text-center"> <div className="flex max-w-[260px] flex-col items-center gap-3 px-5 text-center">
<div className="text-xs leading-5 text-red-300">{node.metadata?.errorDetails || "生成失败"}</div> <div className="text-xs leading-5 text-red-300">{node.metadata?.errorDetails || t("canvas.node.failed")}</div>
<button <button
type="button" type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-xs font-medium transition hover:scale-[1.02]" className="inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-xs font-medium transition hover:scale-[1.02]"
@@ -506,23 +511,25 @@ function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "
onMouseDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}
> >
<RefreshCw className="size-3.5" /> <RefreshCw className="size-3.5" />
{t("canvas.node.retry")}
</button> </button>
</div> </div>
); );
} }
function MissingPluginContent({ theme, type }: Pick<NodeContentRendererProps, "theme"> & { type: string }) { function MissingPluginContent({ theme, type }: Pick<NodeContentRendererProps, "theme"> & { type: string }) {
const { t } = useTranslation();
return ( return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.placeholder }}> <div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.placeholder }}>
<Puzzle className="size-7 opacity-40" /> <Puzzle className="size-7 opacity-40" />
<span className="text-sm"></span> <span className="text-sm">{t("canvas.node.missingPlugin")}</span>
<span className="text-[11px] opacity-70"> {type} </span> <span className="text-[11px] opacity-70">{t("canvas.node.missingPluginDescription", { type })}</span>
</div> </div>
); );
} }
function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing, onGenerateImage }: NodeContentRendererProps) { function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing, onGenerateImage }: NodeContentRendererProps) {
const { t } = useTranslation();
const fontSize = node.metadata?.fontSize || 14; const fontSize = node.metadata?.fontSize || 14;
const textStyle = { fontSize: `${fontSize}px`, lineHeight: `${Math.round(fontSize * 1.65)}px`, color: theme.node.text, boxSizing: "border-box" } as React.CSSProperties; const textStyle = { fontSize: `${fontSize}px`, lineHeight: `${Math.round(fontSize * 1.65)}px`, color: theme.node.text, boxSizing: "border-box" } as React.CSSProperties;
@@ -538,11 +545,11 @@ function TextContent({ node, theme, isEditingContent, textareaRef, mentionRefere
}} }}
onMouseDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}
title="用文本生图" title={t("canvas.node.generateImage")}
aria-label="用文本生图" aria-label={t("canvas.node.generateImage")}
> >
<ImageIcon className="size-3.5" /> <ImageIcon className="size-3.5" />
{t("canvas.node.generate")}
</button> </button>
{isEditingContent ? ( {isEditingContent ? (
<CanvasResourceMentionTextarea <CanvasResourceMentionTextarea
@@ -567,7 +574,7 @@ function TextContent({ node, theme, isEditingContent, textareaRef, mentionRefere
style={textStyle} style={textStyle}
onWheel={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()}
> >
{node.metadata?.content || <span style={{ color: theme.node.placeholder }}></span>} {node.metadata?.content || <span style={{ color: theme.node.placeholder }}>{t("canvas.node.editText")}</span>}
</div> </div>
)} )}
</div> </div>
@@ -607,12 +614,13 @@ function ImageNodeContent(props: NodeContentRendererProps) {
} }
function EmptyImageContent({ theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: NodeContentRendererProps) { function EmptyImageContent({ theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: NodeContentRendererProps) {
const { t } = useTranslation();
const content = ( const content = (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}> <div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}>
<div className="flex size-14 items-center justify-center rounded-2xl" style={{ background: theme.toolbar.activeBg }}> <div className="flex size-14 items-center justify-center rounded-2xl" style={{ background: theme.toolbar.activeBg }}>
<ImageIcon className="size-6 opacity-30" /> <ImageIcon className="size-6 opacity-30" />
</div> </div>
<span className="text-[10px] tracking-[0.18em] opacity-50"></span> <span className="text-[10px] tracking-[0.18em] opacity-50">{t("canvas.node.emptyImage")}</span>
</div> </div>
); );
if (isBatchRoot) if (isBatchRoot)
@@ -625,29 +633,31 @@ function EmptyImageContent({ theme, isBatchRoot, batchCount, batchExpanded, batc
} }
function VideoNodeContent({ node, theme }: NodeContentRendererProps) { function VideoNodeContent({ node, theme }: NodeContentRendererProps) {
const { t } = useTranslation();
if (!node.metadata?.content) if (!node.metadata?.content)
return ( return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}> <div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}>
<Video className="size-7 opacity-35" /> <Video className="size-7 opacity-35" />
<span className="text-sm"></span> <span className="text-sm">{t("canvas.node.emptyVideo")}</span>
</div> </div>
); );
return <video src={node.metadata.content} controls className="h-full w-full rounded-[18px] bg-black object-contain" data-canvas-no-zoom />; return <video src={node.metadata.content} controls className="h-full w-full rounded-[18px] bg-black object-contain" data-canvas-no-zoom />;
} }
function AudioNodeContent({ node, theme }: NodeContentRendererProps) { function AudioNodeContent({ node, theme }: NodeContentRendererProps) {
const { t } = useTranslation();
if (!node.metadata?.content) if (!node.metadata?.content)
return ( return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2" style={{ color: theme.node.placeholder }}> <div className="flex h-full w-full flex-col items-center justify-center gap-2" style={{ color: theme.node.placeholder }}>
<Music2 className="size-7 opacity-35" /> <Music2 className="size-7 opacity-35" />
<span className="text-sm"></span> <span className="text-sm">{t("canvas.node.emptyAudio")}</span>
</div> </div>
); );
return ( return (
<div className="flex h-full w-full flex-col justify-center gap-3 px-4" style={{ background: theme.node.fill, color: theme.node.text }}> <div className="flex h-full w-full flex-col justify-center gap-3 px-4" style={{ background: theme.node.fill, color: theme.node.text }}>
<div className="flex min-w-0 items-center gap-2 text-sm opacity-70"> <div className="flex min-w-0 items-center gap-2 text-sm opacity-70">
<Music2 className="size-4 shrink-0" /> <Music2 className="size-4 shrink-0" />
<span className="truncate"></span> <span className="truncate">{t("canvas.node.audio")}</span>
</div> </div>
<audio src={node.metadata.content} controls className="w-full" data-canvas-no-zoom /> <audio src={node.metadata.content} controls className="w-full" data-canvas-no-zoom />
</div> </div>
@@ -674,6 +684,7 @@ function ImageContent({
onSetBatchPrimary?: () => void; onSetBatchPrimary?: () => void;
}) { }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { t } = useTranslation();
const isBatchChild = Boolean(node.metadata?.batchRootId); const isBatchChild = Boolean(node.metadata?.batchRootId);
return ( return (
@@ -692,7 +703,7 @@ function ImageContent({
type="button" type="button"
className="absolute right-2.5 top-2.5 z-30 flex h-8 items-center justify-center gap-1 rounded-full border px-2.5 text-xs font-semibold shadow-[0_6px_18px_rgba(15,23,42,.10)] backdrop-blur-md transition hover:scale-[1.02]" className="absolute right-2.5 top-2.5 z-30 flex h-8 items-center justify-center gap-1 rounded-full border px-2.5 text-xs font-semibold shadow-[0_6px_18px_rgba(15,23,42,.10)] backdrop-blur-md transition hover:scale-[1.02]"
style={{ background: `${theme.toolbar.panel}d9`, borderColor: `${theme.toolbar.border}cc`, color: theme.node.text }} style={{ background: `${theme.toolbar.panel}d9`, borderColor: `${theme.toolbar.border}cc`, color: theme.node.text }}
aria-label={batchExpanded ? "图片组已展开" : "图片组已收起"} aria-label={batchExpanded ? t("canvas.node.batchExpanded") : t("canvas.node.batchCollapsed")}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
onToggleBatch?.(); onToggleBatch?.();
@@ -717,7 +728,7 @@ function ImageContent({
onPointerDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}
> >
<Star className="size-3.5 text-[#2f80ff]" /> <Star className="size-3.5 text-[#2f80ff]" />
{t("canvas.node.setPrimary")}
</button> </button>
) : null} ) : null}
</BatchFrame> </BatchFrame>
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { App, Button, Input, Modal, Popconfirm, Switch, Tabs } from "antd"; import { App, Button, Input, Modal, Popconfirm, Switch, Tabs } from "antd";
import { AlertTriangle, Download, Puzzle, RefreshCw, Trash2 } from "lucide-react"; import { AlertTriangle, Download, Puzzle, RefreshCw, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { installPluginFromUrl, setPluginEnabled, uninstallPlugin, updatePlugin } from "@/lib/canvas/plugin-loader"; import { installPluginFromUrl, setPluginEnabled, uninstallPlugin, updatePlugin } from "@/lib/canvas/plugin-loader";
@@ -9,6 +10,7 @@ import { useThemeStore } from "@/stores/use-theme-store";
import { usePluginStore, type InstalledPlugin } from "@/stores/canvas/use-plugin-store"; import { usePluginStore, type InstalledPlugin } from "@/stores/canvas/use-plugin-store";
export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) { export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { message } = App.useApp(); const { message } = App.useApp();
const plugins = usePluginStore((state) => state.plugins); const plugins = usePluginStore((state) => state.plugins);
@@ -47,10 +49,10 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
setInstalling(true); setInstalling(true);
try { try {
const plugin = await installPluginFromUrl(target); const plugin = await installPluginFromUrl(target);
message.success(`已安装插件 ${plugin.name}`); message.success(t("canvas.plugins.installedPlugin", { name: plugin.name }));
setUrl(""); setUrl("");
} catch (error) { } catch (error) {
message.error(`安装失败:${error instanceof Error ? error.message : String(error)}`); message.error(t("canvas.plugins.installFailed", { error: error instanceof Error ? error.message : String(error) }));
} finally { } finally {
setInstalling(false); setInstalling(false);
} }
@@ -60,9 +62,9 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
setBusyId(entry.id); setBusyId(entry.id);
try { try {
const plugin = await installPluginFromUrl(entry.url, { official: true }); const plugin = await installPluginFromUrl(entry.url, { official: true });
message.success(`已安装 ${plugin.name}`); message.success(t("canvas.plugins.installed", { name: plugin.name }));
} catch (error) { } catch (error) {
message.error(`安装失败:${error instanceof Error ? error.message : String(error)}`); message.error(t("canvas.plugins.installFailed", { error: error instanceof Error ? error.message : String(error) }));
} finally { } finally {
setBusyId(null); setBusyId(null);
} }
@@ -84,7 +86,7 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
// upgradable=true 时(远程有更高版本),更新按钮高亮为主色以提示升级 // upgradable=true 时(远程有更高版本),更新按钮高亮为主色以提示升级
const installedControls = (record: InstalledPlugin, upgradable = false) => ( const installedControls = (record: InstalledPlugin, upgradable = false) => (
<> <>
<Switch size="small" checked={record.enabled} loading={busyId === record.id} onChange={(checked) => runOnPlugin(record, () => setPluginEnabled(record, checked), checked ? "已启用" : "已禁用")} /> <Switch size="small" checked={record.enabled} loading={busyId === record.id} onChange={(checked) => runOnPlugin(record, () => setPluginEnabled(record, checked), t(checked ? "canvas.plugins.enabled" : "canvas.plugins.disabled"))} />
{!record.local && ( {!record.local && (
<> <>
<Button <Button
@@ -92,11 +94,11 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
size="small" size="small"
icon={<RefreshCw className="size-4" />} icon={<RefreshCw className="size-4" />}
loading={busyId === record.id} loading={busyId === record.id}
title={upgradable ? "有新版本,点击升级" : "从来源更新"} title={t(upgradable ? "canvas.plugins.upgradeAvailable" : "canvas.plugins.updateFromSource")}
onClick={() => runOnPlugin(record, async () => void (await updatePlugin(record)), "已更新")} onClick={() => runOnPlugin(record, async () => void (await updatePlugin(record)), t("canvas.plugins.updated"))}
/> />
<Popconfirm title="卸载该插件?" okText="卸载" cancelText="取消" onConfirm={() => uninstallPlugin(record.id)}> <Popconfirm title={t("canvas.plugins.uninstallTitle")} okText={t("canvas.plugins.uninstall")} cancelText={t("canvas.editors.cancel")} onConfirm={() => uninstallPlugin(record.id)}>
<Button type="text" size="small" danger icon={<Trash2 className="size-4" />} title="卸载" /> <Button type="text" size="small" danger icon={<Trash2 className="size-4" />} title={t("canvas.plugins.uninstall")} />
</Popconfirm> </Popconfirm>
</> </>
)} )}
@@ -108,7 +110,7 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
const withUpgradeDot = (icon: ReactNode) => ( const withUpgradeDot = (icon: ReactNode) => (
<span className="relative inline-flex"> <span className="relative inline-flex">
{icon} {icon}
<span className="absolute -right-1 -top-1 size-2 rounded-full" style={{ background: "#22c55e", boxShadow: `0 0 0 2px ${theme.node.fill}` }} title="有新版本可升级" /> <span className="absolute -right-1 -top-1 size-2 rounded-full" style={{ background: "#22c55e", boxShadow: `0 0 0 2px ${theme.node.fill}` }} title={t("canvas.plugins.newVersion")} />
</span> </span>
); );
@@ -149,20 +151,20 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-xs" style={{ color: theme.node.muted }}> <div className="text-xs" style={{ color: theme.node.muted }}>
, {t("canvas.plugins.officialDescription")}
</div> </div>
<Button type="text" size="small" icon={<RefreshCw className={`size-4 ${loadingOfficial ? "animate-spin" : ""}`} />} onClick={loadOfficial} disabled={loadingOfficial}> <Button type="text" size="small" icon={<RefreshCw className={`size-4 ${loadingOfficial ? "animate-spin" : ""}`} />} onClick={loadOfficial} disabled={loadingOfficial}>
{t("canvas.plugins.refresh")}
</Button> </Button>
</div> </div>
{officialError ? ( {officialError ? (
<div className="rounded-lg border px-3 py-2 text-xs" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}> <div className="rounded-lg border px-3 py-2 text-xs" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
{officialError} {t("canvas.plugins.loadFailed", { error: officialError })}
</div> </div>
) : loadingOfficial && official.length === 0 ? ( ) : loadingOfficial && official.length === 0 ? (
emptyHint("正在获取官方插件…") emptyHint(t("canvas.plugins.loadingOfficial"))
) : official.length === 0 ? ( ) : official.length === 0 ? (
emptyHint("暂无官方插件") emptyHint(t("canvas.plugins.noOfficial"))
) : ( ) : (
<div className="thin-scrollbar max-h-[46vh] space-y-2 overflow-auto"> <div className="thin-scrollbar max-h-[46vh] space-y-2 overflow-auto">
{official.map((entry) => { {official.map((entry) => {
@@ -181,7 +183,7 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
installedControls(record, upgradable) installedControls(record, upgradable)
) : ( ) : (
<Button type="primary" size="small" icon={<Download className="size-4" />} loading={busyId === entry.id} onClick={() => handleInstallOfficial(entry)}> <Button type="primary" size="small" icon={<Download className="size-4" />} loading={busyId === entry.id} onClick={() => handleInstallOfficial(entry)}>
{t("canvas.plugins.install")}
</Button> </Button>
), ),
); );
@@ -196,27 +198,27 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
const thirdPartyTab = ( const thirdPartyTab = (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex gap-2"> <div className="flex gap-2">
<Input placeholder="输入插件 JS 文件 URL,例如 https://.../plugin.js" value={url} onChange={(event) => setUrl(event.target.value)} onPressEnter={handleInstallUrl} allowClear /> <Input placeholder={t("canvas.plugins.urlPlaceholder")} value={url} onChange={(event) => setUrl(event.target.value)} onPressEnter={handleInstallUrl} allowClear />
<Button type="primary" loading={installing} onClick={handleInstallUrl} icon={<Puzzle className="size-4" />}> <Button type="primary" loading={installing} onClick={handleInstallUrl} icon={<Puzzle className="size-4" />}>
{t("canvas.plugins.install")}
</Button> </Button>
</div> </div>
<div className="thin-scrollbar max-h-[42vh] space-y-2 overflow-auto">{thirdPartyPlugins.length === 0 ? emptyHint("还没有安装第三方插件") : thirdPartyPlugins.map((record) => row(record.id, <Puzzle className="size-4" />, record.name, record.version, record.description || record.url, installedControls(record)))}</div> <div className="thin-scrollbar max-h-[42vh] space-y-2 overflow-auto">{thirdPartyPlugins.length === 0 ? emptyHint(t("canvas.plugins.noThirdParty")) : thirdPartyPlugins.map((record) => row(record.id, <Puzzle className="size-4" />, record.name, record.version, record.description || record.url, installedControls(record)))}</div>
</div> </div>
); );
const tabs = [ const tabs = [
{ key: "official", label: "官方插件", children: officialTab }, { key: "official", label: t("canvas.plugins.official"), children: officialTab },
...(localPlugins.length > 0 ? [{ key: "local", label: "本地插件", children: localTab }] : []), ...(localPlugins.length > 0 ? [{ key: "local", label: t("canvas.plugins.local"), children: localTab }] : []),
{ key: "third", label: "第三方插件", children: thirdPartyTab }, { key: "third", label: t("canvas.plugins.thirdParty"), children: thirdPartyTab },
]; ];
return ( return (
<Modal title="节点插件" open={open} onCancel={onClose} footer={null} centered width={640}> <Modal title={t("canvas.plugins.title")} open={open} onCancel={onClose} footer={null} centered width={640}>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-start gap-2 rounded-lg border px-3 py-2 text-xs leading-5" style={{ borderColor: "#f59e0b55", background: "#f59e0b14", color: theme.node.text }}> <div className="flex items-start gap-2 rounded-lg border px-3 py-2 text-xs leading-5" style={{ borderColor: "#f59e0b55", background: "#f59e0b14", color: theme.node.text }}>
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-500" /> <AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-500" />
<span>访 AI API Key</span> <span>{t("canvas.plugins.warning")}</span>
</div> </div>
<Tabs defaultActiveKey="official" items={tabs} /> <Tabs defaultActiveKey="official" items={tabs} />
</div> </div>
@@ -1,12 +1,14 @@
import { Check, Download, Pencil, Trash2, X } from "lucide-react"; import { Check, Download, Pencil, Trash2, X } from "lucide-react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { Button, Input } from "antd"; import { Button, Input } from "antd";
import { useTranslation } from "react-i18next";
import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store"; import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store"; import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
import { exportCanvasProjects } from "@/lib/canvas/canvas-export"; import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
export function CanvasProjectCard({ project }: { project: CanvasProject }) { export function CanvasProjectCard({ project }: { project: CanvasProject }) {
const { i18n, t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const renameProject = useCanvasStore((state) => state.renameProject); const renameProject = useCanvasStore((state) => state.renameProject);
@@ -35,7 +37,7 @@ export function CanvasProjectCard({ project }: { project: CanvasProject }) {
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onChange={(event) => toggleSelected(project.id, event.target.checked)} onChange={(event) => toggleSelected(project.id, event.target.checked)}
className="mt-1 size-4 accent-stone-950 dark:accent-stone-100" className="mt-1 size-4 accent-stone-950 dark:accent-stone-100"
aria-label={`选择 ${project.title}`} aria-label={t("canvas.project.select", { name: project.title })}
/> />
{editing ? ( {editing ? (
<Input className="min-w-0" value={editingTitle} onClick={(event) => event.stopPropagation()} onChange={(event) => setEditingTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && saveTitle()} autoFocus /> <Input className="min-w-0" value={editingTitle} onClick={(event) => event.stopPropagation()} onChange={(event) => setEditingTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && saveTitle()} autoFocus />
@@ -50,24 +52,24 @@ export function CanvasProjectCard({ project }: { project: CanvasProject }) {
> >
<h2 className="truncate text-xl font-semibold">{project.title}</h2> <h2 className="truncate text-xl font-semibold">{project.title}</h2>
<p className="mt-3 text-sm leading-6 text-stone-600 dark:text-stone-400"> <p className="mt-3 text-sm leading-6 text-stone-600 dark:text-stone-400">
{project.nodes.length} · {project.connections.length} 线 {t("canvas.project.stats", { nodes: project.nodes.length, connections: project.connections.length })}
</p> </p>
</button> </button>
)} )}
</div> </div>
<div className="mt-8 flex items-end justify-between gap-3"> <div className="mt-8 flex items-end justify-between gap-3">
<p className="text-xs text-stone-500"> {new Date(project.updatedAt).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}</p> <p className="text-xs text-stone-500">{t("canvas.project.updated", { date: new Date(project.updatedAt).toLocaleString(i18n.resolvedLanguage, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) })}</p>
<div className="flex items-center gap-1" onClick={(event) => event.stopPropagation()}> <div className="flex items-center gap-1" onClick={(event) => event.stopPropagation()}>
{editing ? ( {editing ? (
<> <>
<Button type="text" size="small" shape="circle" icon={<Check className="size-4" />} onClick={saveTitle} aria-label="保存名称" /> <Button type="text" size="small" shape="circle" icon={<Check className="size-4" />} onClick={saveTitle} aria-label={t("canvas.project.saveName")} />
<Button type="text" size="small" shape="circle" icon={<X className="size-4" />} onClick={stopEditing} aria-label="取消重命名" /> <Button type="text" size="small" shape="circle" icon={<X className="size-4" />} onClick={stopEditing} aria-label={t("canvas.project.cancelRename")} />
</> </>
) : ( ) : (
<> <>
<Button type="text" size="small" shape="circle" icon={<Download className="size-4" />} onClick={() => void exportCanvasProjects([project], project.title || "无限画布")} aria-label="导出" /> <Button type="text" size="small" shape="circle" icon={<Download className="size-4" />} onClick={() => void exportCanvasProjects([project], project.title || t("canvas.title"))} aria-label={t("canvas.project.export")} />
<Button type="text" size="small" shape="circle" icon={<Pencil className="size-4" />} onClick={() => startEditing(project.id, project.title)} aria-label="重命名" /> <Button type="text" size="small" shape="circle" icon={<Pencil className="size-4" />} onClick={() => startEditing(project.id, project.title)} aria-label={t("canvas.project.rename")} />
<Button type="text" size="small" shape="circle" icon={<Trash2 className="size-4" />} onClick={() => setDeleteIds([project.id])} aria-label="删除" /> <Button type="text" size="small" shape="circle" icon={<Trash2 className="size-4" />} onClick={() => setDeleteIds([project.id])} aria-label={t("canvas.project.delete")} />
</> </>
)} )}
</div> </div>
@@ -4,6 +4,7 @@ import { createPortal } from "react-dom";
import { Image } from "antd"; import { Image } from "antd";
import { FileText, Image as ImageIcon, Music2, Video } from "lucide-react"; import { FileText, Image as ImageIcon, Music2, Video } from "lucide-react";
import i18n from "@/i18n";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { isImeComposing, isPlainEnterKey } from "@/lib/keyboard-event"; import { isImeComposing, isPlainEnterKey } from "@/lib/keyboard-event";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
@@ -192,7 +193,7 @@ export function CanvasPromptChipInput({ value, references, onChange, onSubmit, c
{mention && candidates.length ? ( {mention && candidates.length ? (
<MentionMenu rect={mention.rect} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> <MentionMenu rect={mention.rect} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} />
) : null} ) : null}
{imagePreview ? <Image src={imagePreview} alt="引用图片预览" style={{ display: "none" }} preview={{ visible: true, src: imagePreview, onVisibleChange: (visible) => !visible && setImagePreview(null) }} /> : null} {imagePreview ? <Image src={imagePreview} alt={i18n.t("canvas.composer.imagePreview")} style={{ display: "none" }} preview={{ visible: true, src: imagePreview, onVisibleChange: (visible) => !visible && setImagePreview(null) }} /> : null}
</div> </div>
); );
} }
@@ -1,25 +1,27 @@
import { useState } from "react"; import { useState } from "react";
import { Button, Tooltip } from "antd"; import { Button, Tooltip } from "antd";
import { BookOpen } from "lucide-react"; import { BookOpen } from "lucide-react";
import { useTranslation } from "react-i18next";
import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog"; import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
export function CanvasPromptLibrary({ onSelect }: { onSelect: (prompt: string) => void }) { export function CanvasPromptLibrary({ onSelect }: { onSelect: (prompt: string) => void }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
return ( return (
<> <>
<Tooltip title="提示词库"> <Tooltip title={t("navigation.prompts")}>
<Button <Button
type="text" type="text"
className="!h-8 !w-8 !min-w-8 shrink-0 !rounded-full !bg-transparent !p-0" className="!h-8 !w-8 !min-w-8 shrink-0 !rounded-full !bg-transparent !p-0"
style={{ color: theme.node.text }} style={{ color: theme.node.text }}
icon={<BookOpen className="size-3.5" />} icon={<BookOpen className="size-3.5" />}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
aria-label="提示词库" aria-label={t("navigation.prompts")}
/> />
</Tooltip> </Tooltip>
<PromptSelectDialog open={open} onOpenChange={setOpen} onSelect={onSelect} /> <PromptSelectDialog open={open} onOpenChange={setOpen} onSelect={onSelect} />
+56 -56
View File
@@ -3,6 +3,7 @@ import { App, Empty, Input, Popconfirm, Select, Spin, Tag } from "antd";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { BookOpen, Check, ChevronRight, Download, Eye, FileText, Image as ImageIcon, ListChecks, Music2, Plus, Search, Settings2, Square, Trash2, Type, Video } from "lucide-react"; import { BookOpen, Check, ChevronRight, Download, Eye, FileText, Image as ImageIcon, ListChecks, Music2, Plus, Search, Settings2, Square, Trash2, Type, Video } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useTranslation } from "react-i18next";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme"; import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { exportCanvasNodes } from "@/lib/canvas/canvas-export"; import { exportCanvasNodes } from "@/lib/canvas/canvas-export";
@@ -50,6 +51,7 @@ const STATUS_COLOR: Record<string, string> = {
}; };
export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, onInsertAsset }: Props) { export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, onInsertAsset }: Props) {
const { t } = useTranslation();
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [tab, setTab] = useState<PanelTab>("canvas"); const [tab, setTab] = useState<PanelTab>("canvas");
const width = useCanvasSidePanelStore((state) => state.width); const width = useCanvasSidePanelStore((state) => state.width);
@@ -98,9 +100,9 @@ export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onPreview
data-canvas-no-zoom data-canvas-no-zoom
> >
<div className="flex items-center gap-5 px-4 pt-3.5"> <div className="flex items-center gap-5 px-4 pt-3.5">
<TabButton label="画布" active={tab === "canvas"} theme={theme} onClick={() => setTab("canvas")} /> <TabButton label={t("canvas.sidePanel.canvas")} active={tab === "canvas"} theme={theme} onClick={() => setTab("canvas")} />
<TabButton label="资产" active={tab === "assets"} theme={theme} onClick={() => setTab("assets")} /> <TabButton label={t("canvas.sidePanel.assets")} active={tab === "assets"} theme={theme} onClick={() => setTab("assets")} />
<TabButton label="提示词库" active={tab === "prompts"} theme={theme} onClick={() => setTab("prompts")} /> <TabButton label={t("canvas.sidePanel.prompts")} active={tab === "prompts"} theme={theme} onClick={() => setTab("prompts")} />
</div> </div>
<div className="mt-2 min-h-0 flex-1 overflow-hidden"> <div className="mt-2 min-h-0 flex-1 overflow-hidden">
{tab === "canvas" ? ( {tab === "canvas" ? (
@@ -111,7 +113,7 @@ export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onPreview
<CanvasPromptsTab onInsert={onInsertAsset} theme={theme} /> <CanvasPromptsTab onInsert={onInsertAsset} theme={theme} />
)} )}
</div> </div>
<button type="button" className="absolute inset-y-0 right-0 z-40 w-4 translate-x-1/2 cursor-col-resize" onPointerDown={startResize} aria-label="调整左侧面板宽度" /> <button type="button" className="absolute inset-y-0 right-0 z-40 w-4 translate-x-1/2 cursor-col-resize" onPointerDown={startResize} aria-label={t("canvas.sidePanel.resize")} />
</motion.aside> </motion.aside>
</motion.div> </motion.div>
); );
@@ -130,15 +132,7 @@ function TabButton({ label, active, theme, onClick }: { label: string; active: b
// 画布 Tab —— 列出节点,点击居中放大并选中 // 画布 Tab —— 列出节点,点击居中放大并选中
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const NODE_FILTER_OPTIONS = [ const NODE_FILTER_VALUES = ["all", CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Text, CanvasNodeType.Audio, CanvasNodeType.Config, CanvasNodeType.Group];
{ label: "全部", value: "all" },
{ label: "图片", value: CanvasNodeType.Image },
{ label: "视频", value: CanvasNodeType.Video },
{ label: "文本", value: CanvasNodeType.Text },
{ label: "音频", value: CanvasNodeType.Audio },
{ label: "配置", value: CanvasNodeType.Config },
{ label: "分组", value: CanvasNodeType.Group },
];
function nodePreviewText(node: CanvasNodeData) { function nodePreviewText(node: CanvasNodeData) {
if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt || ""; if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt || "";
@@ -147,6 +141,7 @@ function nodePreviewText(node: CanvasNodeData) {
function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, theme }: { nodes: CanvasNodeData[]; selectedNodeIds: Set<string>; onFocusNode: (nodeId: string) => void; onPreviewNode: (nodeId: string) => void; theme: CanvasTheme }) { function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, theme }: { nodes: CanvasNodeData[]; selectedNodeIds: Set<string>; onFocusNode: (nodeId: string) => void; onPreviewNode: (nodeId: string) => void; theme: CanvasTheme }) {
const { message } = App.useApp(); const { message } = App.useApp();
const { t } = useTranslation();
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all"); const [typeFilter, setTypeFilter] = useState<string>("all");
const [selectMode, setSelectMode] = useState(false); const [selectMode, setSelectMode] = useState(false);
@@ -175,14 +170,14 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, th
const targets = nodes.filter((node) => checked.has(node.id)); const targets = nodes.filter((node) => checked.has(node.id));
if (!targets.length) return; if (!targets.length) return;
setExporting(true); setExporting(true);
const hide = message.loading("正在导出选中元素…", 0); const hide = message.loading(t("canvas.sidePanel.exporting"), 0);
try { try {
await exportCanvasNodes(targets, `画布元素-${targets.length}`); await exportCanvasNodes(targets, t("canvas.sidePanel.exportName", { count: targets.length }));
message.success(`已导出 ${targets.length} 个元素`); message.success(t("canvas.sidePanel.exported", { count: targets.length }));
exitSelect(); exitSelect();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
message.error("导出失败,请重试"); message.error(t("canvas.sidePanel.exportFailed"));
} finally { } finally {
hide(); hide();
setExporting(false); setExporting(false);
@@ -192,7 +187,7 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, th
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex items-center gap-2 px-3 pb-2.5 pt-1"> <div className="flex items-center gap-2 px-3 pb-2.5 pt-1">
<span className="text-xs font-medium opacity-60"></span> <span className="text-xs font-medium opacity-60">{t("canvas.sidePanel.elements")}</span>
{filtered.length ? <span className="text-xs opacity-35">{filtered.length}</span> : null} {filtered.length ? <span className="text-xs opacity-35">{filtered.length}</span> : null}
<button <button
type="button" type="button"
@@ -201,12 +196,12 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, th
style={selectMode ? { color: theme.toolbar.activeText, opacity: 1 } : undefined} style={selectMode ? { color: theme.toolbar.activeText, opacity: 1 } : undefined}
> >
<ListChecks className="size-3.5" /> <ListChecks className="size-3.5" />
{selectMode ? "取消" : "选择"} {selectMode ? t("common.cancel") : t("canvas.sidePanel.select")}
</button> </button>
{selectMode ? null : <Select size="small" variant="borderless" className="w-20" value={typeFilter} onChange={setTypeFilter} options={NODE_FILTER_OPTIONS} />} {selectMode ? null : <Select size="small" variant="borderless" className="w-20" value={typeFilter} onChange={setTypeFilter} options={NODE_FILTER_VALUES.map((value) => ({ value, label: value === "all" ? t("common.all") : t(`canvas.sidePanel.filter.${value}`) }))} />}
</div> </div>
<div className="px-3 pb-2.5"> <div className="px-3 pb-2.5">
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索节点" value={keyword} onChange={(e) => setKeyword(e.target.value)} /> <Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder={t("canvas.sidePanel.searchNodes")} value={keyword} onChange={(e) => setKeyword(e.target.value)} />
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3"> <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{filtered.length ? ( {filtered.length ? (
@@ -218,20 +213,20 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, th
const active = selectMode ? isChecked : selectedNodeIds.has(node.id); const active = selectMode ? isChecked : selectedNodeIds.has(node.id);
return ( return (
<div key={node.id} className={cn("group flex w-full items-center rounded-lg transition", active ? "" : "hover:bg-black/5 dark:hover:bg-white/5")} style={active ? { background: theme.toolbar.activeBg } : undefined}> <div key={node.id} className={cn("group flex w-full items-center rounded-lg transition", active ? "" : "hover:bg-black/5 dark:hover:bg-white/5")} style={active ? { background: theme.toolbar.activeBg } : undefined}>
<button type="button" onClick={() => (selectMode ? toggleChecked(node.id) : onFocusNode(node.id))} className="flex min-w-0 flex-1 items-center gap-3 px-2 py-2 text-left" title={selectMode ? undefined : "定位到节点"}> <button type="button" onClick={() => (selectMode ? toggleChecked(node.id) : onFocusNode(node.id))} className="flex min-w-0 flex-1 items-center gap-3 px-2 py-2 text-left" title={selectMode ? undefined : t("canvas.sidePanel.focusNode")}>
{selectMode ? <CheckMark checked={isChecked} theme={theme} /> : null} {selectMode ? <CheckMark checked={isChecked} theme={theme} /> : null}
<span className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-md"> <span className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-md">
{isImage ? <img src={node.metadata!.content} alt={node.title} className="size-full object-cover" /> : <Icon className="size-5 opacity-60" />} {isImage ? <img src={node.metadata!.content} alt={node.title} className="size-full object-cover" /> : <Icon className="size-5 opacity-60" />}
</span> </span>
<span className="min-w-0 flex-1 space-y-0.5"> <span className="min-w-0 flex-1 space-y-0.5">
<span className="block truncate text-sm font-medium leading-snug">{node.title || getNodeDefinition(node.type)?.title || "未命名节点"}</span> <span className="block truncate text-sm font-medium leading-snug">{node.title || getNodeDefinition(node.type)?.title || t("canvas.node.untitled")}</span>
<span className="block truncate text-xs leading-snug opacity-50">{nodePreviewText(node)}</span> <span className="block truncate text-xs leading-snug opacity-50">{nodePreviewText(node)}</span>
</span> </span>
{node.metadata?.status && node.metadata.status !== "idle" ? <span className="size-1.5 shrink-0 rounded-full" style={{ background: STATUS_COLOR[node.metadata.status] || "transparent" }} /> : null} {node.metadata?.status && node.metadata.status !== "idle" ? <span className="size-1.5 shrink-0 rounded-full" style={{ background: STATUS_COLOR[node.metadata.status] || "transparent" }} /> : null}
</button> </button>
{selectMode || !isImage ? null : ( {selectMode || !isImage ? null : (
<div className="flex shrink-0 flex-col items-center gap-0.5 pr-1.5"> <div className="flex shrink-0 flex-col items-center gap-0.5 pr-1.5">
<button type="button" onClick={() => onPreviewNode(node.id)} className="grid size-7 place-items-center rounded-md opacity-55 transition hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10" aria-label="放大预览" title="放大预览"> <button type="button" onClick={() => onPreviewNode(node.id)} className="grid size-7 place-items-center rounded-md opacity-55 transition hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10" aria-label={t("canvas.sidePanel.preview")} title={t("canvas.sidePanel.preview")}>
<Eye className="size-3.5" /> <Eye className="size-3.5" />
</button> </button>
</div> </div>
@@ -241,15 +236,15 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, th
})} })}
</div> </div>
) : ( ) : (
<div className="pt-16 text-center text-sm opacity-40"></div> <div className="pt-16 text-center text-sm opacity-40">{t("canvas.sidePanel.noNodes")}</div>
)} )}
</div> </div>
{selectMode ? ( {selectMode ? (
<div className="flex items-center gap-2 border-t px-3 py-2.5" style={{ borderColor: theme.toolbar.border }}> <div className="flex items-center gap-2 border-t px-3 py-2.5" style={{ borderColor: theme.toolbar.border }}>
<button type="button" onClick={toggleAll} className="rounded-md px-2 py-1 text-xs font-medium opacity-70 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10"> <button type="button" onClick={toggleAll} className="rounded-md px-2 py-1 text-xs font-medium opacity-70 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10">
{allChecked ? "取消全选" : "全选"} {allChecked ? t("canvas.sidePanel.clearAll") : t("workbench.selectAll")}
</button> </button>
<span className="text-xs opacity-45"> {checked.size}</span> <span className="text-xs opacity-45">{t("canvas.sidePanel.selected", { count: checked.size })}</span>
<button <button
type="button" type="button"
onClick={() => void handleExport()} onClick={() => void handleExport()}
@@ -258,7 +253,7 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, th
style={{ color: theme.node.text }} style={{ color: theme.node.text }}
> >
<Download className="size-3.5" /> <Download className="size-3.5" />
{t("canvas.exportSelected")}
</button> </button>
</div> </div>
) : null} ) : null}
@@ -278,10 +273,10 @@ function CheckMark({ checked, theme }: { checked: boolean; theme: CanvasTheme })
// 资产 Tab —— 按类型折叠分组 + 标签筛选,点击插入画布 // 资产 Tab —— 按类型折叠分组 + 标签筛选,点击插入画布
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const ASSET_GROUPS: { kind: AssetKind; label: string; icon: typeof Square }[] = [ const ASSET_GROUPS: { kind: AssetKind; icon: typeof Square }[] = [
{ kind: "image", label: "图片", icon: ImageIcon }, { kind: "image", icon: ImageIcon },
{ kind: "video", label: "视频", icon: Video }, { kind: "video", icon: Video },
{ kind: "text", label: "文本", icon: FileText }, { kind: "text", icon: FileText },
]; ];
function buildInsertPayload(asset: Asset): InsertAssetPayload { function buildInsertPayload(asset: Asset): InsertAssetPayload {
@@ -292,6 +287,7 @@ function buildInsertPayload(asset: Asset): InsertAssetPayload {
const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) { const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) {
const { message } = App.useApp(); const { message } = App.useApp();
const { t } = useTranslation();
const assets = useAssetStore((state) => state.assets); const assets = useAssetStore((state) => state.assets);
const addAsset = useAssetStore((state) => state.addAsset); const addAsset = useAssetStore((state) => state.addAsset);
const removeAsset = useAssetStore((state) => state.removeAsset); const removeAsset = useAssetStore((state) => state.removeAsset);
@@ -314,25 +310,25 @@ const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onI
const files = Array.from(fileList || []); const files = Array.from(fileList || []);
if (!files.length) return; if (!files.length) return;
setUploading(true); setUploading(true);
const hide = message.loading("正在添加资产…", 0); const hide = message.loading(t("canvas.sidePanel.addingAssets"), 0);
let added = 0; let added = 0;
try { try {
for (const file of files) { for (const file of files) {
if (file.type.startsWith("image/")) { if (file.type.startsWith("image/")) {
const image = await uploadImage(file); const image = await uploadImage(file);
addAsset({ kind: "image", title: file.name || "图片", coverUrl: image.url, tags: [], data: { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType } }); addAsset({ kind: "image", title: file.name || t("assets.kinds.image"), coverUrl: image.url, tags: [], data: { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType } });
added += 1; added += 1;
} else if (file.type.startsWith("video/")) { } else if (file.type.startsWith("video/")) {
const media = await uploadMediaFile(file, "video"); const media = await uploadMediaFile(file, "video");
addAsset({ kind: "video", title: file.name || "视频", coverUrl: "", tags: [], data: { url: media.url, storageKey: media.storageKey, width: media.width || 0, height: media.height || 0, bytes: media.bytes, mimeType: media.mimeType } }); addAsset({ kind: "video", title: file.name || t("assets.kinds.video"), coverUrl: "", tags: [], data: { url: media.url, storageKey: media.storageKey, width: media.width || 0, height: media.height || 0, bytes: media.bytes, mimeType: media.mimeType } });
added += 1; added += 1;
} }
} }
if (added) message.success(`已添加 ${added} 个资产`); if (added) message.success(t("canvas.sidePanel.addedAssets", { count: added }));
else message.warning("仅支持图片或视频文件"); else message.warning(t("canvas.sidePanel.mediaOnly"));
} catch (error) { } catch (error) {
console.error(error); console.error(error);
message.error("添加失败,请重试"); message.error(t("canvas.sidePanel.addFailed"));
} finally { } finally {
hide(); hide();
setUploading(false); setUploading(false);
@@ -343,7 +339,7 @@ const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onI
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex items-center gap-2 px-3 pb-2 pt-1"> <div className="flex items-center gap-2 px-3 pb-2 pt-1">
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索资产" value={keyword} onChange={(e) => setKeyword(e.target.value)} /> <Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder={t("canvas.sidePanel.searchAssets")} value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<button <button
type="button" type="button"
disabled={uploading} disabled={uploading}
@@ -352,14 +348,14 @@ const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onI
style={{ color: theme.node.text }} style={{ color: theme.node.text }}
> >
<Plus className="size-3.5" /> <Plus className="size-3.5" />
{t("canvas.sidePanel.add")}
</button> </button>
<input ref={fileInputRef} type="file" accept="image/*,video/*" multiple className="hidden" onChange={(e) => void handleFiles(e.target.files)} /> <input ref={fileInputRef} type="file" accept="image/*,video/*" multiple className="hidden" onChange={(e) => void handleFiles(e.target.files)} />
</div> </div>
{allTags.length ? ( {allTags.length ? (
<div className="flex flex-wrap gap-1.5 px-3 pb-2"> <div className="flex flex-wrap gap-1.5 px-3 pb-2">
<Tag.CheckableTag checked={tagFilter === "all"} className={cn("prompt-filter-tag", tagFilter === "all" && "is-active")} onChange={() => setTagFilter("all")}> <Tag.CheckableTag checked={tagFilter === "all"} className={cn("prompt-filter-tag", tagFilter === "all" && "is-active")} onChange={() => setTagFilter("all")}>
{t("common.all")}
</Tag.CheckableTag> </Tag.CheckableTag>
{allTags.map((tag) => ( {allTags.map((tag) => (
<Tag.CheckableTag key={tag} checked={tagFilter === tag} className={cn("prompt-filter-tag", tagFilter === tag && "is-active")} onChange={() => setTagFilter((prev) => (prev === tag ? "all" : tag))}> <Tag.CheckableTag key={tag} checked={tagFilter === tag} className={cn("prompt-filter-tag", tagFilter === tag && "is-active")} onChange={() => setTagFilter((prev) => (prev === tag ? "all" : tag))}>
@@ -382,13 +378,13 @@ const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onI
> >
<ChevronRight className={cn("size-3.5 transition-transform", !isCollapsed && "rotate-90")} /> <ChevronRight className={cn("size-3.5 transition-transform", !isCollapsed && "rotate-90")} />
<group.icon className="size-3.5" /> <group.icon className="size-3.5" />
<span>{group.label}</span> <span>{t(`assets.kinds.${group.kind}`)}</span>
<span className="opacity-50">{group.items.length}</span> <span className="opacity-50">{group.items.length}</span>
</button> </button>
{isCollapsed ? null : ( {isCollapsed ? null : (
<div className="grid grid-cols-2 gap-2 px-1 pb-2 pt-1"> <div className="grid grid-cols-2 gap-2 px-1 pb-2 pt-1">
{group.items.map((asset) => ( {group.items.map((asset) => (
<AssetCard key={asset.id} asset={asset} theme={theme} onInsert={() => onInsert(buildInsertPayload(asset))} onRemove={() => (removeAsset(asset.id), message.success("资产已移除"))} /> <AssetCard key={asset.id} asset={asset} theme={theme} onInsert={() => onInsert(buildInsertPayload(asset))} onRemove={() => (removeAsset(asset.id), message.success(t("canvas.sidePanel.assetRemoved")))} />
))} ))}
</div> </div>
)} )}
@@ -397,7 +393,7 @@ const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onI
})} })}
</div> </div>
) : ( ) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无资产" className="pt-16" /> <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("canvas.sidePanel.noAssets")} className="pt-16" />
)} )}
</div> </div>
</div> </div>
@@ -405,6 +401,7 @@ const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onI
}); });
function AssetCard({ asset, theme, onInsert, onRemove }: { asset: Asset; theme: CanvasTheme; onInsert: () => void; onRemove: () => void }) { function AssetCard({ asset, theme, onInsert, onRemove }: { asset: Asset; theme: CanvasTheme; onInsert: () => void; onRemove: () => void }) {
const { t } = useTranslation();
return ( return (
<div className="group relative aspect-square overflow-hidden rounded-xl border transition duration-200 hover:-translate-y-0.5 hover:shadow-lg" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}> <div className="group relative aspect-square overflow-hidden rounded-xl border transition duration-200 hover:-translate-y-0.5 hover:shadow-lg" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}>
<AssetCover asset={asset} /> <AssetCover asset={asset} />
@@ -413,15 +410,15 @@ function AssetCard({ asset, theme, onInsert, onRemove }: { asset: Asset; theme:
type="button" type="button"
onClick={onInsert} onClick={onInsert}
className="grid size-8 place-items-center rounded-full bg-white/90 text-stone-700 shadow-sm backdrop-blur transition hover:bg-white hover:text-stone-900 dark:bg-black/60 dark:text-stone-100 dark:hover:bg-black/80" className="grid size-8 place-items-center rounded-full bg-white/90 text-stone-700 shadow-sm backdrop-blur transition hover:bg-white hover:text-stone-900 dark:bg-black/60 dark:text-stone-100 dark:hover:bg-black/80"
aria-label="插入画布" aria-label={t("canvas.sidePanel.inserted")}
> >
<Plus className="size-4" /> <Plus className="size-4" />
</button> </button>
<Popconfirm title="移除该资产?" okText="移除" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={onRemove}> <Popconfirm title={t("canvas.sidePanel.removeAssetTitle")} okText={t("canvas.sidePanel.remove")} cancelText={t("common.cancel")} okButtonProps={{ danger: true }} onConfirm={onRemove}>
<button <button
type="button" type="button"
className="grid size-8 place-items-center rounded-full bg-white/90 text-stone-700 shadow-sm backdrop-blur transition hover:bg-white hover:text-red-500 dark:bg-black/60 dark:text-stone-100 dark:hover:bg-black/80 dark:hover:text-red-400" className="grid size-8 place-items-center rounded-full bg-white/90 text-stone-700 shadow-sm backdrop-blur transition hover:bg-white hover:text-red-500 dark:bg-black/60 dark:text-stone-100 dark:hover:bg-black/80 dark:hover:text-red-400"
aria-label="移除资产" aria-label={t("canvas.sidePanel.removeAsset")}
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
</button> </button>
@@ -446,6 +443,7 @@ function AssetCover({ asset }: { asset: Asset }) {
const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) { const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) {
const { message } = App.useApp(); const { message } = App.useApp();
const { t } = useTranslation();
const sources = usePromptSourceStore((state) => state.sources); const sources = usePromptSourceStore((state) => state.sources);
const enabledSources = useMemo(() => sources.filter((source) => source.enabled), [sources]); const enabledSources = useMemo(() => sources.filter((source) => source.enabled), [sources]);
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
@@ -455,16 +453,16 @@ const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { o
const copyPrompt = async (prompt: string) => { const copyPrompt = async (prompt: string) => {
try { try {
await navigator.clipboard.writeText(prompt); await navigator.clipboard.writeText(prompt);
message.success("已复制提示词"); message.success(t("canvas.sidePanel.promptCopied"));
} catch { } catch {
message.error("复制失败"); message.error(t("canvas.sidePanel.copyFailed"));
} }
}; };
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="px-3 pb-2.5 pt-1"> <div className="px-3 pb-2.5 pt-1">
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索提示词" value={keyword} onChange={(e) => setKeyword(e.target.value)} /> <Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder={t("canvas.sidePanel.searchPrompts")} value={keyword} onChange={(e) => setKeyword(e.target.value)} />
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3"> <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
<div className="space-y-1"> <div className="space-y-1">
@@ -480,7 +478,7 @@ const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { o
onInsert={onInsert} onInsert={onInsert}
onView={setDetail} onView={setDetail}
/> />
)) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词" className="pt-12" />} )) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("canvas.sidePanel.noPrompts")} className="pt-12" />}
</div> </div>
</div> </div>
<PromptDetailDialog prompt={detail} onClose={() => setDetail(null)} onCopy={(prompt) => void copyPrompt(prompt)} /> <PromptDetailDialog prompt={detail} onClose={() => setDetail(null)} onCopy={(prompt) => void copyPrompt(prompt)} />
@@ -507,6 +505,7 @@ function PromptSourceGroup({
onInsert: (payload: InsertAssetPayload) => void; onInsert: (payload: InsertAssetPayload) => void;
onView: (prompt: Prompt) => void; onView: (prompt: Prompt) => void;
}) { }) {
const { t } = useTranslation();
// 展开过一次即缓存,避免收起后重复请求;搜索命中时也需要拿到数据来计数。 // 展开过一次即缓存,避免收起后重复请求;搜索命中时也需要拿到数据来计数。
const showResults = open || !!keyword.trim(); const showResults = open || !!keyword.trim();
const query = useQuery({ queryKey: ["side-panel-prompts", sourceId], queryFn: () => fetchSourcePrompts(sourceId), enabled: showResults, staleTime: 1000 * 60 * 60 }); const query = useQuery({ queryKey: ["side-panel-prompts", sourceId], queryFn: () => fetchSourcePrompts(sourceId), enabled: showResults, staleTime: 1000 * 60 * 60 });
@@ -536,7 +535,7 @@ function PromptSourceGroup({
</div> </div>
) : query.isError ? ( ) : query.isError ? (
<button type="button" onClick={() => void query.refetch()} className="block w-full py-4 text-center text-xs text-red-500 opacity-80 transition hover:opacity-100"> <button type="button" onClick={() => void query.refetch()} className="block w-full py-4 text-center text-xs text-red-500 opacity-80 transition hover:opacity-100">
, {t("canvas.sidePanel.loadFailedRetry")}
</button> </button>
) : filtered.length ? ( ) : filtered.length ? (
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -545,7 +544,7 @@ function PromptSourceGroup({
))} ))}
</div> </div>
) : ( ) : (
<div className="py-4 text-center text-xs opacity-40">{keyword.trim() ? "无匹配提示词" : "该来源暂无提示词"}</div> <div className="py-4 text-center text-xs opacity-40">{keyword.trim() ? t("canvas.sidePanel.noMatchingPrompts") : t("canvas.sidePanel.sourceEmpty")}</div>
)} )}
</div> </div>
) : null} ) : null}
@@ -554,6 +553,7 @@ function PromptSourceGroup({
} }
function PromptRow({ item, theme, onInsert, onView }: { item: Prompt; theme: CanvasTheme; onInsert: () => void; onView: () => void }) { function PromptRow({ item, theme, onInsert, onView }: { item: Prompt; theme: CanvasTheme; onInsert: () => void; onView: () => void }) {
const { t } = useTranslation();
return ( return (
<div className="group relative flex items-center gap-2.5 rounded-lg px-2 py-2 transition hover:bg-black/5 dark:hover:bg-white/5"> <div className="group relative flex items-center gap-2.5 rounded-lg px-2 py-2 transition hover:bg-black/5 dark:hover:bg-white/5">
{item.coverUrl ? ( {item.coverUrl ? (
@@ -568,7 +568,7 @@ function PromptRow({ item, theme, onInsert, onView }: { item: Prompt; theme: Can
<div className="mt-0.5 truncate text-xs leading-snug opacity-50">{item.prompt}</div> <div className="mt-0.5 truncate text-xs leading-snug opacity-50">{item.prompt}</div>
</button> </button>
<div className="flex shrink-0 flex-col items-center gap-0.5"> <div className="flex shrink-0 flex-col items-center gap-0.5">
<button type="button" onClick={onView} className="grid size-6 place-items-center rounded-md opacity-60 transition hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10" aria-label="查看详情" title="查看详情"> <button type="button" onClick={onView} className="grid size-6 place-items-center rounded-md opacity-60 transition hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10" aria-label={t("canvas.sidePanel.viewDetails")} title={t("canvas.sidePanel.viewDetails")}>
<Eye className="size-3.5" /> <Eye className="size-3.5" />
</button> </button>
<button <button
@@ -576,8 +576,8 @@ function PromptRow({ item, theme, onInsert, onView }: { item: Prompt; theme: Can
onClick={onInsert} onClick={onInsert}
className="grid size-6 place-items-center rounded-md opacity-60 transition hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10" className="grid size-6 place-items-center rounded-md opacity-60 transition hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10"
style={{ color: theme.toolbar.activeText }} style={{ color: theme.toolbar.activeText }}
aria-label="插入画布" aria-label={t("canvas.sidePanel.inserted")}
title="插入画布" title={t("canvas.sidePanel.inserted")}
> >
<Plus className="size-3.5" /> <Plus className="size-3.5" />
</button> </button>
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Select } from "antd"; import { Select } from "antd";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -12,6 +13,7 @@ type CanvasSizePickerProps = {
}; };
export function CanvasSizePicker({ value, className, onChange }: CanvasSizePickerProps) { export function CanvasSizePicker({ value, className, onChange }: CanvasSizePickerProps) {
const { t } = useTranslation();
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -42,7 +44,7 @@ export function CanvasSizePicker({ value, className, onChange }: CanvasSizePicke
className={cn("canvas-compact-control canvas-control-select h-full w-full")} className={cn("canvas-compact-control canvas-control-select h-full w-full")}
value={value || undefined} value={value || undefined}
searchValue={search} searchValue={search}
placeholder="比例" placeholder={t("canvas.controls.ratio")}
options={options} options={options}
popupMatchSelectWidth={false} popupMatchSelectWidth={false}
popupRender={(menu) => ( popupRender={(menu) => (
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Settings2 } from "lucide-react"; import { Settings2 } from "lucide-react";
import { Button } from "antd"; import { Button } from "antd";
import { useTranslation } from "react-i18next";
import { reasoningEffortLabel, TextSettingsPanel } from "@/components/text-settings-panel"; import { reasoningEffortLabel, TextSettingsPanel } from "@/components/text-settings-panel";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
@@ -16,6 +17,7 @@ type CanvasTextSettingsPopoverProps = {
}; };
export function CanvasTextSettingsPopover({ config, onConfigChange, buttonClassName, placement = "topLeft" }: CanvasTextSettingsPopoverProps) { export function CanvasTextSettingsPopover({ config, onConfigChange, buttonClassName, placement = "topLeft" }: CanvasTextSettingsPopoverProps) {
const { t } = useTranslation();
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const buttonRef = useRef<HTMLSpanElement>(null); const buttonRef = useRef<HTMLSpanElement>(null);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
@@ -47,7 +49,7 @@ export function CanvasTextSettingsPopover({ config, onConfigChange, buttonClassN
<> <>
<span ref={buttonRef} className="inline-flex min-w-0"> <span ref={buttonRef} className="inline-flex min-w-0">
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[170px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} onClick={() => setOpen((current) => !current)}> <Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[170px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} onClick={() => setOpen((current) => !current)}>
<span className="truncate"> · {reasoningEffortLabel(config.reasoningEffort)}</span> <span className="truncate">{t("canvas.controls.reasoning")} · {reasoningEffortLabel(config.reasoningEffort)}</span>
</Button> </Button>
</span> </span>
{panel} {panel}
+46 -42
View File
@@ -7,6 +7,7 @@ import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type Ca
import { getNodePluginId, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry"; import { getNodePluginId, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler"; import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import { useTranslation } from "react-i18next";
export function CanvasToolbar({ export function CanvasToolbar({
selectedCount, selectedCount,
@@ -52,6 +53,7 @@ export function CanvasToolbar({
onShowImageInfoChange: (show: boolean) => void; onShowImageInfoChange: (show: boolean) => void;
}) { }) {
const wrapRef = useRef<HTMLDivElement>(null); const wrapRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const colorTheme = useThemeStore((state) => state.theme); const colorTheme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme); const setTheme = useThemeStore((state) => state.setTheme);
@@ -68,7 +70,7 @@ export function CanvasToolbar({
const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" }; const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" };
const hoverStyle = { background: theme.toolbar.itemHover, color: theme.toolbar.activeText }; const hoverStyle = { background: theme.toolbar.itemHover, color: theme.toolbar.activeText };
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText }; const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
const tip = hovered ? toolLabel(hovered) : ""; const tip = hovered ? toolLabel(hovered, t) : "";
// 点击工具栏(含弹出面板)以外的地方,关闭弹出的扩展节点/画布外观面板 // 点击工具栏(含弹出面板)以外的地方,关闭弹出的扩展节点/画布外观面板
useEffect(() => { useEffect(() => {
@@ -87,38 +89,38 @@ export function CanvasToolbar({
<div ref={rootRef} className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}> <div ref={rootRef} className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}>
{tip ? <DockTip label={tip} x={tipX} theme={theme} /> : null} {tip ? <DockTip label={tip} x={tipX} theme={theme} /> : null}
<div ref={wrapRef} className="thin-scrollbar pointer-events-auto flex h-14 max-w-full items-center gap-1 overflow-x-auto rounded-xl border px-2 shadow-lg backdrop-blur [&>*]:shrink-0" style={dockStyle}> <div ref={wrapRef} className="thin-scrollbar pointer-events-auto flex h-14 max-w-full items-center gap-1 overflow-x-auto rounded-xl border px-2 shadow-lg backdrop-blur [&>*]:shrink-0" style={dockStyle}>
<ToolbarButton id="tool-hand" label="移动/选择" active={!selectedCount} hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDeselect}> <ToolbarButton id="tool-hand" label={t("canvas.toolbar.move")} active={!selectedCount} hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDeselect}>
<Hand className="size-4.5" /> <Hand className="size-4.5" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton id="tool-undo" label="撤销" disabled={!canUndo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUndo}> <ToolbarButton id="tool-undo" label={t("canvas.undo")} disabled={!canUndo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUndo}>
<Undo2 className="size-4.5" /> <Undo2 className="size-4.5" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton id="tool-redo" label="重做" disabled={!canRedo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onRedo}> <ToolbarButton id="tool-redo" label={t("canvas.redo")} disabled={!canRedo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onRedo}>
<Redo2 className="size-4.5" /> <Redo2 className="size-4.5" />
</ToolbarButton> </ToolbarButton>
<Divider theme={theme} /> <Divider theme={theme} />
<ToolbarButton id="tool-text" label="文本" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddText}> <ToolbarButton id="tool-text" label={t("canvas.toolbar.text")} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddText}>
<Type className="size-4.5" /> <Type className="size-4.5" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton id="tool-image" label="图片" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddImage}> <ToolbarButton id="tool-image" label={t("canvas.toolbar.image")} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddImage}>
<ImageIcon className="size-4.5" /> <ImageIcon className="size-4.5" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton id="tool-video" label="视频" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddVideo}> <ToolbarButton id="tool-video" label={t("canvas.toolbar.video")} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddVideo}>
<Video className="size-4.5" /> <Video className="size-4.5" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton id="tool-audio" label="音频" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddAudio}> <ToolbarButton id="tool-audio" label={t("canvas.toolbar.audio")} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddAudio}>
<Music2 className="size-4.5" /> <Music2 className="size-4.5" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton id="tool-config" label="生成配置" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddConfig}> <ToolbarButton id="tool-config" label={t("canvas.toolbar.config")} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddConfig}>
<Settings2 className="size-4.5" /> <Settings2 className="size-4.5" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton id="tool-group" label="组" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddGroup}> <ToolbarButton id="tool-group" label={t("canvas.toolbar.group")} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddGroup}>
<Group className="size-4.5" /> <Group className="size-4.5" />
</ToolbarButton> </ToolbarButton>
{extensionDefs.length ? ( {extensionDefs.length ? (
<ToolbarButton <ToolbarButton
id="tool-extensions" id="tool-extensions"
label="扩展节点" label={t("canvas.toolbar.extensions")}
active={extensionsOpen} active={extensionsOpen}
hovered={hovered} hovered={hovered}
activeStyle={activeStyle} activeStyle={activeStyle}
@@ -135,13 +137,13 @@ export function CanvasToolbar({
<Puzzle className="size-4.5" /> <Puzzle className="size-4.5" />
</ToolbarButton> </ToolbarButton>
) : null} ) : null}
<ToolbarButton id="tool-upload" label="上传资产" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}> <ToolbarButton id="tool-upload" label={t("canvas.toolbar.upload")} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
<Upload className="size-4.5" /> <Upload className="size-4.5" />
</ToolbarButton> </ToolbarButton>
<Divider theme={theme} /> <Divider theme={theme} />
<ToolbarButton <ToolbarButton
id="tool-style" id="tool-style"
label="画布外观" label={t("canvas.toolbar.appearance")}
active={appearanceOpen} active={appearanceOpen}
hovered={hovered} hovered={hovered}
activeStyle={activeStyle} activeStyle={activeStyle}
@@ -160,13 +162,13 @@ export function CanvasToolbar({
{selectedCount ? ( {selectedCount ? (
<> <>
<Divider theme={theme} /> <Divider theme={theme} />
<ToolbarButton id="tool-delete" label="删除选中" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDelete} danger> <ToolbarButton id="tool-delete" label={t("canvas.deleteSelected")} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDelete} danger>
<Trash2 className="size-4.5" /> <Trash2 className="size-4.5" />
</ToolbarButton> </ToolbarButton>
</> </>
) : null} ) : null}
<Divider theme={theme} /> <Divider theme={theme} />
<ToolbarButton id="tool-clear" label="清空画布" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onClear} danger> <ToolbarButton id="tool-clear" label={t("canvas.toolbar.clear")} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onClear} danger>
<Eraser className="size-4.5" /> <Eraser className="size-4.5" />
</ToolbarButton> </ToolbarButton>
</div> </div>
@@ -176,7 +178,7 @@ export function CanvasToolbar({
className="thin-scrollbar pointer-events-auto absolute bottom-[72px] z-30 max-h-[50vh] w-[240px] -translate-x-1/2 overflow-y-auto rounded-xl border p-2 shadow-xl backdrop-blur" className="thin-scrollbar pointer-events-auto absolute bottom-[72px] z-30 max-h-[50vh] w-[240px] -translate-x-1/2 overflow-y-auto rounded-xl border p-2 shadow-xl backdrop-blur"
style={{ left: extPanelX || "50%", background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item }} style={{ left: extPanelX || "50%", background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item }}
> >
<div className="px-1.5 pb-1.5 text-[11px] font-medium opacity-50"></div> <div className="px-1.5 pb-1.5 text-[11px] font-medium opacity-50">{t("canvas.toolbar.extensions")}</div>
<div className="grid gap-0.5"> <div className="grid gap-0.5">
{extensionDefs.map((def) => ( {extensionDefs.map((def) => (
<button <button
@@ -206,19 +208,19 @@ export function CanvasToolbar({
className="pointer-events-auto absolute bottom-[72px] z-30 w-[248px] -translate-x-1/2 rounded-xl border p-2.5 shadow-xl backdrop-blur" className="pointer-events-auto absolute bottom-[72px] z-30 w-[248px] -translate-x-1/2 rounded-xl border p-2.5 shadow-xl backdrop-blur"
style={{ left: panelX || "50%", background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item }} style={{ left: panelX || "50%", background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item }}
> >
<div className="px-1 pb-2 text-sm font-medium opacity-65"></div> <div className="px-1 pb-2 text-sm font-medium opacity-65">{t("canvas.toolbar.appearance")}</div>
<div className="px-1 pb-1.5 text-[11px] font-medium opacity-50"></div> <div className="px-1 pb-1.5 text-[11px] font-medium opacity-50">{t("canvas.toolbar.themeMode")}</div>
<div className="grid grid-cols-2 gap-1 rounded-lg p-1" style={{ background: theme.toolbar.itemHover }}> <div className="grid grid-cols-2 gap-1 rounded-lg p-1" style={{ background: theme.toolbar.itemHover }}>
<CanvasThemeButton colorTheme={colorTheme} targetTheme="light" onThemeChange={setTheme}> <CanvasThemeButton colorTheme={colorTheme} targetTheme="light" onThemeChange={setTheme}>
<Sun className="size-4" /> <Sun className="size-4" />
{t("canvas.toolbar.light")}
</CanvasThemeButton> </CanvasThemeButton>
<CanvasThemeButton colorTheme={colorTheme} targetTheme="dark" onThemeChange={setTheme}> <CanvasThemeButton colorTheme={colorTheme} targetTheme="dark" onThemeChange={setTheme}>
<Moon className="size-4" /> <Moon className="size-4" />
{t("canvas.toolbar.dark")}
</CanvasThemeButton> </CanvasThemeButton>
</div> </div>
<div className="mt-3 px-1 pb-1.5 text-[11px] font-medium opacity-50"></div> <div className="mt-3 px-1 pb-1.5 text-[11px] font-medium opacity-50">{t("canvas.toolbar.gridStyle")}</div>
<Segmented <Segmented
className="w-full !p-1 [&_.ant-segmented-group]:!flex [&_.ant-segmented-item]:!min-h-8 [&_.ant-segmented-item]:!flex-1 [&_.ant-segmented-item-label]:!min-h-8 [&_.ant-segmented-item-label]:!leading-8" className="w-full !p-1 [&_.ant-segmented-group]:!flex [&_.ant-segmented-item]:!min-h-8 [&_.ant-segmented-item]:!flex-1 [&_.ant-segmented-item-label]:!min-h-8 [&_.ant-segmented-item-label]:!leading-8"
value={backgroundMode} value={backgroundMode}
@@ -228,7 +230,7 @@ export function CanvasToolbar({
value: "dots", value: "dots",
label: ( label: (
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<CircleDot className="size-4" /> <CircleDot className="size-4" />{t("canvas.toolbar.dots")}
</span> </span>
), ),
}, },
@@ -236,7 +238,7 @@ export function CanvasToolbar({
value: "lines", value: "lines",
label: ( label: (
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<Grid2x2 className="size-4" />线 <Grid2x2 className="size-4" />{t("canvas.toolbar.lines")}
</span> </span>
), ),
}, },
@@ -245,7 +247,7 @@ export function CanvasToolbar({
label: ( label: (
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<Square className="size-4" /> <Square className="size-4" />
{t("canvas.toolbar.blank")}
</span> </span>
), ),
}, },
@@ -254,7 +256,7 @@ export function CanvasToolbar({
<div className="mt-3 flex items-center justify-between gap-3 rounded-lg px-1.5 py-1"> <div className="mt-3 flex items-center justify-between gap-3 rounded-lg px-1.5 py-1">
<span className="inline-flex min-w-0 items-center gap-1.5 text-[11px] font-medium opacity-65"> <span className="inline-flex min-w-0 items-center gap-1.5 text-[11px] font-medium opacity-65">
<Info className="size-3.5" /> <Info className="size-3.5" />
{t("canvas.toolbar.imageInfo")}
</span> </span>
<Switch size="small" checked={showImageInfo} onChange={onShowImageInfoChange} /> <Switch size="small" checked={showImageInfo} onChange={onShowImageInfoChange} />
</div> </div>
@@ -321,6 +323,8 @@ function CanvasThemeButton({ colorTheme, targetTheme, onThemeChange, children }:
const theme = canvasThemes[colorTheme]; const theme = canvasThemes[colorTheme];
const active = colorTheme === targetTheme; const active = colorTheme === targetTheme;
const activeStyle = colorTheme === "light" ? { background: "#111111", color: "#ffffff" } : { background: theme.toolbar.activeBg, color: theme.toolbar.activeText }; const activeStyle = colorTheme === "light" ? { background: "#111111", color: "#ffffff" } : { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
const { t } = useTranslation();
const label = targetTheme === "dark" ? t("topNav.darkTheme") : t("topNav.lightTheme");
return ( return (
<AnimatedThemeToggler <AnimatedThemeToggler
@@ -329,8 +333,8 @@ function CanvasThemeButton({ colorTheme, targetTheme, onThemeChange, children }:
onThemeChange={onThemeChange} onThemeChange={onThemeChange}
className="inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-md px-2 text-sm transition" className="inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-md px-2 text-sm transition"
style={active ? activeStyle : { color: theme.toolbar.item }} style={active ? activeStyle : { color: theme.toolbar.item }}
aria-label={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`} aria-label={label}
title={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`} title={label}
> >
{children} {children}
</AnimatedThemeToggler> </AnimatedThemeToggler>
@@ -345,21 +349,21 @@ function DockTip({ label, x, theme }: { label: string; x: number; theme: CanvasT
); );
} }
function toolLabel(id: string) { function toolLabel(id: string, t: (key: string) => string) {
if (id === "tool-hand") return "移动/选择"; if (id === "tool-hand") return t("canvas.toolbar.move");
if (id === "tool-undo") return "撤销"; if (id === "tool-undo") return t("canvas.undo");
if (id === "tool-redo") return "重做"; if (id === "tool-redo") return t("canvas.redo");
if (id === "tool-text") return "文本"; if (id === "tool-text") return t("canvas.toolbar.text");
if (id === "tool-image") return "图片"; if (id === "tool-image") return t("canvas.toolbar.image");
if (id === "tool-video") return "视频"; if (id === "tool-video") return t("canvas.toolbar.video");
if (id === "tool-audio") return "音频"; if (id === "tool-audio") return t("canvas.toolbar.audio");
if (id === "tool-config") return "生成配置"; if (id === "tool-config") return t("canvas.toolbar.config");
if (id === "tool-group") return "组"; if (id === "tool-group") return t("canvas.toolbar.group");
if (id === "tool-extensions") return "扩展节点"; if (id === "tool-extensions") return t("canvas.toolbar.extensions");
if (id === "tool-upload") return "上传资产"; if (id === "tool-upload") return t("canvas.toolbar.upload");
if (id === "tool-style") return "画布外观"; if (id === "tool-style") return t("canvas.toolbar.appearance");
if (id === "tool-delete") return "删除选中"; if (id === "tool-delete") return t("canvas.deleteSelected");
if (id === "tool-clear") return "清空画布"; if (id === "tool-clear") return t("canvas.toolbar.clear");
return ""; return "";
} }
+32 -29
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { BookOpen, Bot, Download, Home, Images, Menu, PanelLeftClose, PanelLeftOpen, Plus, Redo2, Trash2, Undo2, Upload } from "lucide-react"; import { BookOpen, Bot, Download, Home, Images, Menu, PanelLeftClose, PanelLeftOpen, Plus, Redo2, Trash2, Undo2, Upload } from "lucide-react";
import { Button, Dropdown, Modal, Tooltip } from "antd"; import { Button, Dropdown, Modal, Tooltip } from "antd";
import { useTranslation } from "react-i18next";
import { UserStatusActions } from "@/components/layout/user-status-actions"; import { UserStatusActions } from "@/components/layout/user-status-actions";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
@@ -54,6 +55,7 @@ export function CanvasTopBar({
onToggleAgent: () => void; onToggleAgent: () => void;
}) { }) {
const colorTheme = useThemeStore((state) => state.theme); const colorTheme = useThemeStore((state) => state.theme);
const { t } = useTranslation();
const theme = canvasThemes[colorTheme]; const theme = canvasThemes[colorTheme];
const titleRef = useRef<HTMLDivElement>(null); const titleRef = useRef<HTMLDivElement>(null);
const [shortcutsOpen, setShortcutsOpen] = useState(false); const [shortcutsOpen, setShortcutsOpen] = useState(false);
@@ -73,11 +75,11 @@ export function CanvasTopBar({
<> <>
<div className="pointer-events-none absolute left-0 right-0 top-0 z-50 flex h-16 items-center justify-between pl-1 pr-4"> <div className="pointer-events-none absolute left-0 right-0 top-0 z-50 flex h-16 items-center justify-between pl-1 pr-4">
<div className="pointer-events-auto flex min-w-0 items-center gap-2"> <div className="pointer-events-auto flex min-w-0 items-center gap-2">
<Tooltip title={sidePanelOpen ? "收起面板" : "展开面板"}> <Tooltip title={sidePanelOpen ? t("canvas.collapsePanel") : t("canvas.expandPanel")}>
<button <button
type="button" type="button"
onClick={toggleSidePanel} onClick={toggleSidePanel}
aria-label={sidePanelOpen ? "收起面板" : "展开面板"} aria-label={sidePanelOpen ? t("canvas.collapsePanel") : t("canvas.expandPanel")}
className="grid size-7 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" className="grid size-7 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10"
style={{ color: theme.node.text }} style={{ color: theme.node.text }}
> >
@@ -88,22 +90,22 @@ export function CanvasTopBar({
trigger={["click"]} trigger={["click"]}
menu={{ menu={{
items: [ items: [
{ key: "home", icon: <Home className="size-4" />, label: "主页", onClick: onHome }, { key: "home", icon: <Home className="size-4" />, label: t("canvas.home"), onClick: onHome },
{ key: "docs", icon: <BookOpen className="size-4" />, label: "文档", onClick: () => window.open(DOCS_URL, "_blank", "noopener,noreferrer") }, { key: "docs", icon: <BookOpen className="size-4" />, label: t("canvas.docs"), onClick: () => window.open(DOCS_URL, "_blank", "noopener,noreferrer") },
{ key: "projects", icon: <Images className="size-4" />, label: "我的画布", onClick: onProjects }, { key: "projects", icon: <Images className="size-4" />, label: t("canvas.projects"), onClick: onProjects },
{ type: "divider" }, { type: "divider" },
{ key: "new", icon: <Plus className="size-4" />, label: "新建画布", onClick: onCreateProject }, { key: "new", icon: <Plus className="size-4" />, label: t("canvas.create"), onClick: onCreateProject },
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject }, { key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: t("canvas.deleteCurrent"), onClick: onDeleteProject },
{ type: "divider" }, { type: "divider" },
{ key: "import", icon: <Upload className="size-4" />, label: "导入资产", onClick: onImportImage }, { key: "import", icon: <Upload className="size-4" />, label: t("canvas.importAsset"), onClick: onImportImage },
{ key: "export", icon: <Download className="size-4" />, label: "导出当前画布", onClick: onExportProject }, { key: "export", icon: <Download className="size-4" />, label: t("canvas.exportCurrent"), onClick: onExportProject },
{ type: "divider" }, { type: "divider" },
{ key: "undo", disabled: !canUndo, icon: <Undo2 className="size-4" />, label: <MenuLabel text="撤销" shortcut="⌘ Z" />, onClick: onUndo }, { key: "undo", disabled: !canUndo, icon: <Undo2 className="size-4" />, label: <MenuLabel text={t("canvas.undo")} shortcut="⌘ Z" />, onClick: onUndo },
{ key: "redo", disabled: !canRedo, icon: <Redo2 className="size-4" />, label: <MenuLabel text="重做" shortcut="⌘ ⇧ Z / ⌘ Y" />, onClick: onRedo }, { key: "redo", disabled: !canRedo, icon: <Redo2 className="size-4" />, label: <MenuLabel text={t("canvas.redo")} shortcut="⌘ ⇧ Z / ⌘ Y" />, onClick: onRedo },
], ],
}} }}
> >
<button type="button" className="grid size-7 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: theme.node.text }} aria-label="打开画布菜单"> <button type="button" className="grid size-7 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: theme.node.text }} aria-label={t("canvas.openMenu")}>
<Menu className="size-4" /> <Menu className="size-4" />
</button> </button>
</Dropdown> </Dropdown>
@@ -127,7 +129,7 @@ export function CanvasTopBar({
type="button" type="button"
className="max-w-[280px] truncate border-b border-dashed border-transparent text-left text-lg font-semibold tracking-normal transition hover:border-current" className="max-w-[280px] truncate border-b border-dashed border-transparent text-left text-lg font-semibold tracking-normal transition hover:border-current"
onDoubleClick={onStartTitleEditing} onDoubleClick={onStartTitleEditing}
title="双击修改画布名称" title={t("canvas.renameHint")}
> >
{title} {title}
</button> </button>
@@ -150,21 +152,21 @@ export function CanvasTopBar({
</Button> </Button>
</div> </div>
</div> </div>
<Modal title="快捷键" open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered> <Modal title={t("canvas.shortcuts")} open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
<div className="space-y-2 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}> <div className="space-y-2 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}>
<Shortcut keys={["拖动画布"]} value="平移视图" /> <Shortcut keys={[t("canvas.shortcut.dragCanvas")]} value={t("canvas.shortcut.pan")} />
<Shortcut keys={["滚轮"]} value="缩放画布" /> <Shortcut keys={[t("canvas.shortcut.wheel")]} value={t("canvas.shortcut.zoom")} />
<Shortcut keys={["缩放滑杆"]} value="精确调整缩放" /> <Shortcut keys={[t("canvas.shortcut.zoomSlider")]} value={t("canvas.shortcut.preciseZoom")} />
<Shortcut keys={["Ctrl / Cmd", "拖动"]} value="框选多个节点" /> <Shortcut keys={["Ctrl / Cmd", t("canvas.shortcut.drag")]} value={t("canvas.shortcut.boxSelect")} />
<Shortcut keys={["Shift / Ctrl / Cmd", "点击"]} value="追加选择节点" /> <Shortcut keys={["Shift / Ctrl / Cmd", t("canvas.shortcut.click")]} value={t("canvas.shortcut.addSelection")} />
<Shortcut keys={["Ctrl / Cmd", "A"]} value="全选节点" /> <Shortcut keys={["Ctrl / Cmd", "A"]} value={t("canvas.shortcut.selectAll")} />
<Shortcut keys={["Ctrl / Cmd", "C / V"]} value="复制 / 粘贴节点,或粘贴剪切板文本/图片" /> <Shortcut keys={["Ctrl / Cmd", "C / V"]} value={t("canvas.shortcut.copyPaste")} />
<Shortcut keys={["Ctrl / Cmd", "Z"]} value="撤销" /> <Shortcut keys={["Ctrl / Cmd", "Z"]} value={t("canvas.undo")} />
<Shortcut keys={["Ctrl / Cmd", "Shift", "Z"]} value="重做" /> <Shortcut keys={["Ctrl / Cmd", "Shift", "Z"]} value={t("canvas.redo")} />
<Shortcut keys={["Ctrl / Cmd", "Y"]} value="重做" /> <Shortcut keys={["Ctrl / Cmd", "Y"]} value={t("canvas.redo")} />
<Shortcut keys={["Delete / Backspace"]} value="删除选中" /> <Shortcut keys={["Delete / Backspace"]} value={t("canvas.shortcut.delete")} />
<Shortcut keys={["Esc"]} value="取消选择并关闭浮层" /> <Shortcut keys={["Esc"]} value={t("canvas.shortcut.escape")} />
<Shortcut keys={["拖入图片/视频/音频"]} value="上传到画布" /> <Shortcut keys={[t("canvas.shortcut.dropMedia")]} value={t("canvas.shortcut.upload")} />
</div> </div>
</Modal> </Modal>
</> </>
@@ -183,10 +185,11 @@ function MenuLabel({ text, shortcut }: { text: string; shortcut: string }) {
function CompactAgentStatus({ status, onClick }: { status: { connected: boolean; enabled: boolean; activity: string }; onClick: () => void }) { function CompactAgentStatus({ status, onClick }: { status: { connected: boolean; enabled: boolean; activity: string }; onClick: () => void }) {
const colorTheme = useThemeStore((state) => state.theme); const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme]; const theme = canvasThemes[colorTheme];
const label = status.connected ? "Codex 已连接" : status.enabled ? `Codex ${status.activity || "连接中"}` : "Codex 未连接"; const { t } = useTranslation();
const label = status.connected ? t("canvas.agentConnected") : status.enabled ? t("canvas.agentConnecting", { activity: status.activity || t("canvas.connecting") }) : t("canvas.agentDisconnected");
const dotColor = status.connected ? "#22c55e" : status.enabled ? "#f59e0b" : theme.node.muted; const dotColor = status.connected ? "#22c55e" : status.enabled ? "#f59e0b" : theme.node.muted;
return ( return (
<button type="button" className="flex h-8 items-center gap-1.5 text-xs transition hover:opacity-75" style={{ color: status.connected ? "#16a34a" : status.enabled ? "#d97706" : theme.node.muted }} onClick={onClick} title="打开本地 Codex 面板"> <button type="button" className="flex h-8 items-center gap-1.5 text-xs transition hover:opacity-75" style={{ color: status.connected ? "#16a34a" : status.enabled ? "#d97706" : theme.node.muted }} onClick={onClick} title={t("canvas.openAgent")}>
<span className="size-2 rounded-full" style={{ background: dotColor }} /> <span className="size-2 rounded-full" style={{ background: dotColor }} />
<span className="max-w-[140px] truncate">{label}</span> <span className="max-w-[140px] truncate">{label}</span>
</button> </button>
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { Compass, Focus, HelpCircle } from "lucide-react"; import { Compass, Focus, HelpCircle } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Button, Modal, Tooltip } from "antd"; import { Button, Modal, Tooltip } from "antd";
import { useTranslation } from "react-i18next";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
@@ -16,6 +17,7 @@ type CanvasZoomControlsProps = {
export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpen, onToggleMiniMap }: CanvasZoomControlsProps) { export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpen, onToggleMiniMap }: CanvasZoomControlsProps) {
const [shortcutsOpen, setShortcutsOpen] = useState(false); const [shortcutsOpen, setShortcutsOpen] = useState(false);
const { t } = useTranslation();
const colorTheme = useThemeStore((state) => state.theme); const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme]; const theme = canvasThemes[colorTheme];
const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" }; const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" };
@@ -24,20 +26,20 @@ export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpe
return ( return (
<div className="absolute bottom-5 left-5 z-50" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> <div className="absolute bottom-5 left-5 z-50" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<div className="flex h-14 items-center gap-1 rounded-xl border px-2 shadow-lg backdrop-blur" style={dockStyle}> <div className="flex h-14 items-center gap-1 rounded-xl border px-2 shadow-lg backdrop-blur" style={dockStyle}>
<Tooltip title={isMiniMapOpen ? "关闭小地图" : "打开小地图"}> <Tooltip title={isMiniMapOpen ? t("canvas.miniMapClose") : t("canvas.miniMapOpen")}>
<Button <Button
type="text" type="text"
className="!h-8 !w-8 !min-w-8 !p-0" className="!h-8 !w-8 !min-w-8 !p-0"
style={isMiniMapOpen ? activeStyle : { color: theme.toolbar.item }} style={isMiniMapOpen ? activeStyle : { color: theme.toolbar.item }}
icon={<Compass className="size-4" />} icon={<Compass className="size-4" />}
onClick={onToggleMiniMap} onClick={onToggleMiniMap}
aria-label={isMiniMapOpen ? "关闭小地图" : "打开小地图"} aria-label={isMiniMapOpen ? t("canvas.miniMapClose") : t("canvas.miniMapOpen")}
/> />
</Tooltip> </Tooltip>
<Tooltip title="重置视图"> <Tooltip title={t("canvas.resetView")}>
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={{ color: theme.toolbar.item }} icon={<Focus className="size-4" />} onClick={onReset} aria-label="重置视图" /> <Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={{ color: theme.toolbar.item }} icon={<Focus className="size-4" />} onClick={onReset} aria-label={t("canvas.resetView")} />
</Tooltip> </Tooltip>
<Tooltip title="放大/缩小画布"> <Tooltip title={t("canvas.zoom")}>
<input <input
type="range" type="range"
min="5" min="5"
@@ -47,24 +49,24 @@ export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpe
className="w-24" className="w-24"
style={{ accentColor: theme.node.activeStroke }} style={{ accentColor: theme.node.activeStroke }}
onChange={(event) => onScaleChange(Number(event.target.value) / 100)} onChange={(event) => onScaleChange(Number(event.target.value) / 100)}
aria-label="放大/缩小画布" aria-label={t("canvas.zoom")}
/> />
</Tooltip> </Tooltip>
<span className="w-10 text-right text-xs tabular-nums" style={{ color: theme.node.muted }}> <span className="w-10 text-right text-xs tabular-nums" style={{ color: theme.node.muted }}>
{Math.round(scale * 100)}% {Math.round(scale * 100)}%
</span> </span>
<Tooltip title="快捷键"> <Tooltip title={t("canvas.shortcuts")}>
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={shortcutsOpen ? activeStyle : { color: theme.toolbar.item }} icon={<HelpCircle className="size-4" />} onClick={() => setShortcutsOpen(true)} aria-label="快捷键" /> <Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={shortcutsOpen ? activeStyle : { color: theme.toolbar.item }} icon={<HelpCircle className="size-4" />} onClick={() => setShortcutsOpen(true)} aria-label={t("canvas.shortcuts")} />
</Tooltip> </Tooltip>
</div> </div>
<Modal title="快捷键" open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered> <Modal title={t("canvas.shortcuts")} open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
<div className="space-y-3 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}> <div className="space-y-3 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}>
<Shortcut label="拖动画布" value="平移视图" /> <Shortcut label={t("canvas.shortcut.dragCanvas")} value={t("canvas.shortcut.pan")} />
<Shortcut label="滚轮" value="缩放画布" /> <Shortcut label={t("canvas.shortcut.wheel")} value={t("canvas.shortcut.zoom")} />
<Shortcut label="Ctrl / Cmd + 拖动" value="框选多个节点" /> <Shortcut label={`Ctrl / Cmd + ${t("canvas.shortcut.drag")}`} value={t("canvas.shortcut.boxSelect")} />
<Shortcut label="Shift / Ctrl / Cmd + 点击" value="追加选择节点" /> <Shortcut label={`Shift / Ctrl / Cmd + ${t("canvas.shortcut.click")}`} value={t("canvas.shortcut.addSelection")} />
<Shortcut label="Ctrl / Cmd + C / V" value="复制 / 粘贴节点" /> <Shortcut label="Ctrl / Cmd + C / V" value={t("canvas.shortcut.copyPasteNodes")} />
<Shortcut label="Delete / Backspace" value="删除选中" /> <Shortcut label="Delete / Backspace" value={t("canvas.shortcut.delete")} />
</div> </div>
</Modal> </Modal>
</div> </div>
@@ -1,5 +1,7 @@
import { FileText, Group, Image as ImageIcon, Music2, Settings2, Video } from "lucide-react"; import { FileText, Group, Image as ImageIcon, Music2, Settings2, Video } from "lucide-react";
import i18n from "@/i18n";
import { NODE_SPECS } from "@/constant/canvas"; import { NODE_SPECS } from "@/constant/canvas";
import { registerNodeDefinitions } from "@/lib/canvas/node-registry"; import { registerNodeDefinitions } from "@/lib/canvas/node-registry";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
@@ -18,12 +20,12 @@ function builtinResource(node: CanvasNodeData): CanvasNodeResource | null {
const iconClass = "size-5"; const iconClass = "size-5";
const BUILTIN_DEFINITIONS: CanvasNodeDefinition[] = [ const BUILTIN_DEFINITIONS: CanvasNodeDefinition[] = [
{ type: CanvasNodeType.Text, title: "文本", icon: <FileText className={iconClass} />, minimapColor: undefined, resource: builtinResource }, { type: CanvasNodeType.Text, title: i18n.t("assets.kinds.text"), icon: <FileText className={iconClass} />, minimapColor: undefined, resource: builtinResource },
{ type: CanvasNodeType.Image, title: "图片", icon: <ImageIcon className={iconClass} />, minimapColor: "#10b981", keepAspectRatio: (node: CanvasNodeData) => !node.metadata?.freeResize, resource: builtinResource }, { type: CanvasNodeType.Image, title: i18n.t("assets.kinds.image"), icon: <ImageIcon className={iconClass} />, minimapColor: "#10b981", keepAspectRatio: (node: CanvasNodeData) => !node.metadata?.freeResize, resource: builtinResource },
{ type: CanvasNodeType.Video, title: "视频", icon: <Video className={iconClass} />, minimapColor: "#f97316", keepAspectRatio: () => true, resource: builtinResource }, { type: CanvasNodeType.Video, title: i18n.t("assets.kinds.video"), icon: <Video className={iconClass} />, minimapColor: "#f97316", keepAspectRatio: () => true, resource: builtinResource },
{ type: CanvasNodeType.Audio, title: "音频", icon: <Music2 className={iconClass} />, minimapColor: "#a855f7", resource: builtinResource }, { type: CanvasNodeType.Audio, title: i18n.t("assets.kinds.audio"), icon: <Music2 className={iconClass} />, minimapColor: "#a855f7", resource: builtinResource },
{ type: CanvasNodeType.Config, title: "生成配置", icon: <Settings2 className={iconClass} />, minimapColor: "#60a5fa", hasSourceHandle: false }, { type: CanvasNodeType.Config, title: i18n.t("canvas.configNode.title"), icon: <Settings2 className={iconClass} />, minimapColor: "#60a5fa", hasSourceHandle: false },
{ type: CanvasNodeType.Group, title: "组", icon: <Group className={iconClass} />, minimapColor: "#94a3b8" }, { type: CanvasNodeType.Group, title: i18n.t("canvas.node.group"), icon: <Group className={iconClass} />, minimapColor: "#94a3b8" },
].map((def) => { ].map((def) => {
const spec = NODE_SPECS[def.type]; const spec = NODE_SPECS[def.type];
return { ...def, title: spec.title, defaultSize: { width: spec.width, height: spec.height }, defaultMetadata: spec.metadata }; return { ...def, title: spec.title, defaultSize: { width: spec.width, height: spec.height }, defaultMetadata: spec.metadata };
@@ -1,13 +1,14 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LoaderCircle } from "lucide-react"; import { LoaderCircle } from "lucide-react";
import { useTranslation } from "react-i18next";
import { formatDuration } from "@/lib/image-utils"; import { formatDuration } from "@/lib/image-utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const pendingMessages = ["正在创建图片", "马上就好了", "再等等", "正在整理细节"];
export function ImageGenerationPending({ className, label, compact = false }: { className?: string; label?: string; compact?: boolean }) { export function ImageGenerationPending({ className, label, compact = false }: { className?: string; label?: string; compact?: boolean }) {
const { t } = useTranslation();
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
const pendingMessages = t("generation.pending", { returnObjects: true }) as string[];
useEffect(() => { useEffect(() => {
const timer = window.setInterval(() => setTick((value) => value + 1), 1000); const timer = window.setInterval(() => setTick((value) => value + 1), 1000);
+20 -17
View File
@@ -1,14 +1,16 @@
import { type ReactNode, useState } from "react"; import { type ReactNode, useState } from "react";
import { ConfigProvider, Switch } from "antd"; import { ConfigProvider, Switch } from "antd";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n";
import { type CanvasTheme } from "@/lib/canvas-theme"; import { type CanvasTheme } from "@/lib/canvas-theme";
import type { AiConfig } from "@/stores/use-config-store"; import type { AiConfig } from "@/stores/use-config-store";
const qualityOptions = [ const qualityOptions = [
{ value: "auto", label: "自动" }, { value: "auto", labelKey: "auto" },
{ value: "high", label: "" }, { value: "high", labelKey: "high" },
{ value: "medium", label: "" }, { value: "medium", labelKey: "medium" },
{ value: "low", label: "" }, { value: "low", labelKey: "low" },
]; ];
const DIMENSION_STEP = 16; const DIMENSION_STEP = 16;
@@ -28,7 +30,7 @@ const aspectOptions = [
{ value: "auto", label: "auto", width: 0, height: 0, icon: "auto" }, { value: "auto", label: "auto", width: 0, height: 0, icon: "auto" },
]; ];
export const imageQualityOptions = qualityOptions.map((item) => ({ value: item.value, label: item.label })); export const imageQualityOptions = qualityOptions.map((item) => ({ value: item.value, get label() { return i18n.t(`settingsPanels.common.${item.labelKey}`); } }));
export const imageAspectOptions = aspectOptions.map((item) => ({ value: item.size || item.value, label: item.label })); export const imageAspectOptions = aspectOptions.map((item) => ({ value: item.size || item.value, label: item.label }));
type ImageSettingsPanelProps = { type ImageSettingsPanelProps = {
@@ -42,6 +44,7 @@ type ImageSettingsPanelProps = {
}; };
export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[320px] space-y-4 rounded-2xl px-1 py-0.5", maxCount = 15, quickCount = 10 }: ImageSettingsPanelProps) { export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[320px] space-y-4 rounded-2xl px-1 py-0.5", maxCount = 15, quickCount = 10 }: ImageSettingsPanelProps) {
const { t } = useTranslation();
const [snapDimensionToStep, setSnapDimensionToStep] = useState(true); const [snapDimensionToStep, setSnapDimensionToStep] = useState(true);
const quality = config.quality || "auto"; const quality = config.quality || "auto";
const count = Math.max(1, Math.min(maxCount, Math.floor(Math.abs(Number(config.count)) || 1))); const count = Math.max(1, Math.min(maxCount, Math.floor(Math.abs(Number(config.count)) || 1)));
@@ -71,25 +74,25 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
if (document.activeElement instanceof HTMLInputElement && event.currentTarget.contains(document.activeElement)) document.activeElement.blur(); if (document.activeElement instanceof HTMLInputElement && event.currentTarget.contains(document.activeElement)) document.activeElement.blur();
}} }}
> >
{showTitle ? <div className="text-lg font-semibold"></div> : null} {showTitle ? <div className="text-lg font-semibold">{t("settingsPanels.image.title")}</div> : null}
<div className="space-y-2.5"> <div className="space-y-2.5">
<SettingTitle color={theme.node.muted}></SettingTitle> <SettingTitle color={theme.node.muted}>{t("settingsPanels.image.quality")}</SettingTitle>
<div className="grid grid-cols-4 gap-2.5"> <div className="grid grid-cols-4 gap-2.5">
{qualityOptions.map((item) => ( {qualityOptions.map((item) => (
<OptionPill key={item.value} selected={quality === item.value} theme={theme} onClick={() => onConfigChange("quality", item.value)}> <OptionPill key={item.value} selected={quality === item.value} theme={theme} onClick={() => onConfigChange("quality", item.value)}>
{item.label} {t(`settingsPanels.common.${item.labelKey}`)}
</OptionPill> </OptionPill>
))} ))}
</div> </div>
</div> </div>
<div className="space-y-2.5"> <div className="space-y-2.5">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<SettingTitle color={theme.node.muted}></SettingTitle> <SettingTitle color={theme.node.muted}>{t("settingsPanels.image.size")}</SettingTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-medium" style={{ color: theme.node.muted }}> <span className="text-xs font-medium" style={{ color: theme.node.muted }}>
16 {t("settingsPanels.image.align16")}
</span> </span>
<span title="输入完成后自动向上补成 16 的倍数" onMouseDown={(event) => event.stopPropagation()}> <span title={t("settingsPanels.image.align16Hint")} onMouseDown={(event) => event.stopPropagation()}>
<Switch size="small" checked={snapDimensionToStep} onChange={setSnapDimensionToStep} /> <Switch size="small" checked={snapDimensionToStep} onChange={setSnapDimensionToStep} />
</span> </span>
</div> </div>
@@ -101,7 +104,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
</div> </div>
</div> </div>
<div className="space-y-2.5"> <div className="space-y-2.5">
<SettingTitle color={theme.node.muted}></SettingTitle> <SettingTitle color={theme.node.muted}>{t("settingsPanels.image.aspectRatio")}</SettingTitle>
<div className="grid grid-cols-4 gap-2.5"> <div className="grid grid-cols-4 gap-2.5">
{aspectOptions.map((item) => ( {aspectOptions.map((item) => (
<button <button
@@ -120,9 +123,9 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
</div> </div>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="space-y-0.5"> <div className="space-y-0.5">
<SettingTitle color={theme.node.muted}></SettingTitle> <SettingTitle color={theme.node.muted}>{t("settingsPanels.image.transparent")}</SettingTitle>
<div className="text-xs" style={{ color: theme.node.muted, opacity: 0.75 }}> <div className="text-xs" style={{ color: theme.node.muted, opacity: 0.75 }}>
() {t("settingsPanels.image.transparentHint")}
</div> </div>
</div> </div>
<span onMouseDown={(event) => event.stopPropagation()}> <span onMouseDown={(event) => event.stopPropagation()}>
@@ -130,11 +133,11 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
</span> </span>
</div> </div>
<div className="space-y-2.5"> <div className="space-y-2.5">
<SettingTitle color={theme.node.muted}></SettingTitle> <SettingTitle color={theme.node.muted}>{t("settingsPanels.image.count")}</SettingTitle>
<div className="grid grid-cols-4 gap-2.5"> <div className="grid grid-cols-4 gap-2.5">
{Array.from({ length: quickCount }, (_, index) => index + 1).map((value) => ( {Array.from({ length: quickCount }, (_, index) => index + 1).map((value) => (
<OptionPill key={value} selected={count === value} theme={theme} onClick={() => onConfigChange("count", String(value))}> <OptionPill key={value} selected={count === value} theme={theme} onClick={() => onConfigChange("count", String(value))}>
{value} {t("settingsPanels.image.images", { count: value })}
</OptionPill> </OptionPill>
))} ))}
<CountInput value={count} max={maxCount} theme={theme} onChange={(value) => onConfigChange("count", String(value || 1))} /> <CountInput value={count} max={maxCount} theme={theme} onChange={(value) => onConfigChange("count", String(value || 1))} />
@@ -159,7 +162,7 @@ export function ImageSettingsTheme({ theme, children }: { theme: CanvasTheme; ch
} }
export function imageQualityLabel(value: string) { export function imageQualityLabel(value: string) {
return ({ auto: "自动", high: "高", medium: "中", low: "低" } as Record<string, string>)[value] || value; return (["auto", "high", "medium", "low"].includes(value) ? i18n.t(`settingsPanels.common.${value}`) : value);
} }
export function imageSizeLabel(size: string) { export function imageSizeLabel(size: string) {
+105 -71
View File
@@ -1,10 +1,13 @@
import { App, Button, Form, Input, Modal, Progress, Select, Tabs } from "antd"; import { App, Button, Form, Input, Modal, Progress, Select, Tabs } from "antd";
import type { TFunction } from "i18next";
import { Cloud, Download, Pencil, Plus, RefreshCw, Trash2, Upload, Wifi } from "lucide-react"; import { Cloud, Download, Pencil, Plus, RefreshCw, Trash2, Upload, Wifi } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ModelPicker } from "@/components/model-picker"; import { ModelPicker } from "@/components/model-picker";
import { ChannelEditorDrawer } from "@/components/layout/channel-editor-drawer"; import { ChannelEditorDrawer } from "@/components/layout/channel-editor-drawer";
import { ConfigPromptSources } from "@/components/layout/config-prompt-sources"; import { ConfigPromptSources } from "@/components/layout/config-prompt-sources";
import { changeAppLocale, type AppLocale } from "@/i18n";
import { exportAppConfig, importAppConfig } from "@/services/config-file"; import { exportAppConfig, importAppConfig } from "@/services/config-file";
import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync"; import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync";
import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync"; import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
@@ -14,11 +17,10 @@ import { createModelChannel, modelOptionsFromChannels, normalizeModelOptionValue
type ModelGroup = { type ModelGroup = {
capability: ModelCapability; capability: ModelCapability;
modelKey: "imageModel" | "videoModel" | "textModel" | "audioModel"; modelKey: "imageModel" | "videoModel" | "textModel" | "audioModel";
defaultLabel: string; labelKey: string;
}; };
type WebdavDomainProgress = { type WebdavDomainProgress = {
label: string;
stage: string; stage: string;
current?: number; current?: number;
total?: number; total?: number;
@@ -26,25 +28,18 @@ type WebdavDomainProgress = {
}; };
const modelGroups: ModelGroup[] = [ const modelGroups: ModelGroup[] = [
{ capability: "image", modelKey: "imageModel", defaultLabel: "默认生图模型" }, { capability: "image", modelKey: "imageModel", labelKey: "config.preferences.defaultImageModel" },
{ capability: "video", modelKey: "videoModel", defaultLabel: "默认视频模型" }, { capability: "video", modelKey: "videoModel", labelKey: "config.preferences.defaultVideoModel" },
{ capability: "text", modelKey: "textModel", defaultLabel: "默认文本模型" }, { capability: "text", modelKey: "textModel", labelKey: "config.preferences.defaultTextModel" },
{ capability: "audio", modelKey: "audioModel", defaultLabel: "默认音频模型" }, { capability: "audio", modelKey: "audioModel", labelKey: "config.preferences.defaultAudioModel" },
]; ];
const webdavDomainKeys: AppSyncDomainKey[] = ["canvas", "assets", "image-workbench", "video-workbench"]; const webdavDomainKeys: AppSyncDomainKey[] = ["canvas", "assets", "image-workbench", "video-workbench"];
const webdavDomainLabels: Record<AppSyncDomainKey, string> = {
canvas: "画布",
assets: "我的资产",
"image-workbench": "生图工作台",
"video-workbench": "视频创作台",
};
function createWebdavDomainProgress(): Record<AppSyncDomainKey, WebdavDomainProgress> { function createWebdavDomainProgress(): Record<AppSyncDomainKey, WebdavDomainProgress> {
return webdavDomainKeys.reduce( return webdavDomainKeys.reduce(
(progress, key) => ({ (progress, key) => ({
...progress, ...progress,
[key]: { label: webdavDomainLabels[key], stage: "等待同步" }, [key]: { stage: "等待同步" },
}), }),
{} as Record<AppSyncDomainKey, WebdavDomainProgress>, {} as Record<AppSyncDomainKey, WebdavDomainProgress>,
); );
@@ -52,6 +47,7 @@ function createWebdavDomainProgress(): Record<AppSyncDomainKey, WebdavDomainProg
export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" }: { showDoneButton?: boolean; initialTab?: ConfigTabKey }) { export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" }: { showDoneButton?: boolean; initialTab?: ConfigTabKey }) {
const { message } = App.useApp(); const { message } = App.useApp();
const { i18n, t } = useTranslation();
const configInputRef = useRef<HTMLInputElement>(null); const configInputRef = useRef<HTMLInputElement>(null);
const [activeTab, setActiveTab] = useState<ConfigTabKey>(initialTab); const [activeTab, setActiveTab] = useState<ConfigTabKey>(initialTab);
const [editingChannelId, setEditingChannelId] = useState(""); const [editingChannelId, setEditingChannelId] = useState("");
@@ -68,6 +64,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue); const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue);
const webdavReady = Boolean(webdav.url.trim()); const webdavReady = Boolean(webdav.url.trim());
const editingChannel = config.channels.find((channel) => channel.id === editingChannelId) || null; const editingChannel = config.channels.find((channel) => channel.id === editingChannelId) || null;
const locale = i18n.resolvedLanguage as AppLocale;
useEffect(() => setActiveTab(initialTab), [initialTab]); useEffect(() => setActiveTab(initialTab), [initialTab]);
const saveConfig = (nextConfig: AiConfig) => { const saveConfig = (nextConfig: AiConfig) => {
@@ -78,16 +75,16 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
const ready = config.channels.some((channel) => channel.baseUrl.trim() && channel.apiKey.trim() && channel.models.length); const ready = config.channels.some((channel) => channel.baseUrl.trim() && channel.apiKey.trim() && channel.models.length);
setConfigDialogOpen(false); setConfigDialogOpen(false);
if (!ready) return; if (!ready) return;
message.success(shouldPromptContinue ? "配置已保存,请继续刚才的请求" : "配置已保存"); message.success(t(shouldPromptContinue ? "config.savedContinue" : "config.saved"));
clearPromptContinue(); clearPromptContinue();
}; };
const loadConfigFile = async (file: File) => { const loadConfigFile = async (file: File) => {
try { try {
await importAppConfig(file); await importAppConfig(file);
message.success("配置与用户偏好已导入"); message.success(t("config.imported"));
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : "配置文件读取失败"); message.error(error instanceof Error ? error.message : t("config.importFailed"));
} finally { } finally {
if (configInputRef.current) configInputRef.current.value = ""; if (configInputRef.current) configInputRef.current.value = "";
} }
@@ -96,14 +93,14 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
const updateChannels = (channels: ModelChannel[]) => saveConfig(withChannels(config, channels)); const updateChannels = (channels: ModelChannel[]) => saveConfig(withChannels(config, channels));
const addChannel = () => { const addChannel = () => {
const channel = createModelChannel({ name: `渠道 ${config.channels.length + 1}` }); const channel = createModelChannel({ name: t("config.channels.numberedName", { count: config.channels.length + 1 }) });
updateChannels([...config.channels, channel]); updateChannels([...config.channels, channel]);
setEditingChannelId(channel.id); setEditingChannelId(channel.id);
}; };
const deleteChannel = (id: string) => { const deleteChannel = (id: string) => {
if (config.channels.length <= 1) { if (config.channels.length <= 1) {
message.warning("至少保留一个渠道"); message.warning(t("config.channels.keepOne"));
return; return;
} }
updateChannels(config.channels.filter((channel) => channel.id !== id)); updateChannels(config.channels.filter((channel) => channel.id !== id));
@@ -115,15 +112,15 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
const testWebdav = async () => { const testWebdav = async () => {
if (!webdavReady) { if (!webdavReady) {
message.error("请先填写 WebDAV 地址"); message.error(t("config.webdav.missingUrl"));
return; return;
} }
setTestingWebdav(true); setTestingWebdav(true);
try { try {
await testWebdavConnection(webdav); await testWebdavConnection(webdav);
message.success("WebDAV 连接可用"); message.success(t("config.webdav.available"));
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : "WebDAV 连接测试失败"); message.error(error instanceof Error ? error.message : t("config.webdav.testFailed"));
} finally { } finally {
setTestingWebdav(false); setTestingWebdav(false);
} }
@@ -135,7 +132,6 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
setWebdavDomainProgress((current) => ({ setWebdavDomainProgress((current) => ({
...current, ...current,
[event.domain as AppSyncDomainKey]: { [event.domain as AppSyncDomainKey]: {
label: event.label || webdavDomainLabels[event.domain as AppSyncDomainKey],
stage: event.stage, stage: event.stage,
current: event.current, current: event.current,
total: event.total, total: event.total,
@@ -146,19 +142,19 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
const syncWebdav = async () => { const syncWebdav = async () => {
if (!webdavReady) { if (!webdavReady) {
message.error("请先填写 WebDAV 地址"); message.error(t("config.webdav.missingUrl"));
return; return;
} }
setSyncingWebdav(true); setSyncingWebdav(true);
setWebdavDomainProgress(createWebdavDomainProgress()); setWebdavDomainProgress(createWebdavDomainProgress());
setWebdavSyncStatus("准备同步"); setWebdavSyncStatus(t("config.webdav.preparing"));
try { try {
const result = await syncAppDataToWebdav(webdav, updateWebdavProgress); const result = await syncAppDataToWebdav(webdav, updateWebdavProgress);
updateWebdavConfig("lastSyncedAt", result.syncedAt); updateWebdavConfig("lastSyncedAt", result.syncedAt);
message.success(`同步完成:${result.projects} 个画布,${result.assets} 个资产,${result.imageLogs + result.videoLogs} 条记录,本次上传 ${result.uploadedFiles} 个文件 ${formatBytes(result.uploadedBytes)}`); message.success(t("config.webdav.completed", { projects: result.projects, assets: result.assets, records: result.imageLogs + result.videoLogs, files: result.uploadedFiles, bytes: formatBytes(result.uploadedBytes) }));
} catch (error) { } catch (error) {
setWebdavSyncStatus(error instanceof Error ? error.message : "WebDAV 同步失败"); setWebdavSyncStatus(error instanceof Error ? error.message : t("config.webdav.failed"));
message.error(error instanceof Error ? error.message : "WebDAV 同步失败"); message.error(error instanceof Error ? error.message : t("config.webdav.failed"));
} finally { } finally {
setSyncingWebdav(false); setSyncingWebdav(false);
} }
@@ -167,13 +163,13 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
return ( return (
<> <>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-stone-200 pb-3 dark:border-stone-800"> <div className="flex flex-wrap items-center justify-between gap-3 border-b border-stone-200 pb-3 dark:border-stone-800">
<div className="text-xs text-stone-500">JSON API Key WebDAV </div> <div className="text-xs text-stone-500">{t("config.fileSecurity")}</div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button icon={<Upload className="size-4" />} onClick={() => configInputRef.current?.click()}> <Button icon={<Upload className="size-4" />} onClick={() => configInputRef.current?.click()}>
{t("config.import")}
</Button> </Button>
<Button icon={<Download className="size-4" />} onClick={exportAppConfig}> <Button icon={<Download className="size-4" />} onClick={exportAppConfig}>
{t("config.export")}
</Button> </Button>
<input ref={configInputRef} type="file" accept="application/json,.json" className="hidden" onChange={(event) => event.target.files?.[0] && void loadConfigFile(event.target.files[0])} /> <input ref={configInputRef} type="file" accept="application/json,.json" className="hidden" onChange={(event) => event.target.files?.[0] && void loadConfigFile(event.target.files[0])} />
</div> </div>
@@ -184,27 +180,27 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
items={[ items={[
{ {
key: "channels", key: "channels",
label: "渠道", label: t("config.tabs.channels"),
children: ( children: (
<div> <div>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3"> <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="text-xs text-stone-500">///</div> <div className="text-xs text-stone-500">{t("config.channels.description")}</div>
<Button type="primary" icon={<Plus className="size-4" />} onClick={addChannel}> <Button type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>
{t("config.channels.add")}
</Button> </Button>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{config.channels.map((channel) => ( {config.channels.map((channel) => (
<div key={channel.id} className="flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-4 py-3 dark:border-stone-800"> <div key={channel.id} className="flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-4 py-3 dark:border-stone-800">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold">{channel.name || "未命名渠道"}</div> <div className="truncate text-sm font-semibold">{channel.name || t("config.channels.unnamed")}</div>
<div className="mt-1 truncate text-xs text-stone-500"> <div className="mt-1 truncate text-xs text-stone-500">
{apiFormatLabel(channel.apiFormat)} · {channel.models.length} · {channel.baseUrl || "未填写接口地址"} {apiFormatLabel(channel.apiFormat, t)} · {t("config.channels.modelCount", { count: channel.models.length })} · {channel.baseUrl || t("config.channels.missingUrl")}
</div> </div>
</div> </div>
<div className="flex shrink-0 gap-2"> <div className="flex shrink-0 gap-2">
<Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingChannelId(channel.id)}> <Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingChannelId(channel.id)}>
{t("common.edit")}
</Button> </Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => deleteChannel(channel.id)} /> <Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => deleteChannel(channel.id)} />
</div> </div>
@@ -216,20 +212,31 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
}, },
{ {
key: "preferences", key: "preferences",
label: "偏好设置", label: t("config.tabs.preferences"),
children: ( children: (
<Form layout="vertical" requiredMark={false}> <Form layout="vertical" requiredMark={false}>
<div className="mb-2 text-sm font-semibold"></div> <div className="mb-2 text-sm font-semibold">{t("config.preferences.interface")}</div>
<Form.Item label={t("config.preferences.language")} extra={t("config.preferences.languageDescription")} className="mb-4 max-w-sm">
<Select
value={locale}
options={[
{ label: t("locale.zhCN"), value: "zh-CN" },
{ label: t("locale.enUS"), value: "en-US" },
]}
onChange={(value: AppLocale) => void changeAppLocale(value)}
/>
</Form.Item>
<div className="mb-2 text-sm font-semibold">{t("config.preferences.defaultModels")}</div>
<div className="mb-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="mb-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{modelGroups.map((group) => ( {modelGroups.map((group) => (
<Form.Item key={group.modelKey} label={group.defaultLabel} className="mb-0"> <Form.Item key={group.modelKey} label={t(group.labelKey)} className="mb-0">
<ModelPicker config={config} value={config[group.modelKey]} onChange={(model) => updateConfig(group.modelKey, model)} capability={group.capability} fullWidth /> <ModelPicker config={config} value={config[group.modelKey]} onChange={(model) => updateConfig(group.modelKey, model)} capability={group.capability} fullWidth />
</Form.Item> </Form.Item>
))} ))}
</div> </div>
<div className="mb-2 text-sm font-semibold"></div> <div className="mb-2 text-sm font-semibold">{t("config.preferences.generation")}</div>
<div className="grid gap-4 md:grid-cols-4"> <div className="grid gap-4 md:grid-cols-4">
<Form.Item label="画布默认生图张数" extra="新建画布生图和配置节点默认使用,单个节点仍可单独覆盖。" className="mb-4"> <Form.Item label={t("config.preferences.canvasImageCount")} extra={t("config.preferences.canvasImageCountDescription")} className="mb-4">
<Input <Input
type="number" type="number"
min={1} min={1}
@@ -239,13 +246,13 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
onBlur={(event) => updateConfig("canvasImageCount", normalizeImageCount(event.target.value))} onBlur={(event) => updateConfig("canvasImageCount", normalizeImageCount(event.target.value))}
/> />
</Form.Item> </Form.Item>
<Form.Item label="默认音频声音" className="mb-4"> <Form.Item label={t("config.preferences.audioVoice")} className="mb-4">
<Select value={config.audioVoice} options={audioVoiceOptions} onChange={(value) => updateConfig("audioVoice", value)} /> <Select value={config.audioVoice} options={audioVoiceOptions} onChange={(value) => updateConfig("audioVoice", value)} />
</Form.Item> </Form.Item>
<Form.Item label="默认音频格式" className="mb-4"> <Form.Item label={t("config.preferences.audioFormat")} className="mb-4">
<Select value={config.audioFormat} options={audioFormatOptions} onChange={(value) => updateConfig("audioFormat", value)} /> <Select value={config.audioFormat} options={audioFormatOptions} onChange={(value) => updateConfig("audioFormat", value)} />
</Form.Item> </Form.Item>
<Form.Item label="默认音频语速" className="mb-4"> <Form.Item label={t("config.preferences.audioSpeed")} className="mb-4">
<Input <Input
type="number" type="number"
min={0.25} min={0.25}
@@ -257,18 +264,18 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
/> />
</Form.Item> </Form.Item>
</div> </div>
<Form.Item label="默认音频指令" className="mb-4"> <Form.Item label={t("config.preferences.audioInstructions")} className="mb-4">
<Input.TextArea rows={2} value={config.audioInstructions} placeholder="例如:自然、温暖、适合旁白。" onChange={(event) => updateConfig("audioInstructions", event.target.value)} /> <Input.TextArea rows={2} value={config.audioInstructions} placeholder={t("config.preferences.audioInstructionsPlaceholder")} onChange={(event) => updateConfig("audioInstructions", event.target.value)} />
</Form.Item> </Form.Item>
<Form.Item label="系统提示词" className="mb-0"> <Form.Item label={t("config.preferences.systemPrompt")} className="mb-0">
<Input.TextArea rows={4} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} /> <Input.TextArea rows={4} value={config.systemPrompt} placeholder={t("config.preferences.systemPromptPlaceholder")} onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
</Form.Item> </Form.Item>
</Form> </Form>
), ),
}, },
{ {
key: "prompt-sources", key: "prompt-sources",
label: "提示词来源", label: t("config.tabs.promptSources"),
children: <ConfigPromptSources />, children: <ConfigPromptSources />,
}, },
{ {
@@ -281,36 +288,36 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
<div> <div>
<div className="flex items-center gap-2 text-sm font-semibold"> <div className="flex items-center gap-2 text-sm font-semibold">
<Cloud className="size-4" /> <Cloud className="size-4" />
WebDAV {t("config.webdav.title")}
</div> </div>
<div className="mt-1 text-xs text-stone-500"> AI API Key WebDAV </div> <div className="mt-1 text-xs text-stone-500">{t("config.webdav.description")}</div>
</div> </div>
<div className="text-xs text-stone-500">{webdav.lastSyncedAt ? `上次同步 ${formatWebdavTime(webdav.lastSyncedAt)}` : "尚未同步"}</div> <div className="text-xs text-stone-500">{webdav.lastSyncedAt ? t("config.webdav.lastSynced", { time: formatWebdavTime(webdav.lastSyncedAt, locale) }) : t("config.webdav.neverSynced")}</div>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<Form.Item label="WebDAV 地址" className="mb-4"> <Form.Item label={t("config.webdav.url")} className="mb-4">
<Input value={webdav.url} placeholder="https://nas.example.com/webdav" onChange={(event) => updateWebdavConfig("url", event.target.value)} /> <Input value={webdav.url} placeholder="https://nas.example.com/webdav" onChange={(event) => updateWebdavConfig("url", event.target.value)} />
</Form.Item> </Form.Item>
<Form.Item label="远程目录" extra={`会在该目录下分业务目录保存,每个目录包含 ${WEBDAV_MANIFEST_FILE_NAME} 和 files/`} className="mb-4"> <Form.Item label={t("config.webdav.directory")} extra={t("config.webdav.directoryDescription", { manifest: WEBDAV_MANIFEST_FILE_NAME })} className="mb-4">
<Input value={webdav.directory} placeholder="infinite-canvas" onChange={(event) => updateWebdavConfig("directory", event.target.value)} /> <Input value={webdav.directory} placeholder="infinite-canvas" onChange={(event) => updateWebdavConfig("directory", event.target.value)} />
</Form.Item> </Form.Item>
<Form.Item label="用户名" className="mb-0"> <Form.Item label={t("config.webdav.username")} className="mb-0">
<Input value={webdav.username} autoComplete="username" onChange={(event) => updateWebdavConfig("username", event.target.value)} /> <Input value={webdav.username} autoComplete="username" onChange={(event) => updateWebdavConfig("username", event.target.value)} />
</Form.Item> </Form.Item>
<Form.Item label="密码 / 应用密码" className="mb-0"> <Form.Item label={t("config.webdav.password")} className="mb-0">
<Input.Password value={webdav.password} autoComplete="current-password" onChange={(event) => updateWebdavConfig("password", event.target.value)} /> <Input.Password value={webdav.password} autoComplete="current-password" onChange={(event) => updateWebdavConfig("password", event.target.value)} />
</Form.Item> </Form.Item>
</div> </div>
<div className="mt-4 flex flex-wrap items-center gap-2"> <div className="mt-4 flex flex-wrap items-center gap-2">
<Button icon={<Wifi className="size-4" />} disabled={!webdavReady || syncingWebdav} loading={testingWebdav} onClick={() => void testWebdav()}> <Button icon={<Wifi className="size-4" />} disabled={!webdavReady || syncingWebdav} loading={testingWebdav} onClick={() => void testWebdav()}>
{t("config.webdav.test")}
</Button> </Button>
<Button type="primary" icon={<RefreshCw className="size-4" />} disabled={!webdavReady || testingWebdav} loading={syncingWebdav} onClick={() => void syncWebdav()}> <Button type="primary" icon={<RefreshCw className="size-4" />} disabled={!webdavReady || testingWebdav} loading={syncingWebdav} onClick={() => void syncWebdav()}>
{syncingWebdav ? "同步中" : "立即同步"} {t(syncingWebdav ? "config.webdav.syncing" : "config.webdav.syncNow")}
</Button> </Button>
{webdavSyncStatus ? <span className="text-xs text-stone-500">{webdavSyncStatus}</span> : null} {webdavSyncStatus ? <span className="text-xs text-stone-500">{syncStageLabel(webdavSyncStatus, t)}</span> : null}
</div> </div>
{syncingWebdav || webdavSyncStatus ? <WebdavProgressGrid progress={webdavDomainProgress} /> : null} {syncingWebdav || webdavSyncStatus ? <WebdavProgressGrid progress={webdavDomainProgress} t={t} /> : null}
</section> </section>
</Form> </Form>
), ),
@@ -320,7 +327,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
{showDoneButton ? ( {showDoneButton ? (
<div className="mt-4 flex justify-end"> <div className="mt-4 flex justify-end">
<Button type="primary" onClick={finishConfig}> <Button type="primary" onClick={finishConfig}>
{t("common.done")}
</Button> </Button>
</div> </div>
) : null} ) : null}
@@ -330,6 +337,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
} }
export function AppConfigModal() { export function AppConfigModal() {
const { t } = useTranslation();
const isConfigOpen = useConfigStore((state) => state.isConfigOpen); const isConfigOpen = useConfigStore((state) => state.isConfigOpen);
const configTab = useConfigStore((state) => state.configTab); const configTab = useConfigStore((state) => state.configTab);
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen); const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
@@ -337,8 +345,8 @@ export function AppConfigModal() {
<Modal <Modal
title={ title={
<div> <div>
<div className="text-lg font-semibold"></div> <div className="text-lg font-semibold">{t("config.title")}</div>
<div className="mt-1 text-xs font-normal text-stone-500"></div> <div className="mt-1 text-xs font-normal text-stone-500">{t("config.modalDescription")}</div>
</div> </div>
} }
open={isConfigOpen} open={isConfigOpen}
@@ -381,17 +389,17 @@ function normalizeImageCount(value: string) {
return String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 3)))); return String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 3))));
} }
function apiFormatLabel(apiFormat: ApiCallFormat) { function apiFormatLabel(apiFormat: ApiCallFormat, t: TFunction) {
if (apiFormat === "gemini") return "Gemini"; if (apiFormat === "gemini") return "Gemini";
if (apiFormat === "ark") return "火山方舟"; if (apiFormat === "ark") return t("config.protocols.ark");
return "OpenAI"; return "OpenAI";
} }
function formatWebdavTime(value: string) { function formatWebdavTime(value: string, locale: AppLocale) {
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); return new Date(value).toLocaleString(locale, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
} }
function WebdavProgressGrid({ progress }: { progress: Record<AppSyncDomainKey, WebdavDomainProgress> }) { function WebdavProgressGrid({ progress, t }: { progress: Record<AppSyncDomainKey, WebdavDomainProgress>; t: TFunction }) {
return ( return (
<div className="mt-3 grid gap-2"> <div className="mt-3 grid gap-2">
{webdavDomainKeys.map((key) => { {webdavDomainKeys.map((key) => {
@@ -400,9 +408,9 @@ function WebdavProgressGrid({ progress }: { progress: Record<AppSyncDomainKey, W
return ( return (
<div key={key} className="rounded-md border border-stone-200 px-3 py-2 dark:border-stone-800"> <div key={key} className="rounded-md border border-stone-200 px-3 py-2 dark:border-stone-800">
<div className="mb-1 flex min-w-0 items-center justify-between gap-3 text-xs"> <div className="mb-1 flex min-w-0 items-center justify-between gap-3 text-xs">
<span className="shrink-0 font-medium text-stone-700 dark:text-stone-200">{item.label}</span> <span className="shrink-0 font-medium text-stone-700 dark:text-stone-200">{t(`config.webdav.domains.${domainTranslationKey(key)}`)}</span>
<span className="min-w-0 truncate text-right text-stone-500"> <span className="min-w-0 truncate text-right text-stone-500">
{item.stage} {syncStageLabel(item.stage, t)}
{count ? ` · ${count}` : ""} {count ? ` · ${count}` : ""}
</span> </span>
</div> </div>
@@ -414,6 +422,32 @@ function WebdavProgressGrid({ progress }: { progress: Record<AppSyncDomainKey, W
); );
} }
function domainTranslationKey(domain: AppSyncDomainKey) {
if (domain === "image-workbench") return "imageWorkbench";
if (domain === "video-workbench") return "videoWorkbench";
return domain;
}
function syncStageLabel(stage: string, t: TFunction) {
if (stage === "等待本地数据加载") return t("config.webdav.stages.localWaiting");
if (stage === "同步完成") return t("config.webdav.stages.syncComplete");
if (stage === "等待同步") return t("config.webdav.stages.waiting");
if (stage === "读取远端清单") return t("config.webdav.stages.remoteManifest");
if (stage === "读取本地数据") return t("config.webdav.stages.localData");
if (stage === "下载缺失媒体") return t("config.webdav.stages.downloadMedia");
if (stage === "写入本地合并结果") return t("config.webdav.stages.writeMerge");
if (stage === "上传新增媒体") return t("config.webdav.stages.uploadMedia");
if (stage === "媒体已齐全") return t("config.webdav.stages.mediaReady");
if (stage === "媒体无需上传") return t("config.webdav.stages.mediaSkipped");
if (stage === "检查缺失媒体") return t("config.webdav.stages.checkMissingMedia");
if (stage === "下载媒体") return t("config.webdav.stages.downloadMediaFile");
if (stage === "检查本地媒体") return t("config.webdav.stages.checkLocalMedia");
if (stage.startsWith("上传媒体 ")) return t("config.webdav.stages.uploadMediaFile", { size: stage.slice(5) });
if (stage === "完成") return t("config.webdav.stages.complete");
if (stage.startsWith("上传清单 ")) return t("config.webdav.stages.uploadManifest", { size: stage.slice(5) });
return stage;
}
function getWebdavProgressPercent(item: WebdavDomainProgress) { function getWebdavProgressPercent(item: WebdavDomainProgress) {
if (item.status === "success") return 100; if (item.status === "success") return 100;
if (item.total) return Math.min(100, Math.round(((item.current || 0) / item.total) * 100)); if (item.total) return Math.min(100, Math.round(((item.current || 0) / item.total) * 100));
+16 -2
View File
@@ -3,9 +3,14 @@ import { useEffect } from "react";
import { ProConfigProvider } from "@ant-design/pro-components"; import { ProConfigProvider } from "@ant-design/pro-components";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { App, ConfigProvider } from "antd"; import { App, ConfigProvider } from "antd";
import zhCN from "antd/locale/zh_CN"; import enUS from "antd/es/locale/en_US";
import zhCN from "antd/es/locale/zh_CN";
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
import { useTranslation } from "react-i18next";
import { ClientRootInit } from "@/components/layout/client-root-init"; import { ClientRootInit } from "@/components/layout/client-root-init";
import type { AppLocale } from "@/i18n";
import { getAntThemeConfig } from "@/lib/app-theme"; import { getAntThemeConfig } from "@/lib/app-theme";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
@@ -20,16 +25,25 @@ const queryClient = new QueryClient({
}); });
export function AppProviders({ children }: { children: ReactNode }) { export function AppProviders({ children }: { children: ReactNode }) {
const { i18n, t } = useTranslation();
const theme = useThemeStore((state) => state.theme); const theme = useThemeStore((state) => state.theme);
const dark = theme === "dark"; const dark = theme === "dark";
const locale = i18n.resolvedLanguage as AppLocale;
useEffect(() => { useEffect(() => {
document.documentElement.classList.toggle("dark", dark); document.documentElement.classList.toggle("dark", dark);
document.documentElement.style.colorScheme = theme; document.documentElement.style.colorScheme = theme;
}, [dark, theme]); }, [dark, theme]);
useEffect(() => {
document.documentElement.lang = locale;
document.title = t("meta.title");
document.querySelector('meta[name="description"]')?.setAttribute("content", t("meta.description"));
dayjs.locale(locale === "zh-CN" ? "zh-cn" : "en");
}, [locale, t]);
return ( return (
<ConfigProvider locale={zhCN} theme={getAntThemeConfig(dark)}> <ConfigProvider locale={locale === "zh-CN" ? zhCN : enUS} theme={getAntThemeConfig(dark)}>
<ProConfigProvider dark={dark}> <ProConfigProvider dark={dark}>
<App> <App>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
+8 -6
View File
@@ -1,6 +1,7 @@
import { Bot, Menu } from "lucide-react"; import { Bot, Menu } from "lucide-react";
import { Button, Tooltip } from "antd"; import { Button, Tooltip } from "antd";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools"; import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
import { AppConfigModal } from "@/components/layout/app-config-modal"; import { AppConfigModal } from "@/components/layout/app-config-modal";
@@ -11,6 +12,7 @@ import { useEffect, useRef, useState } from "react";
import { useAgentStore } from "@/stores/use-agent-store"; import { useAgentStore } from "@/stores/use-agent-store";
export function AppTopNav() { export function AppTopNav() {
const { t } = useTranslation();
const { pathname } = useLocation(); const { pathname } = useLocation();
const [mobileNavOpen, setMobileNavOpen] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false);
const autoConnectRef = useRef(false); const autoConnectRef = useRef(false);
@@ -44,15 +46,15 @@ export function AppTopNav() {
WebkitMask: "url(/logo.svg) center / contain no-repeat", WebkitMask: "url(/logo.svg) center / contain no-repeat",
}} }}
/> />
<span className="text-base font-medium"></span> <span className="text-base font-medium">{t("meta.title")}</span>
</Link> </Link>
<button <button
type="button" type="button"
className="ml-3 inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 md:hidden dark:text-stone-300 dark:hover:text-white" className="ml-3 inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 md:hidden dark:text-stone-300 dark:hover:text-white"
onClick={() => setMobileNavOpen(true)} onClick={() => setMobileNavOpen(true)}
aria-label="打开导航菜单" aria-label={t("topNav.openMenu")}
title="导航菜单" title={t("topNav.menu")}
> >
<Menu className="size-5" /> <Menu className="size-5" />
</button> </button>
@@ -73,7 +75,7 @@ export function AppTopNav() {
)} )}
> >
<Icon className="size-4" /> <Icon className="size-4" />
<span className="truncate">{tool.label}</span> <span className="truncate">{t(`navigation.${tool.slug}`)}</span>
</Link> </Link>
); );
})} })}
@@ -81,8 +83,8 @@ export function AppTopNav() {
</div> </div>
<div className="my-auto flex h-9 min-w-0 items-center justify-end gap-2 justify-self-end whitespace-nowrap"> <div className="my-auto flex h-9 min-w-0 items-center justify-end gap-2 justify-self-end whitespace-nowrap">
<Tooltip title={panelOpen ? "收起 Agent" : "打开 Agent"}> <Tooltip title={t(panelOpen ? "topNav.closeAgent" : "topNav.openAgent")}>
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" icon={<Bot className="size-4" />} onClick={togglePanel} aria-label="打开 Agent" /> <Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" icon={<Bot className="size-4" />} onClick={togglePanel} aria-label={t(panelOpen ? "topNav.closeAgent" : "topNav.openAgent")} />
</Tooltip> </Tooltip>
<UserStatusActions /> <UserStatusActions />
</div> </div>
@@ -1,30 +1,25 @@
import { Button, Drawer, Input, Segmented, Select, Space } from "antd"; import { Button, Drawer, Input, Segmented, Select, Space } from "antd";
import { ListPlus, Trash2 } from "lucide-react"; import { ListPlus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { defaultBaseUrlForApiFormat, guessCapability, normalizeChannelModels, type ApiCallFormat, type ChannelModel, type ModelCapability, type ModelChannel } from "@/stores/use-config-store"; import { defaultBaseUrlForApiFormat, guessCapability, normalizeChannelModels, type ApiCallFormat, type ChannelModel, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
import { ModelScriptEditor } from "./model-script-editor"; import { ModelScriptEditor } from "./model-script-editor";
import { ModelSelectModal } from "./model-select-modal"; import { ModelSelectModal } from "./model-select-modal";
const apiFormatOptions: Array<{ label: string; value: ApiCallFormat }> = [
{ label: "OpenAI", value: "openai" },
{ label: "Gemini", value: "gemini" },
{ label: "火山方舟", value: "ark" },
];
const capabilityOptions: Array<{ label: string; value: ModelCapability }> = [
{ label: "生图", value: "image" },
{ label: "视频", value: "video" },
{ label: "文本", value: "text" },
{ label: "音频", value: "audio" },
];
type ScriptTarget = { name: string; capability: ModelCapability; value: string }; type ScriptTarget = { name: string; capability: ModelCapability; value: string };
export function ChannelEditorDrawer({ open, channel, onSave, onClose }: { open: boolean; channel: ModelChannel | null; onSave: (channel: ModelChannel) => void; onClose: () => void }) { export function ChannelEditorDrawer({ open, channel, onSave, onClose }: { open: boolean; channel: ModelChannel | null; onSave: (channel: ModelChannel) => void; onClose: () => void }) {
const { t } = useTranslation();
const [draft, setDraft] = useState<ModelChannel | null>(channel); const [draft, setDraft] = useState<ModelChannel | null>(channel);
const [selectOpen, setSelectOpen] = useState(false); const [selectOpen, setSelectOpen] = useState(false);
const [scriptTarget, setScriptTarget] = useState<ScriptTarget | null>(null); const [scriptTarget, setScriptTarget] = useState<ScriptTarget | null>(null);
const apiFormatOptions: Array<{ label: string; value: ApiCallFormat }> = [
{ label: "OpenAI", value: "openai" },
{ label: "Gemini", value: "gemini" },
{ label: t("config.protocols.ark"), value: "ark" },
];
const capabilityOptions: Array<{ label: string; value: ModelCapability }> = ["image", "video", "text", "audio"].map((value) => ({ label: t(`config.channelEditor.capabilities.${value}`), value: value as ModelCapability }));
useEffect(() => { useEffect(() => {
if (open && channel) setDraft(channel); if (open && channel) setDraft(channel);
@@ -50,7 +45,7 @@ export function ChannelEditorDrawer({ open, channel, onSave, onClose }: { open:
const removeModel = (name: string) => setModels(draft.models.filter((model) => model.name !== name)); const removeModel = (name: string) => setModels(draft.models.filter((model) => model.name !== name));
const save = () => { const save = () => {
onSave({ ...draft, name: draft.name.trim() || "未命名渠道", models: normalizeChannelModels(draft.models) }); onSave({ ...draft, name: draft.name.trim() || t("config.channels.unnamed"), models: normalizeChannelModels(draft.models) });
onClose(); onClose();
}; };
@@ -58,29 +53,29 @@ export function ChannelEditorDrawer({ open, channel, onSave, onClose }: { open:
<Drawer <Drawer
open={open} open={open}
width={640} width={640}
title="编辑渠道" title={t("config.channelEditor.title")}
onClose={onClose} onClose={onClose}
styles={{ body: { paddingTop: 16 } }} styles={{ body: { paddingTop: 16 } }}
extra={ extra={
<Space> <Space>
<Button onClick={onClose}></Button> <Button onClick={onClose}>{t("common.cancel")}</Button>
<Button type="primary" onClick={save}> <Button type="primary" onClick={save}>
{t("common.save")}
</Button> </Button>
</Space> </Space>
} }
> >
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<label className="block"> <label className="block">
<span className="mb-1 block text-sm font-medium"></span> <span className="mb-1 block text-sm font-medium">{t("config.channelEditor.name")}</span>
<Input value={draft.name} onChange={(event) => patch({ name: event.target.value })} /> <Input value={draft.name} onChange={(event) => patch({ name: event.target.value })} />
</label> </label>
<label className="block"> <label className="block">
<span className="mb-1 block text-sm font-medium"></span> <span className="mb-1 block text-sm font-medium">{t("config.channelEditor.protocol")}</span>
<Select className="w-full" value={draft.apiFormat} options={apiFormatOptions} onChange={changeApiFormat} /> <Select className="w-full" value={draft.apiFormat} options={apiFormatOptions} onChange={changeApiFormat} />
</label> </label>
<label className="block md:col-span-2"> <label className="block md:col-span-2">
<span className="mb-1 block text-sm font-medium"></span> <span className="mb-1 block text-sm font-medium">{t("config.channelEditor.baseUrl")}</span>
<Input value={draft.baseUrl} onChange={(event) => patch({ baseUrl: event.target.value })} placeholder="https://api.example.com" /> <Input value={draft.baseUrl} onChange={(event) => patch({ baseUrl: event.target.value })} placeholder="https://api.example.com" />
</label> </label>
<label className="block md:col-span-2"> <label className="block md:col-span-2">
@@ -91,11 +86,11 @@ export function ChannelEditorDrawer({ open, channel, onSave, onClose }: { open:
<div className="mt-6 mb-3 flex flex-wrap items-center justify-between gap-2"> <div className="mt-6 mb-3 flex flex-wrap items-center justify-between gap-2">
<div> <div>
<div className="text-sm font-semibold"></div> <div className="text-sm font-semibold">{t("config.channelEditor.models")}</div>
<div className="mt-0.5 text-xs text-stone-500"> {draft.models.length} </div> <div className="mt-0.5 text-xs text-stone-500">{t("config.channelEditor.modelDescription", { count: draft.models.length })}</div>
</div> </div>
<Button type="primary" icon={<ListPlus className="size-4" />} onClick={() => setSelectOpen(true)}> <Button type="primary" icon={<ListPlus className="size-4" />} onClick={() => setSelectOpen(true)}>
{t("config.channelEditor.selectModels")}
</Button> </Button>
</div> </div>
@@ -109,14 +104,14 @@ export function ChannelEditorDrawer({ open, channel, onSave, onClose }: { open:
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<Segmented size="small" value={model.capability} options={capabilityOptions} onChange={(value) => setCapability(model.name, value as ModelCapability)} /> <Segmented size="small" value={model.capability} options={capabilityOptions} onChange={(value) => setCapability(model.name, value as ModelCapability)} />
<Button size="small" type={model.script ? "primary" : "default"} ghost={Boolean(model.script)} onClick={() => setScriptTarget({ name: model.name, capability: model.capability, value: model.script || "" })}> <Button size="small" type={model.script ? "primary" : "default"} ghost={Boolean(model.script)} onClick={() => setScriptTarget({ name: model.name, capability: model.capability, value: model.script || "" })}>
{model.script ? "脚本已设" : "调用脚本"} {t(model.script ? "config.channelEditor.scriptReady" : "config.channelEditor.script")}
</Button> </Button>
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} onClick={() => removeModel(model.name)} /> <Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} onClick={() => removeModel(model.name)} />
</div> </div>
</div> </div>
)) ))
) : ( ) : (
<div className="px-2 py-8 text-center text-sm text-stone-500"></div> <div className="px-2 py-8 text-center text-sm text-stone-500">{t("config.channelEditor.empty")}</div>
)} )}
</div> </div>
@@ -1,12 +1,14 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { App } from "antd"; import { App } from "antd";
import { useTranslation } from "react-i18next";
import { createModelChannel, useConfigStore } from "@/stores/use-config-store"; import { createModelChannel, useConfigStore } from "@/stores/use-config-store";
import { usePromptSourceScheduler } from "@/hooks/use-prompt-source-scheduler"; import { usePromptSourceScheduler } from "@/hooks/use-prompt-source-scheduler";
export function ClientRootInit({ children }: { children: ReactNode }) { export function ClientRootInit({ children }: { children: ReactNode }) {
const { message } = App.useApp(); const { message } = App.useApp();
const { t } = useTranslation();
const handledConfigParams = useRef(false); const handledConfigParams = useRef(false);
const updateConfig = useConfigStore((state) => state.updateConfig); const updateConfig = useConfigStore((state) => state.updateConfig);
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
@@ -39,13 +41,13 @@ export function ClientRootInit({ children }: { children: ReactNode }) {
} }
: channel, : channel,
) )
: [createModelChannel({ id: "default", name: "默认渠道", baseUrl: baseUrl || undefined, apiKey: apiKey || "" })], : [createModelChannel({ id: "default", name: t("config.channels.defaultName"), baseUrl: baseUrl || undefined, apiKey: apiKey || "" })],
); );
if (baseUrl) updateConfig("baseUrl", baseUrl); if (baseUrl) updateConfig("baseUrl", baseUrl);
if (apiKey) updateConfig("apiKey", apiKey); if (apiKey) updateConfig("apiKey", apiKey);
openConfigDialog(false); openConfigDialog(false);
message.success("已导入本地直连配置"); message.success(t("config.importedDirectConfig"));
}, [config.channels, message, openConfigDialog, updateConfig]); }, [config.channels, message, openConfigDialog, t, updateConfig]);
return <>{children}</>; return <>{children}</>;
} }
@@ -2,17 +2,19 @@ import { App, Button, Select, Switch, Tag } from "antd";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Eye, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { Eye, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { PromptSourceEditorDrawer } from "./prompt-source-editor-drawer"; import { PromptSourceEditorDrawer } from "./prompt-source-editor-drawer";
import { PromptSourceContentModal } from "./prompt-source-content-modal"; import { PromptSourceContentModal } from "./prompt-source-content-modal";
import { fetchPromptSourceStatuses, refreshAllSources, refreshSource } from "@/services/api/prompts"; import { fetchPromptSourceStatuses, refreshAllSources, refreshSource } from "@/services/api/prompts";
import { PROMPT_SOURCE_INTERVAL_OPTIONS, usePromptSourceStore } from "@/stores/use-prompt-source-store"; import { PROMPT_SOURCE_INTERVALS, usePromptSourceStore } from "@/stores/use-prompt-source-store";
import type { PromptSource } from "@/services/api/prompt-source-presets"; import type { PromptSource } from "@/services/api/prompt-source-presets";
const STATUS_QUERY_KEY = ["prompt-source-statuses"]; const STATUS_QUERY_KEY = ["prompt-source-statuses"];
export function ConfigPromptSources() { export function ConfigPromptSources() {
const { message, modal } = App.useApp(); const { message, modal } = App.useApp();
const { i18n, t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const sources = usePromptSourceStore((state) => state.sources); const sources = usePromptSourceStore((state) => state.sources);
const schedule = usePromptSourceStore((state) => state.schedule); const schedule = usePromptSourceStore((state) => state.schedule);
@@ -28,6 +30,7 @@ export function ConfigPromptSources() {
const [refreshingId, setRefreshingId] = useState(""); const [refreshingId, setRefreshingId] = useState("");
const [refreshingAll, setRefreshingAll] = useState(false); const [refreshingAll, setRefreshingAll] = useState(false);
const viewingSource = sources.find((item) => item.id === viewingId) || null; const viewingSource = sources.find((item) => item.id === viewingId) || null;
const intervalOptions = PROMPT_SOURCE_INTERVALS.map((value) => ({ value, label: t(`config.promptSources.intervals.${intervalKey(value)}`) }));
const invalidatePrompts = async () => { const invalidatePrompts = async () => {
await Promise.all([ await Promise.all([
@@ -44,11 +47,11 @@ export function ConfigPromptSources() {
const handleDelete = (source: PromptSource) => { const handleDelete = (source: PromptSource) => {
modal.confirm({ modal.confirm({
title: `删除「${source.name}」?`, title: t("config.promptSources.deleteTitle", { name: source.name }),
content: "来源配置会被移除,已经加入我的资产的内容不受影响。", content: t("config.promptSources.deleteDescription"),
okText: "删除", okText: t("common.delete"),
okButtonProps: { danger: true }, okButtonProps: { danger: true },
cancelText: "取消", cancelText: t("common.cancel"),
onOk: async () => { onOk: async () => {
removeSource(source.id); removeSource(source.id);
await invalidatePrompts(); await invalidatePrompts();
@@ -61,10 +64,10 @@ export function ConfigPromptSources() {
try { try {
const result = await refreshSource(source.id); const result = await refreshSource(source.id);
await invalidatePrompts(); await invalidatePrompts();
message.success(`${source.name}」已更新 ${result.count}`); message.success(t("config.promptSources.refreshed", { name: source.name, count: result.count }));
} catch (error) { } catch (error) {
await queryClient.invalidateQueries({ queryKey: STATUS_QUERY_KEY }); await queryClient.invalidateQueries({ queryKey: STATUS_QUERY_KEY });
message.error(error instanceof Error ? error.message : "更新失败,已保留旧缓存"); message.error(error instanceof Error ? error.message : t("config.promptSources.refreshFailedCached"));
} finally { } finally {
setRefreshingId(""); setRefreshingId("");
} }
@@ -76,10 +79,10 @@ export function ConfigPromptSources() {
const result = await refreshAllSources(); const result = await refreshAllSources();
updateSchedule("lastFetchedAt", new Date().toISOString()); updateSchedule("lastFetchedAt", new Date().toISOString());
await invalidatePrompts(); await invalidatePrompts();
if (result.failureCount) message.warning(`更新完成:${result.successCount} 个成功,${result.failureCount} 个失败,失败来源已保留旧缓存`); if (result.failureCount) message.warning(t("config.promptSources.refreshPartial", { success: result.successCount, failed: result.failureCount }));
else message.success(`已更新 ${result.successCount} 个来源,共 ${result.total}`); else message.success(t("config.promptSources.refreshAllSuccess", { sources: result.successCount, total: result.total }));
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : "更新失败"); message.error(error instanceof Error ? error.message : t("config.promptSources.refreshFailed"));
} finally { } finally {
setRefreshingAll(false); setRefreshingAll(false);
} }
@@ -89,7 +92,7 @@ export function ConfigPromptSources() {
<div> <div>
<div className="mb-4 flex flex-wrap items-center justify-end gap-3"> <div className="mb-4 flex flex-wrap items-center justify-end gap-3">
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => setEditingSource(addSource())}> <Button type="primary" icon={<Plus className="size-4" />} onClick={() => setEditingSource(addSource())}>
{t("config.promptSources.add")}
</Button> </Button>
</div> </div>
@@ -102,26 +105,26 @@ export function ConfigPromptSources() {
<div className="min-w-[220px] flex-1"> <div className="min-w-[220px] flex-1">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-semibold">{source.name}</span> <span className="truncate text-sm font-semibold">{source.name}</span>
{source.builtIn ? <Tag className="m-0 shrink-0 text-[10px]"></Tag> : null} {source.builtIn ? <Tag className="m-0 shrink-0 text-[10px]">{t("config.promptSources.builtIn")}</Tag> : null}
</div> </div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500"> <div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<a className="max-w-full truncate hover:text-stone-800 hover:underline dark:hover:text-stone-200" href={source.homepage || source.url} target="_blank" rel="noreferrer"> <a className="max-w-full truncate hover:text-stone-800 hover:underline dark:hover:text-stone-200" href={source.homepage || source.url} target="_blank" rel="noreferrer">
{source.homepage || source.url} {source.homepage || source.url}
</a> </a>
<span className="tabular-nums">{status?.count ?? 0} </span> <span className="tabular-nums">{t("config.promptSources.itemCount", { count: status?.count ?? 0 })}</span>
{status?.lastError ? <Tag color="error" className="m-0 text-[10px]" title={status.lastError}></Tag> : status?.lastSuccessAt ? <Tag color="success" className="m-0 text-[10px]"></Tag> : <Tag className="m-0 text-[10px]"></Tag>} {status?.lastError ? <Tag color="error" className="m-0 text-[10px]" title={status.lastError}>{t("config.promptSources.failed")}</Tag> : status?.lastSuccessAt ? <Tag color="success" className="m-0 text-[10px]">{t("config.promptSources.healthy")}</Tag> : <Tag className="m-0 text-[10px]">{t("config.promptSources.unsynced")}</Tag>}
<span>{status?.lastSuccessAt ? `上次成功 ${formatTime(status.lastSuccessAt)}` : "尚未拉取"}</span> <span>{status?.lastSuccessAt ? t("config.promptSources.lastSuccess", { time: formatTime(status.lastSuccessAt, i18n.resolvedLanguage) }) : t("config.promptSources.neverFetched")}</span>
</div> </div>
</div> </div>
<div className="ml-auto flex flex-wrap justify-end gap-2"> <div className="ml-auto flex flex-wrap justify-end gap-2">
<Button size="small" icon={<Eye className="size-3.5" />} onClick={() => setViewingId(source.id)}> <Button size="small" icon={<Eye className="size-3.5" />} onClick={() => setViewingId(source.id)}>
{t("config.promptSources.view")}
</Button> </Button>
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={refreshingId === source.id} onClick={() => void handleRefreshOne(source)}> <Button size="small" icon={<RefreshCw className="size-3.5" />} loading={refreshingId === source.id} onClick={() => void handleRefreshOne(source)}>
{t("config.promptSources.refresh")}
</Button> </Button>
{!source.builtIn ? <Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingSource(source)}></Button> : null} {!source.builtIn ? <Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingSource(source)}>{t("config.promptSources.edit")}</Button> : null}
{!source.builtIn ? <Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => handleDelete(source)}></Button> : null} {!source.builtIn ? <Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => handleDelete(source)}>{t("common.delete")}</Button> : null}
</div> </div>
</div> </div>
); );
@@ -129,18 +132,18 @@ export function ConfigPromptSources() {
</div> </div>
<section className="mt-5 rounded-lg border border-stone-200 p-4 dark:border-stone-800"> <section className="mt-5 rounded-lg border border-stone-200 p-4 dark:border-stone-800">
<div className="mb-3 text-sm font-semibold"></div> <div className="mb-3 text-sm font-semibold">{t("config.promptSources.schedule")}</div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-stone-500"></span> <span className="text-xs text-stone-500">{t("config.promptSources.interval")}</span>
<Select size="small" className="w-36" value={schedule.intervalMinutes} options={PROMPT_SOURCE_INTERVAL_OPTIONS} onChange={(value) => updateSchedule("intervalMinutes", value)} /> <Select size="small" className="w-36" value={schedule.intervalMinutes} options={intervalOptions} onChange={(value) => updateSchedule("intervalMinutes", value)} />
</div> </div>
<Button size="small" type="primary" icon={<RefreshCw className="size-3.5" />} loading={refreshingAll} onClick={() => void handleRefreshAll()}> <Button size="small" type="primary" icon={<RefreshCw className="size-3.5" />} loading={refreshingAll} onClick={() => void handleRefreshAll()}>
{t("config.promptSources.refreshAll")}
</Button> </Button>
<span className="text-xs text-stone-500">{schedule.lastFetchedAt ? `上次拉取 ${formatTime(schedule.lastFetchedAt)}` : "尚未定时拉取"}</span> <span className="text-xs text-stone-500">{schedule.lastFetchedAt ? t("config.promptSources.lastFetched", { time: formatTime(schedule.lastFetchedAt, i18n.resolvedLanguage) }) : t("config.promptSources.neverScheduled")}</span>
</div> </div>
<div className="mt-2 text-xs text-stone-400"></div> <div className="mt-2 text-xs text-stone-400">{t("config.promptSources.scheduleDescription")}</div>
</section> </section>
<PromptSourceEditorDrawer open={Boolean(editingSource)} source={editingSource} onSave={handleSave} onClose={() => setEditingSource(null)} /> <PromptSourceEditorDrawer open={Boolean(editingSource)} source={editingSource} onSave={handleSave} onClose={() => setEditingSource(null)} />
@@ -149,7 +152,15 @@ export function ConfigPromptSources() {
); );
} }
function formatTime(value: string) { function formatTime(value: string, locale?: string) {
const date = new Date(value); const date = new Date(value);
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString(locale, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
function intervalKey(value: number) {
if (value === 30) return "minutes30";
if (value === 60) return "hour1";
if (value === 360) return "hours6";
if (value === 1440) return "hours24";
return "disabled";
} }
@@ -1,5 +1,6 @@
import { Drawer } from "antd"; import { Drawer } from "antd";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools"; import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -11,8 +12,10 @@ type MobileNavDrawerProps = {
}; };
export function MobileNavDrawer({ open, activeToolSlug, onClose }: MobileNavDrawerProps) { export function MobileNavDrawer({ open, activeToolSlug, onClose }: MobileNavDrawerProps) {
const { t } = useTranslation();
return ( return (
<Drawer title="导航" placement="left" size={280} open={open} onClose={onClose} className="md:hidden"> <Drawer title={t("topNav.navigation")} placement="left" size={280} open={open} onClose={onClose} className="md:hidden">
<div className="space-y-1"> <div className="space-y-1">
{navigationTools.map((tool) => { {navigationTools.map((tool) => {
const Icon = tool.icon; const Icon = tool.icon;
@@ -28,7 +31,7 @@ export function MobileNavDrawer({ open, activeToolSlug, onClose }: MobileNavDraw
)} )}
> >
<Icon className="size-5" /> <Icon className="size-5" />
<span>{tool.label}</span> <span>{t(`navigation.${tool.slug}`)}</span>
</Link> </Link>
); );
})} })}
@@ -2,23 +2,24 @@ import { javascript } from "@codemirror/lang-javascript";
import CodeMirror from "@uiw/react-codemirror"; import CodeMirror from "@uiw/react-codemirror";
import { Button, Modal } from "antd"; import { Button, Modal } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { PLUGIN_RETURNS, PLUGIN_TEMPLATES, PLUGIN_VARIABLES } from "@/services/api/model-plugin"; import { getPluginReturn, getPluginTemplates, getPluginVariables } from "@/services/api/model-plugin";
import type { ModelCapability } from "@/stores/use-config-store"; import type { ModelCapability } from "@/stores/use-config-store";
const capabilityLabels: Record<ModelCapability, string> = { image: "生图", video: "视频", text: "文本", audio: "音频" };
function isDarkMode() { function isDarkMode() {
return typeof document !== "undefined" && document.documentElement.classList.contains("dark"); return typeof document !== "undefined" && document.documentElement.classList.contains("dark");
} }
export function ModelScriptEditor({ open, capability, modelName, value, onSave, onClose }: { open: boolean; capability: ModelCapability; modelName: string; value: string; onSave: (script: string) => void; onClose: () => void }) { export function ModelScriptEditor({ open, capability, modelName, value, onSave, onClose }: { open: boolean; capability: ModelCapability; modelName: string; value: string; onSave: (script: string) => void; onClose: () => void }) {
const { t } = useTranslation();
const [draft, setDraft] = useState(value); const [draft, setDraft] = useState(value);
useEffect(() => { useEffect(() => {
if (open) setDraft(value); if (open) setDraft(value);
}, [open, value]); }, [open, value]);
const variables = PLUGIN_VARIABLES.filter((variable) => !variable.capabilities || variable.capabilities.includes(capability)); const variables = getPluginVariables().filter((variable) => !variable.capabilities || variable.capabilities.includes(capability));
const templates = getPluginTemplates()[capability];
return ( return (
<Modal <Modal
@@ -26,10 +27,10 @@ export function ModelScriptEditor({ open, capability, modelName, value, onSave,
title={ title={
<div> <div>
<div className="text-base font-semibold"> <div className="text-base font-semibold">
{capabilityLabels[capability]} {t(`config.channelEditor.capabilities.${capability}`)}
{modelName ? ` - ${modelName}` : ""} {modelName ? ` - ${modelName}` : ""}
</div> </div>
<div className="mt-1 text-xs font-normal text-stone-500">使 return 使</div> <div className="mt-1 text-xs font-normal text-stone-500">{t("config.scriptEditor.description")}</div>
</div> </div>
} }
width={1080} width={1080}
@@ -39,17 +40,17 @@ export function ModelScriptEditor({ open, capability, modelName, value, onSave,
footer={ footer={
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{PLUGIN_TEMPLATES[capability].map((template) => ( {templates.map((template) => (
<Button key={template.label} size="small" onClick={() => setDraft(template.script)}> <Button key={template.label} size="small" onClick={() => setDraft(template.script)}>
{template.label} {t("config.scriptEditor.insertTemplate", { name: template.label })}
</Button> </Button>
))} ))}
<Button size="small" danger onClick={() => setDraft("")}> <Button size="small" danger onClick={() => setDraft("")}>
{t("config.scriptEditor.restoreDefault")}
</Button> </Button>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button onClick={onClose}></Button> <Button onClick={onClose}>{t("common.cancel")}</Button>
<Button <Button
type="primary" type="primary"
onClick={() => { onClick={() => {
@@ -57,7 +58,7 @@ export function ModelScriptEditor({ open, capability, modelName, value, onSave,
onClose(); onClose();
}} }}
> >
{t("common.save")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -66,13 +67,13 @@ export function ModelScriptEditor({ open, capability, modelName, value, onSave,
<div className="flex h-[60vh] min-h-[420px] border-t border-stone-200 dark:border-stone-800"> <div className="flex h-[60vh] min-h-[420px] border-t border-stone-200 dark:border-stone-800">
<aside className="flex w-[320px] shrink-0 flex-col overflow-y-auto border-r border-stone-200 bg-stone-50/80 dark:border-stone-800 dark:bg-stone-900/40"> <aside className="flex w-[320px] shrink-0 flex-col overflow-y-auto border-r border-stone-200 bg-stone-50/80 dark:border-stone-800 dark:bg-stone-900/40">
<div className="border-b border-stone-200/70 px-4 py-3 dark:border-stone-800/70"> <div className="border-b border-stone-200/70 px-4 py-3 dark:border-stone-800/70">
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-stone-400"></div> <div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-stone-400">{t("config.scriptEditor.returnRequirements")}</div>
<div className="text-xs leading-6 text-stone-600 dark:text-stone-300">{PLUGIN_RETURNS[capability]}</div> <div className="text-xs leading-6 text-stone-600 dark:text-stone-300">{getPluginReturn(capability)}</div>
</div> </div>
<div className="px-4 py-3"> <div className="px-4 py-3">
<div className="mb-2.5 flex items-center justify-between"> <div className="mb-2.5 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wide text-stone-400"></span> <span className="text-[11px] font-semibold uppercase tracking-wide text-stone-400">{t("config.scriptEditor.variables")}</span>
<span className="text-[10px] text-stone-400"></span> <span className="text-[10px] text-stone-400">{t("config.scriptEditor.insert")}</span>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
{variables.map((variable) => ( {variables.map((variable) => (
@@ -101,7 +102,7 @@ export function ModelScriptEditor({ open, capability, modelName, value, onSave,
height="100%" height="100%"
theme={isDarkMode() ? "dark" : "light"} theme={isDarkMode() ? "dark" : "light"}
extensions={[javascript()]} extensions={[javascript()]}
placeholder={"// 留空使用系统默认调用;点击右下角「插入模板」查看示例。"} placeholder={t("config.scriptEditor.placeholder")}
style={{ height: "100%", fontSize: 13 }} style={{ height: "100%", fontSize: 13 }}
className="h-full [&_.cm-editor]:h-full [&_.cm-gutters]:border-none [&_.cm-scroller]:overflow-auto" className="h-full [&_.cm-editor]:h-full [&_.cm-gutters]:border-none [&_.cm-scroller]:overflow-auto"
/> />
@@ -1,6 +1,7 @@
import { App, Button, Checkbox, Input, Modal, Tabs } from "antd"; import { App, Button, Checkbox, Input, Modal, Tabs } from "antd";
import { RefreshCw, Search } from "lucide-react"; import { RefreshCw, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { fetchChannelModels } from "@/services/api/image"; import { fetchChannelModels } from "@/services/api/image";
import type { ModelChannel } from "@/stores/use-config-store"; import type { ModelChannel } from "@/stores/use-config-store";
@@ -8,6 +9,7 @@ import type { ModelChannel } from "@/stores/use-config-store";
// 选择渠道模型弹窗:拉取上游模型列表或手动增加,勾选后才会进入渠道模型列表。 // 选择渠道模型弹窗:拉取上游模型列表或手动增加,勾选后才会进入渠道模型列表。
export function ModelSelectModal({ open, channel, selectedNames, onConfirm, onClose }: { open: boolean; channel: ModelChannel | null; selectedNames: string[]; onConfirm: (names: string[]) => void; onClose: () => void }) { export function ModelSelectModal({ open, channel, selectedNames, onConfirm, onClose }: { open: boolean; channel: ModelChannel | null; selectedNames: string[]; onConfirm: (names: string[]) => void; onClose: () => void }) {
const { message } = App.useApp(); const { message } = App.useApp();
const { t } = useTranslation();
const [existing, setExisting] = useState<string[]>([]); const [existing, setExisting] = useState<string[]>([]);
const [fetched, setFetched] = useState<string[]>([]); const [fetched, setFetched] = useState<string[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -60,7 +62,7 @@ export function ModelSelectModal({ open, channel, selectedNames, onConfirm, onCl
const fetchModels = async () => { const fetchModels = async () => {
if (!channel) return; if (!channel) return;
if (!channel.baseUrl.trim() || !channel.apiKey.trim()) { if (!channel.baseUrl.trim() || !channel.apiKey.trim()) {
message.error("请先填写接口地址和 API Key"); message.error(t("config.modelSelect.missingConfig"));
return; return;
} }
setLoading(true); setLoading(true);
@@ -68,9 +70,9 @@ export function ModelSelectModal({ open, channel, selectedNames, onConfirm, onCl
const models = await fetchChannelModels(channel); const models = await fetchChannelModels(channel);
setFetched(models); setFetched(models);
setActiveTab("new"); setActiveTab("new");
message.success(`已拉取 ${models.length} 个模型`); message.success(t("config.modelSelect.fetched", { count: models.length }));
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : "拉取模型失败"); message.error(error instanceof Error ? error.message : t("config.modelSelect.fetchFailed"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -90,47 +92,47 @@ export function ModelSelectModal({ open, channel, selectedNames, onConfirm, onCl
onCancel={onClose} onCancel={onClose}
title={ title={
<span> <span>
<span className="ml-2 text-xs font-normal text-stone-500"> {selected.size} / {new Set([...existing, ...fetched]).size}</span> {t("config.modelSelect.title")} <span className="ml-2 text-xs font-normal text-stone-500">{t("config.modelSelect.selected", { selected: selected.size, total: new Set([...existing, ...fetched]).size })}</span>
</span> </span>
} }
styles={{ body: { maxHeight: "62vh", overflowY: "auto" } }} styles={{ body: { maxHeight: "62vh", overflowY: "auto" } }}
footer={[ footer={[
<Button key="cancel" onClick={onClose}> <Button key="cancel" onClick={onClose}>
{t("common.cancel")}
</Button>, </Button>,
<Button key="confirm" type="primary" onClick={confirm}> <Button key="confirm" type="primary" onClick={confirm}>
{t("config.modelSelect.confirm")}
</Button>, </Button>,
]} ]}
> >
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Input className="min-w-[200px] flex-1" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索模型" prefix={<Search className="size-4 text-stone-400" />} allowClear /> <Input className="min-w-[200px] flex-1" value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t("config.modelSelect.search")} prefix={<Search className="size-4 text-stone-400" />} allowClear />
<Input className="min-w-[180px] flex-1" value={manual} onChange={(event) => setManual(event.target.value)} onPressEnter={addManual} placeholder="输入模型名称" /> <Input className="min-w-[180px] flex-1" value={manual} onChange={(event) => setManual(event.target.value)} onPressEnter={addManual} placeholder={t("config.modelSelect.modelName")} />
<Button onClick={addManual}></Button> <Button onClick={addManual}>{t("config.modelSelect.add")}</Button>
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void fetchModels()}> <Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void fetchModels()}>
{t("config.modelSelect.fetch")}
</Button> </Button>
</div> </div>
<div className="mt-2 text-xs text-stone-500"> OpenAI /models </div> <div className="mt-2 text-xs text-stone-500">{t("config.modelSelect.description")}</div>
<Tabs <Tabs
className="mt-3" className="mt-3"
activeKey={activeTab} activeKey={activeTab}
onChange={setActiveTab} onChange={setActiveTab}
items={[ items={[
{ key: "new", label: `新获取的模型 (${fetched.length})` }, { key: "new", label: t("config.modelSelect.fetchedTab", { count: fetched.length }) },
{ key: "existing", label: `已有的模型 (${existing.length})` }, { key: "existing", label: t("config.modelSelect.existingTab", { count: existing.length }) },
]} ]}
/> />
<div className="mb-3 flex items-center justify-between gap-2"> <div className="mb-3 flex items-center justify-between gap-2">
<span className="text-xs text-stone-500"> {visibleSelectedCount} / {visibleList.length}</span> <span className="text-xs text-stone-500">{t("config.modelSelect.visibleSelected", { selected: visibleSelectedCount, total: visibleList.length })}</span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="small" disabled={!visibleList.length} onClick={() => selectVisible(true)}> <Button size="small" disabled={!visibleList.length} onClick={() => selectVisible(true)}>
{t("config.modelSelect.selectVisible")}
</Button> </Button>
<Button size="small" disabled={!visibleSelectedCount} onClick={() => selectVisible(false)}> <Button size="small" disabled={!visibleSelectedCount} onClick={() => selectVisible(false)}>
{t("config.modelSelect.clearVisible")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -146,7 +148,7 @@ export function ModelSelectModal({ open, channel, selectedNames, onConfirm, onCl
))} ))}
</div> </div>
) : ( ) : (
<div className="py-8 text-center text-sm text-stone-500">{activeTab === "new" ? "点击「拉取模型列表」获取上游模型,或手动增加模型名称。" : "暂无已选择的模型。"}</div> <div className="py-8 text-center text-sm text-stone-500">{t(activeTab === "new" ? "config.modelSelect.fetchedEmpty" : "config.modelSelect.existingEmpty")}</div>
)} )}
</Modal> </Modal>
); );
@@ -1,6 +1,7 @@
import { App, Button, Empty, Modal, Space, Table, Tag } from "antd"; import { App, Button, Empty, Modal, Space, Table, Tag } from "antd";
import { Copy, FolderPlus, RefreshCw } from "lucide-react"; import { Copy, FolderPlus, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { PromptDetailDialog } from "@/pages/prompts/components/prompt-detail-dialog"; import { PromptDetailDialog } from "@/pages/prompts/components/prompt-detail-dialog";
import { useCopyText } from "@/hooks/use-copy-text"; import { useCopyText } from "@/hooks/use-copy-text";
@@ -10,6 +11,7 @@ import type { PromptSource } from "@/services/api/prompt-source-presets";
export function PromptSourceContentModal({ source, onClose }: { source: PromptSource | null; onClose: () => void }) { export function PromptSourceContentModal({ source, onClose }: { source: PromptSource | null; onClose: () => void }) {
const { message } = App.useApp(); const { message } = App.useApp();
const { t } = useTranslation();
const [items, setItems] = useState<Prompt[]>([]); const [items, setItems] = useState<Prompt[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<Prompt | null>(null); const [detail, setDetail] = useState<Prompt | null>(null);
@@ -23,12 +25,12 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
try { try {
setItems(force ? await refreshSourceItems(source.id) : await fetchSourcePrompts(source.id)); setItems(force ? await refreshSourceItems(source.id) : await fetchSourcePrompts(source.id));
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : "拉取提示词失败"); message.error(error instanceof Error ? error.message : t("config.promptSources.content.loadFailed"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, },
[source, message], [source, message, t],
); );
useEffect(() => { useEffect(() => {
@@ -38,7 +40,7 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
const saveAsset = (item: Prompt) => { const saveAsset = (item: Prompt) => {
addAsset({ kind: "text", title: item.title, coverUrl: item.coverUrl, tags: item.tags, source: item.category, data: { content: item.prompt }, metadata: { source: "prompt-library", promptId: item.id, githubUrl: item.githubUrl } }); addAsset({ kind: "text", title: item.title, coverUrl: item.coverUrl, tags: item.tags, source: item.category, data: { content: item.prompt }, metadata: { source: "prompt-library", promptId: item.id, githubUrl: item.githubUrl } });
message.success("已加入我的资产"); message.success(t("common.addedToAssets"));
}; };
return ( return (
@@ -51,11 +53,11 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
title={ title={
<div className="flex flex-wrap items-center justify-between gap-2 pr-6"> <div className="flex flex-wrap items-center justify-between gap-2 pr-6">
<div> <div>
<div className="text-base font-semibold">{source?.name || ""} · </div> <div className="text-base font-semibold">{t("config.promptSources.content.title", { name: source?.name || "" })}</div>
<div className="mt-0.5 text-xs font-normal text-stone-500"> {items.length} </div> <div className="mt-0.5 text-xs font-normal text-stone-500">{t("config.promptSources.content.count", { count: items.length })}</div>
</div> </div>
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={() => void load(true)}> <Button size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={() => void load(true)}>
{t("config.promptSources.content.refresh")}
</Button> </Button>
</div> </div>
} }
@@ -67,16 +69,16 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
dataSource={items} dataSource={items}
pagination={{ pageSize: 10, showSizeChanger: false, size: "small" }} pagination={{ pageSize: 10, showSizeChanger: false, size: "small" }}
scroll={{ y: "56vh" }} scroll={{ y: "56vh" }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词" /> }} locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("config.promptSources.content.empty")} /> }}
columns={[ columns={[
{ {
title: "封面", title: t("config.promptSources.content.cover"),
dataIndex: "coverUrl", dataIndex: "coverUrl",
width: 72, width: 72,
render: (coverUrl: string) => (coverUrl ? <img src={coverUrl} alt="" className="size-12 rounded object-cover" /> : <div className="size-12 rounded bg-stone-100 dark:bg-stone-800" />), render: (coverUrl: string) => (coverUrl ? <img src={coverUrl} alt="" className="size-12 rounded object-cover" /> : <div className="size-12 rounded bg-stone-100 dark:bg-stone-800" />),
}, },
{ {
title: "标题", title: t("config.promptSources.content.titleColumn"),
dataIndex: "title", dataIndex: "title",
render: (title: string, item) => ( render: (title: string, item) => (
<div className="min-w-0"> <div className="min-w-0">
@@ -86,7 +88,7 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
), ),
}, },
{ {
title: "标签", title: t("config.promptSources.content.tags"),
dataIndex: "tags", dataIndex: "tags",
width: 200, width: 200,
render: (tags: string[]) => ( render: (tags: string[]) => (
@@ -100,18 +102,18 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
), ),
}, },
{ {
title: "操作", title: t("config.promptSources.content.actions"),
width: 210, width: 210,
render: (_, item) => ( render: (_, item) => (
<Space size={4} wrap> <Space size={4} wrap>
<Button size="small" type="text" icon={<Copy className="size-3.5" />} onClick={() => copyText(item.prompt, "提示词已复制")}> <Button size="small" type="text" icon={<Copy className="size-3.5" />} onClick={() => copyText(item.prompt, t("common.promptCopied"))}>
{t("common.copy")}
</Button> </Button>
<Button size="small" type="text" onClick={() => setDetail(item)}> <Button size="small" type="text" onClick={() => setDetail(item)}>
{t("common.details")}
</Button> </Button>
<Button size="small" type="text" icon={<FolderPlus className="size-3.5" />} onClick={() => saveAsset(item)}> <Button size="small" type="text" icon={<FolderPlus className="size-3.5" />} onClick={() => saveAsset(item)}>
{t("common.addToAssets")}
</Button> </Button>
</Space> </Space>
), ),
@@ -119,7 +121,7 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
]} ]}
/> />
</Modal> </Modal>
<PromptDetailDialog prompt={detail} onClose={() => setDetail(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={saveAsset} /> <PromptDetailDialog prompt={detail} onClose={() => setDetail(null)} onCopy={(prompt) => copyText(prompt, t("common.promptCopied"))} onSaveAsset={saveAsset} />
</> </>
); );
} }
@@ -1,10 +1,12 @@
import { App, Button, Drawer, Input, Space, Switch } from "antd"; import { App, Button, Drawer, Input, Space, Switch } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { PromptSource } from "@/services/api/prompt-source-presets"; import type { PromptSource } from "@/services/api/prompt-source-presets";
export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { open: boolean; source: PromptSource | null; onSave: (source: PromptSource) => void; onClose: () => void }) { export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { open: boolean; source: PromptSource | null; onSave: (source: PromptSource) => void; onClose: () => void }) {
const { message } = App.useApp(); const { message } = App.useApp();
const { t } = useTranslation();
const [draft, setDraft] = useState<PromptSource | null>(source); const [draft, setDraft] = useState<PromptSource | null>(source);
useEffect(() => { useEffect(() => {
@@ -18,9 +20,9 @@ export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { op
const save = () => { const save = () => {
const name = draft.name.trim(); const name = draft.name.trim();
const url = draft.url.trim(); const url = draft.url.trim();
if (!name) return message.warning("请输入来源名称"); if (!name) return message.warning(t("config.promptSources.editor.nameRequired"));
if (!isHttpUrl(url)) return message.warning("请输入有效的 JSON URL"); if (!isHttpUrl(url)) return message.warning(t("config.promptSources.editor.invalidUrl"));
if (draft.homepage.trim() && !isHttpUrl(draft.homepage.trim())) return message.warning("请输入有效的主页地址"); if (draft.homepage.trim() && !isHttpUrl(draft.homepage.trim())) return message.warning(t("config.promptSources.editor.invalidHomepage"));
onSave({ ...draft, name, url, homepage: draft.homepage.trim(), builtIn: false }); onSave({ ...draft, name, url, homepage: draft.homepage.trim(), builtIn: false });
onClose(); onClose();
}; };
@@ -29,46 +31,46 @@ export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { op
<Drawer <Drawer
open={open} open={open}
width={560} width={560}
title={source?.name === "新来源" ? "新增提示词来源" : "编辑提示词来源"} title={t(source?.name ? "config.promptSources.editor.editTitle" : "config.promptSources.editor.addTitle")}
onClose={onClose} onClose={onClose}
styles={{ body: { paddingTop: 16 } }} styles={{ body: { paddingTop: 16 } }}
extra={ extra={
<Space> <Space>
<Button onClick={onClose}></Button> <Button onClick={onClose}>{t("common.cancel")}</Button>
<Button type="primary" onClick={save}> <Button type="primary" onClick={save}>
{t("common.save")}
</Button> </Button>
</Space> </Space>
} }
> >
<div className="space-y-5"> <div className="space-y-5">
<label className="block"> <label className="block">
<span className="mb-1.5 block text-sm font-medium"></span> <span className="mb-1.5 block text-sm font-medium">{t("config.promptSources.editor.name")}</span>
<Input value={draft.name} onChange={(event) => patch({ name: event.target.value })} placeholder="用于分类展示" /> <Input value={draft.name} onChange={(event) => patch({ name: event.target.value })} placeholder={t("config.promptSources.editor.namePlaceholder")} />
</label> </label>
<label className="block"> <label className="block">
<span className="mb-1.5 block text-sm font-medium">JSON URL</span> <span className="mb-1.5 block text-sm font-medium">JSON URL</span>
<Input value={draft.url} onChange={(event) => patch({ url: event.target.value })} placeholder="https://example.com/prompts.json" /> <Input value={draft.url} onChange={(event) => patch({ url: event.target.value })} placeholder="https://example.com/prompts.json" />
</label> </label>
<label className="block"> <label className="block">
<span className="mb-1.5 block text-sm font-medium"></span> <span className="mb-1.5 block text-sm font-medium">{t("config.promptSources.editor.homepage")}</span>
<Input value={draft.homepage} onChange={(event) => patch({ homepage: event.target.value })} placeholder="https://example.com" /> <Input value={draft.homepage} onChange={(event) => patch({ homepage: event.target.value })} placeholder="https://example.com" />
</label> </label>
<div className="flex items-center justify-between border-y border-stone-200 py-3 dark:border-stone-800"> <div className="flex items-center justify-between border-y border-stone-200 py-3 dark:border-stone-800">
<span className="text-sm font-medium"></span> <span className="text-sm font-medium">{t("config.promptSources.editor.enabled")}</span>
<Switch checked={draft.enabled} onChange={(enabled) => patch({ enabled })} /> <Switch checked={draft.enabled} onChange={(enabled) => patch({ enabled })} />
</div> </div>
<div> <div>
<div className="mb-2 text-sm font-medium">JSON </div> <div className="mb-2 text-sm font-medium">{t("config.promptSources.editor.jsonFormat")}</div>
<pre className="overflow-x-auto rounded-md bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{`[ <pre className="overflow-x-auto rounded-md bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{`[
{ {
"id": "product-photo-1", "id": "product-photo-1",
"title": "白底商品图", "title": "Product photo",
"prompt": "生成专业白底商品摄影图", "prompt": "Generate a professional product photo on a white background",
"description": "", "description": "",
"coverUrl": "", "coverUrl": "",
"referenceImageUrls": [], "referenceImageUrls": [],
"tags": ["商品", "摄影"] "tags": ["product", "photography"]
} }
]`}</pre> ]`}</pre>
</div> </div>
@@ -1,5 +1,6 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { BookOpen, Keyboard, Puzzle, Settings2 } from "lucide-react"; import { BookOpen, Keyboard, Puzzle, Settings2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler"; import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import { GitHubLink } from "@/components/layout/github-link"; import { GitHubLink } from "@/components/layout/github-link";
@@ -18,6 +19,7 @@ type UserStatusActionsProps = {
}; };
export function UserStatusActions({ showConfig = true, variant = "default", onOpenShortcuts, onOpenPlugins }: UserStatusActionsProps) { export function UserStatusActions({ showConfig = true, variant = "default", onOpenShortcuts, onOpenPlugins }: UserStatusActionsProps) {
const { t } = useTranslation();
const theme = useThemeStore((state) => state.theme); const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme); const setTheme = useThemeStore((state) => state.setTheme);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog); const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
@@ -31,23 +33,23 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
return ( return (
<div className="inline-flex shrink-0 items-center gap-1"> <div className="inline-flex shrink-0 items-center gap-1">
{onOpenPlugins ? ( {onOpenPlugins ? (
<button type="button" className={naturalIconClass} style={iconStyle} onClick={onOpenPlugins} aria-label="节点插件" title="节点插件"> <button type="button" className={naturalIconClass} style={iconStyle} onClick={onOpenPlugins} aria-label={t("topNav.plugins")} title={t("topNav.plugins")}>
<Puzzle className="size-4" /> <Puzzle className="size-4" />
</button> </button>
) : null} ) : null}
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className={naturalIconClass} style={iconStyle} aria-label="文档" title="文档"> <a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className={naturalIconClass} style={iconStyle} aria-label={t("topNav.docs")} title={t("topNav.docs")}>
<BookOpen className="size-4" /> <BookOpen className="size-4" />
</a> </a>
{showConfig ? ( {showConfig ? (
<button type="button" className={naturalIconClass} style={iconStyle} onClick={() => openConfigDialog(false)} aria-label="配置" title="配置"> <button type="button" className={naturalIconClass} style={iconStyle} onClick={() => openConfigDialog(false)} aria-label={t("navigation.config")} title={t("navigation.config")}>
<Settings2 className="size-4" /> <Settings2 className="size-4" />
</button> </button>
) : null} ) : null}
<AnimatedThemeToggler theme={theme} onThemeChange={setTheme} className={naturalIconClass} style={iconStyle} aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} /> <AnimatedThemeToggler theme={theme} onThemeChange={setTheme} className={naturalIconClass} style={iconStyle} aria-label={t(theme === "dark" ? "topNav.lightTheme" : "topNav.darkTheme")} title={t(theme === "dark" ? "topNav.lightTheme" : "topNav.darkTheme")} />
<VersionReleaseModal style={versionStyle} /> <VersionReleaseModal style={versionStyle} />
<GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} /> <GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} />
{onOpenShortcuts ? ( {onOpenShortcuts ? (
<button type="button" className={naturalIconClass} style={iconStyle} onClick={onOpenShortcuts} aria-label="快捷键" title="快捷键"> <button type="button" className={naturalIconClass} style={iconStyle} onClick={onOpenShortcuts} aria-label={t("topNav.shortcuts")} title={t("topNav.shortcuts")}>
<Keyboard className="size-4" /> <Keyboard className="size-4" />
</button> </button>
) : null} ) : null}
@@ -1,18 +1,21 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { Modal, Tag, Timeline } from "antd"; import { Modal, Tag, Timeline } from "antd";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { useVersionCheck } from "@/hooks/use-version-check"; import { useVersionCheck } from "@/hooks/use-version-check";
import { APP_VERSION } from "@/constant/env"; import { APP_VERSION } from "@/constant/env";
function getTagColor(type: string) { function getTagColor(type: string) {
if (type === "新增") return "green"; if (type === "新增" || type === "Added") return "green";
if (type === "修复") return "red"; if (type === "修复" || type === "Fixed") return "red";
if (type === "调整") return "blue"; if (type === "调整" || type === "Changed") return "blue";
if (type === "文档") return "purple"; if (type === "文档" || type === "Docs") return "purple";
return "default"; return "default";
} }
function getReleaseTitle(version: string) { function releaseTypeLabel(type: string, t: TFunction) {
return version === "Unreleased" ? "未发布" : version; const key = ({ : "added", : "fixed", : "changed", : "optimized", : "docs" } as Record<string, string>)[type];
return key ? t(`version.types.${key}`) : type;
} }
type VersionReleaseModalProps = { type VersionReleaseModalProps = {
@@ -21,6 +24,7 @@ type VersionReleaseModalProps = {
}; };
export function VersionReleaseModal({ className, style }: VersionReleaseModalProps) { export function VersionReleaseModal({ className, style }: VersionReleaseModalProps) {
const { t } = useTranslation();
const { open, setOpen, openReleaseModal, latestVersion, releases, checking, hasNewVersion, checkLatestRelease } = useVersionCheck(); const { open, setOpen, openReleaseModal, latestVersion, releases, checking, hasNewVersion, checkLatestRelease } = useVersionCheck();
return ( return (
@@ -30,28 +34,28 @@ export function VersionReleaseModal({ className, style }: VersionReleaseModalPro
className={className || "shrink-0 cursor-pointer text-xs font-medium text-stone-500 transition hover:text-stone-950 dark:text-stone-400 dark:hover:text-white"} className={className || "shrink-0 cursor-pointer text-xs font-medium text-stone-500 transition hover:text-stone-950 dark:text-stone-400 dark:hover:text-white"}
style={style} style={style}
onClick={openReleaseModal} onClick={openReleaseModal}
title="查看版本更新" title={t("version.viewUpdates")}
> >
<span className="relative inline-flex"> <span className="relative inline-flex">
{APP_VERSION} {APP_VERSION}
{hasNewVersion ? <span className="absolute -right-1.5 -top-1 size-1.5 rounded-full bg-green-500" /> : null} {hasNewVersion ? <span className="absolute -right-1.5 -top-1 size-1.5 rounded-full bg-green-500" /> : null}
</span> </span>
</button> </button>
<Modal title="版本更新" open={open} width={680} centered footer={null} onCancel={() => setOpen(false)}> <Modal title={t("version.title")} open={open} width={680} centered footer={null} onCancel={() => setOpen(false)}>
<div className="mb-5 grid grid-cols-2 gap-3"> <div className="mb-5 grid grid-cols-2 gap-3">
<div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800"> <div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="text-xs text-stone-500 dark:text-stone-400"></div> <div className="text-xs text-stone-500 dark:text-stone-400">{t("version.currentVersion")}</div>
<div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{APP_VERSION}</div> <div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{APP_VERSION}</div>
</div> </div>
<div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800"> <div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="text-xs text-stone-500 dark:text-stone-400"></div> <div className="text-xs text-stone-500 dark:text-stone-400">{t("version.latestVersion")}</div>
<button <button
type="button" type="button"
className="cursor-pointer bg-transparent p-0 text-[11px] font-normal text-stone-400 underline-offset-2 transition hover:text-stone-700 hover:underline dark:text-stone-500 dark:hover:text-stone-300" className="cursor-pointer bg-transparent p-0 text-[11px] font-normal text-stone-400 underline-offset-2 transition hover:text-stone-700 hover:underline dark:text-stone-500 dark:hover:text-stone-300"
onClick={() => void checkLatestRelease(true)} onClick={() => void checkLatestRelease(true)}
> >
{checking ? "检查中..." : "检查更新"} {t(checking ? "version.checking" : "version.checkUpdates")}
</button> </button>
</div> </div>
<div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{latestVersion}</div> <div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{latestVersion}</div>
@@ -63,18 +67,18 @@ export function VersionReleaseModal({ className, style }: VersionReleaseModalPro
content: ( content: (
<div> <div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-stone-950 dark:text-stone-100">{getReleaseTitle(release.version)}</span> <span className="text-sm font-semibold text-stone-950 dark:text-stone-100">{release.version === "Unreleased" ? t("version.unreleased") : release.version}</span>
<span className="text-xs text-stone-500 dark:text-stone-400">{release.date}</span> <span className="text-xs text-stone-500 dark:text-stone-400">{release.date}</span>
<div className="flex min-w-0 items-center gap-1.5"> <div className="flex min-w-0 items-center gap-1.5">
{release.version === latestVersion ? <Tag color="green"></Tag> : null} {release.version === latestVersion ? <Tag color="green">{t("version.latest")}</Tag> : null}
{release.version === APP_VERSION ? <Tag></Tag> : null} {release.version === APP_VERSION ? <Tag>{t("version.current")}</Tag> : null}
</div> </div>
</div> </div>
<div className="mt-2 space-y-1.5"> <div className="mt-2 space-y-1.5">
{release.items.map((item, index) => ( {release.items.map((item, index) => (
<div key={`${release.version}-${index}`} className="flex items-start gap-2 text-sm leading-6 text-stone-700 dark:text-stone-300"> <div key={`${release.version}-${index}`} className="flex items-start gap-2 text-sm leading-6 text-stone-700 dark:text-stone-300">
<Tag color={getTagColor(item.type)} className="m-0 mt-0.5 shrink-0 whitespace-nowrap"> <Tag color={getTagColor(item.type)} className="m-0 mt-0.5 shrink-0 whitespace-nowrap">
{item.type} {releaseTypeLabel(item.type, t)}
</Tag> </Tag>
<span className="min-w-0 flex-1">{item.content}</span> <span className="min-w-0 flex-1">{item.content}</span>
</div> </div>
+10 -6
View File
@@ -1,6 +1,8 @@
import { useEffect, useId, useMemo, useState } from "react"; import { useEffect, useId, useMemo, useState } from "react";
import { Cpu } from "lucide-react"; import { Cpu } from "lucide-react";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { modelOptionLabel, modelOptionName, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store"; import { modelOptionLabel, modelOptionName, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
@@ -16,11 +18,13 @@ type ModelPickerProps = {
onMissingConfig?: () => void; onMissingConfig?: () => void;
}; };
export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig }: ModelPickerProps) { export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder, onMissingConfig }: ModelPickerProps) {
const { t } = useTranslation();
const pickerId = useId(); const pickerId = useId();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const options = useMemo(() => Array.from(new Set([...(config.channelMode === "local" && !capability ? [value] : []), ...selectableModelsByCapability(config, capability)].filter((model): model is string => Boolean(model)))), [capability, config, value]); const options = useMemo(() => Array.from(new Set([...(config.channelMode === "local" && !capability ? [value] : []), ...selectableModelsByCapability(config, capability)].filter((model): model is string => Boolean(model)))), [capability, config, value]);
const current = value || ""; const current = value || "";
const pickerPlaceholder = placeholder || t("settingsPanels.model.select");
useEffect(() => { useEffect(() => {
const closeOtherPicker = (event: Event) => { const closeOtherPicker = (event: Event) => {
@@ -50,10 +54,10 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
)} )}
onMouseDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}
title={current ? modelOptionLabel(config, current) : placeholder} title={current ? modelOptionLabel(config, current) : pickerPlaceholder}
> >
<ModelIcon model={current} /> <ModelIcon model={current} />
<span className="canvas-model-picker-text min-w-0 flex-1 truncate text-left">{current ? modelOptionLabel(config, current) : placeholder}</span> <span className="canvas-model-picker-text min-w-0 flex-1 truncate text-left">{current ? modelOptionLabel(config, current) : pickerPlaceholder}</span>
</SelectTrigger> </SelectTrigger>
<SelectContent <SelectContent
data-canvas-no-zoom data-canvas-no-zoom
@@ -82,9 +86,9 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
} }
function emptyModelLabel(config: AiConfig, capability?: ModelCapability) { function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : capability === "audio" ? "音频" : ""; const label = capability ? i18n.t(`settingsPanels.model.capabilities.${capability}`) : "";
if (capability && config.models.length) return `请先在渠道里为${label}指定模型`; if (capability && config.models.length) return i18n.t("settingsPanels.model.assign", { capability: label });
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里添加渠道和模型"; return config.models.length ? i18n.t("settingsPanels.model.noMatch", { capability: label }) : i18n.t("settingsPanels.model.addFirst");
} }
function ModelLabel({ config, model }: { config: AiConfig; model: string }) { function ModelLabel({ config, model }: { config: AiConfig; model: string }) {
+5 -3
View File
@@ -1,6 +1,7 @@
import { Copy, FileText } from "lucide-react"; import { Copy, FileText } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Button, Card, Tag } from "antd"; import { Button, Card, Tag } from "antd";
import { useTranslation } from "react-i18next";
import { formatPromptDate, type Prompt } from "@/services/api/prompts"; import { formatPromptDate, type Prompt } from "@/services/api/prompts";
@@ -8,7 +9,7 @@ export function PromptCard({
item, item,
onOpen, onOpen,
onCopy, onCopy,
actionLabel = "复制", actionLabel,
actionIcon = <Copy className="size-3.5" />, actionIcon = <Copy className="size-3.5" />,
actionType = "text", actionType = "text",
extraAction, extraAction,
@@ -21,6 +22,7 @@ export function PromptCard({
actionType?: "text" | "primary"; actionType?: "text" | "primary";
extraAction?: ReactNode; extraAction?: ReactNode;
}) { }) {
const { i18n, t } = useTranslation();
return ( return (
<Card <Card
hoverable hoverable
@@ -36,7 +38,7 @@ export function PromptCard({
<div className="p-4"> <div className="p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{item.title}</h2> <h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{item.title}</h2>
<span className="shrink-0 text-xs text-stone-400 dark:text-stone-500">{formatPromptDate(item.updatedAt)}</span> <span className="shrink-0 text-xs text-stone-400 dark:text-stone-500">{formatPromptDate(item.updatedAt, i18n.resolvedLanguage)}</span>
</div> </div>
<p className="mt-2 line-clamp-3 text-xs leading-5 text-stone-600 dark:text-stone-400">{item.description || item.prompt}</p> <p className="mt-2 line-clamp-3 text-xs leading-5 text-stone-600 dark:text-stone-400">{item.description || item.prompt}</p>
<div className="mt-3 flex flex-wrap gap-1.5"> <div className="mt-3 flex flex-wrap gap-1.5">
@@ -50,7 +52,7 @@ export function PromptCard({
</button> </button>
<div className="flex items-center gap-2 px-4 pb-4"> <div className="flex items-center gap-2 px-4 pb-4">
<Button block={actionType === "primary"} type={actionType} size="small" icon={actionIcon} onClick={onCopy}> <Button block={actionType === "primary"} type={actionType} size="small" icon={actionIcon} onClick={onCopy}>
{actionLabel} {actionLabel || t("common.copy")}
</Button> </Button>
{extraAction} {extraAction}
</div> </div>
@@ -1,6 +1,7 @@
import { Check, Search } from "lucide-react"; import { Check, Search } from "lucide-react";
import { type UIEvent, useEffect, useState } from "react"; import { type UIEvent, useEffect, useState } from "react";
import { App, Empty, Input, Modal, Spin, Tag } from "antd"; import { App, Empty, Input, Modal, Spin, Tag } from "antd";
import { useTranslation } from "react-i18next";
import { ALL_PROMPTS_OPTION } from "@/services/api/prompts"; import { ALL_PROMPTS_OPTION } from "@/services/api/prompts";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -9,6 +10,7 @@ import { usePromptList } from "./use-prompt-list";
export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boolean; onOpenChange: (open: boolean) => void; onSelect: (prompt: string) => void }) { export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boolean; onOpenChange: (open: boolean) => void; onSelect: (prompt: string) => void }) {
const { message } = App.useApp(); const { message } = App.useApp();
const { t } = useTranslation();
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION); const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION);
@@ -23,8 +25,8 @@ export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boo
}; };
useEffect(() => { useEffect(() => {
if (query.isError) message.error(query.error instanceof Error ? query.error.message : "获取提示词失败"); if (query.isError) message.error(query.error instanceof Error ? query.error.message : t("prompts.loadFailed"));
}, [message, query.error, query.isError]); }, [message, query.error, query.isError, t]);
const handleListScroll = (event: UIEvent<HTMLDivElement>) => { const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
const target = event.currentTarget; const target = event.currentTarget;
@@ -32,30 +34,30 @@ export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boo
}; };
return ( return (
<Modal title="提示词库" open={open} onCancel={() => onOpenChange(false)} footer={null} width={1040} centered> <Modal title={t("prompts.library")} open={open} onCancel={() => onOpenChange(false)} footer={null} width={1040} centered>
<div data-canvas-no-zoom onWheelCapture={(event) => event.stopPropagation()}> <div data-canvas-no-zoom onWheelCapture={(event) => event.stopPropagation()}>
<div className="mx-auto max-w-2xl"> <div className="mx-auto max-w-2xl">
<Input size="large" prefix={<Search className="size-4 text-stone-400" />} value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="按标题查询" /> <Input size="large" prefix={<Search className="size-4 text-stone-400" />} value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder={t("prompts.searchTitle")} />
</div> </div>
<div className="mt-5 grid gap-3"> <div className="mt-5 grid gap-3">
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start"> <div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div> <div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">{t("prompts.category")}</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{promptCategories.map((category) => ( {promptCategories.map((category) => (
<Tag.CheckableTag key={category} checked={selectedCategory === category} className={cn("prompt-filter-tag", selectedCategory === category && "is-active")} onChange={() => setSelectedCategory(category)}> <Tag.CheckableTag key={category} checked={selectedCategory === category} className={cn("prompt-filter-tag", selectedCategory === category && "is-active")} onChange={() => setSelectedCategory(category)}>
{category} {category === ALL_PROMPTS_OPTION ? t("common.all") : category}
</Tag.CheckableTag> </Tag.CheckableTag>
))} ))}
</div> </div>
</div> </div>
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start"> <div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div> <div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">{t("prompts.tags")}</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{promptTags.map((tag) => { {promptTags.map((tag) => {
const active = tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag); const active = tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag);
return ( return (
<Tag.CheckableTag key={tag} checked={active} className={cn("prompt-filter-tag", active && "is-active")} onChange={() => toggleTag(tag)}> <Tag.CheckableTag key={tag} checked={active} className={cn("prompt-filter-tag", active && "is-active")} onChange={() => toggleTag(tag)}>
{tag} {tag === ALL_PROMPTS_OPTION ? t("common.all") : tag}
</Tag.CheckableTag> </Tag.CheckableTag>
); );
})} })}
@@ -70,10 +72,10 @@ export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boo
) : null} ) : null}
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => ( {items.map((item) => (
<PromptCard key={item.id} item={item} onOpen={() => selectPrompt(item.prompt)} onCopy={() => selectPrompt(item.prompt)} actionLabel="使用此提示词" actionIcon={<Check className="size-3.5" />} actionType="primary" /> <PromptCard key={item.id} item={item} onOpen={() => selectPrompt(item.prompt)} onCopy={() => selectPrompt(item.prompt)} actionLabel={t("prompts.use")} actionIcon={<Check className="size-3.5" />} actionType="primary" />
))} ))}
</div> </div>
{!query.isLoading && items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-8" /> : null} {!query.isLoading && items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("prompts.empty")} className="py-8" /> : null}
{query.isFetchingNextPage ? ( {query.isFetchingNextPage ? (
<div className="py-4 text-center"> <div className="py-4 text-center">
<Spin size="small" /> <Spin size="small" />
+10 -13
View File
@@ -1,16 +1,12 @@
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n";
import { ImageSettingsTheme } from "@/components/image-settings-panel"; import { ImageSettingsTheme } from "@/components/image-settings-panel";
import { type CanvasTheme } from "@/lib/canvas-theme"; import { type CanvasTheme } from "@/lib/canvas-theme";
import type { AiConfig, ReasoningEffort } from "@/stores/use-config-store"; import type { AiConfig, ReasoningEffort } from "@/stores/use-config-store";
const reasoningEffortOptions: Array<{ value: ReasoningEffort; label: string }> = [ const reasoningEffortOptions: ReasoningEffort[] = ["auto", "low", "medium", "high", "xhigh"];
{ value: "auto", label: "自动" },
{ value: "low", label: "低" },
{ value: "medium", label: "中" },
{ value: "high", label: "高" },
{ value: "xhigh", label: "极高" },
];
type TextSettingsPanelProps = { type TextSettingsPanelProps = {
config: AiConfig; config: AiConfig;
@@ -20,18 +16,19 @@ type TextSettingsPanelProps = {
}; };
export function TextSettingsPanel({ config, onConfigChange, theme, className = "space-y-4" }: TextSettingsPanelProps) { export function TextSettingsPanel({ config, onConfigChange, theme, className = "space-y-4" }: TextSettingsPanelProps) {
const { t } = useTranslation();
return ( return (
<ImageSettingsTheme theme={theme}> <ImageSettingsTheme theme={theme}>
<div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}> <div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
<div className="text-lg font-semibold"></div> <div className="text-lg font-semibold">{t("settingsPanels.text.title")}</div>
<div className="space-y-2.5"> <div className="space-y-2.5">
<div className="text-sm font-medium" style={{ color: theme.node.muted }}> <div className="text-sm font-medium" style={{ color: theme.node.muted }}>
{t("settingsPanels.text.reasoning")}
</div> </div>
<div className="grid grid-cols-5 gap-2"> <div className="grid grid-cols-5 gap-2">
{reasoningEffortOptions.map((item) => ( {reasoningEffortOptions.map((value) => (
<OptionPill key={item.value} selected={config.reasoningEffort === item.value} theme={theme} onClick={() => onConfigChange("reasoningEffort", item.value)}> <OptionPill key={value} selected={config.reasoningEffort === value} theme={theme} onClick={() => onConfigChange("reasoningEffort", value)}>
{item.label} {t(`settingsPanels.common.${value}`)}
</OptionPill> </OptionPill>
))} ))}
</div> </div>
@@ -42,7 +39,7 @@ export function TextSettingsPanel({ config, onConfigChange, theme, className = "
} }
export function reasoningEffortLabel(value: ReasoningEffort) { export function reasoningEffortLabel(value: ReasoningEffort) {
return reasoningEffortOptions.find((item) => item.value === value)?.label || value; return reasoningEffortOptions.includes(value) ? i18n.t(`settingsPanels.common.${value}`) : value;
} }
function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) { function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Moon, Sun } from "lucide-react"; import { Moon, Sun } from "lucide-react";
import { flushSync } from "react-dom"; import { flushSync } from "react-dom";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -82,6 +83,7 @@ function getThemeTransitionClipPaths(variant: TransitionVariant, cx: number, cy:
} }
export const AnimatedThemeToggler = ({ children, className, duration = 400, variant, fromCenter = false, theme, targetTheme, onThemeChange, ...props }: AnimatedThemeTogglerProps) => { export const AnimatedThemeToggler = ({ children, className, duration = 400, variant, fromCenter = false, theme, targetTheme, onThemeChange, ...props }: AnimatedThemeTogglerProps) => {
const { t } = useTranslation();
const shape = variant ?? "circle"; const shape = variant ?? "circle";
const [isDark, setIsDark] = useState(false); const [isDark, setIsDark] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
@@ -186,7 +188,7 @@ export const AnimatedThemeToggler = ({ children, className, duration = 400, vari
return ( return (
<button type="button" ref={buttonRef} onClick={toggleTheme} className={cn(className)} {...props}> <button type="button" ref={buttonRef} onClick={toggleTheme} className={cn(className)} {...props}>
{children ?? (isDark ? <Sun /> : <Moon />)} {children ?? (isDark ? <Sun /> : <Moon />)}
<span className="sr-only">{props["aria-label"] || "切换主题"}</span> <span className="sr-only">{props["aria-label"] || t("theme.toggle")}</span>
</button> </button>
); );
}; };
+31 -25
View File
@@ -1,6 +1,8 @@
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import { Switch } from "antd"; import { Switch } from "antd";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n";
import { ImageSettingsTheme } from "@/components/image-settings-panel"; import { ImageSettingsTheme } from "@/components/image-settings-panel";
import { boolConfig, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceDurationOptions, seedancePixelLabel, seedanceRatioOptions, seedanceResolutionOptions } from "@/lib/seedance-video"; import { boolConfig, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceDurationOptions, seedancePixelLabel, seedanceRatioOptions, seedanceResolutionOptions } from "@/lib/seedance-video";
import { type CanvasTheme } from "@/lib/canvas-theme"; import { type CanvasTheme } from "@/lib/canvas-theme";
@@ -12,18 +14,19 @@ const resolutionOptions = [
]; ];
const sizeOptions = [ const sizeOptions = [
{ value: "1280x720", label: "横屏", width: 1280, height: 720 }, { value: "1280x720", labelKey: "landscape", width: 1280, height: 720 },
{ value: "720x1280", label: "竖屏", width: 720, height: 1280 }, { value: "720x1280", labelKey: "portrait", width: 720, height: 1280 },
{ value: "1024x1024", label: "方形", width: 1024, height: 1024 }, { value: "1024x1024", labelKey: "square", width: 1024, height: 1024 },
{ value: "1792x1024", label: "宽屏", width: 1792, height: 1024 }, { value: "1792x1024", labelKey: "widescreen", width: 1792, height: 1024 },
{ value: "1024x1792", label: "长图", width: 1024, height: 1792 }, { value: "1024x1792", labelKey: "tall", width: 1024, height: 1792 },
{ value: "auto", label: "auto", width: 0, height: 0 }, { value: "auto", labelKey: "auto", width: 0, height: 0 },
]; ];
const secondOptions = [6, 10, 12, 16, 20]; const secondOptions = [6, 10, 12, 16, 20];
const seedanceRatioLabelKeys: Record<string, string> = { "16:9": "landscape", "9:16": "portrait", "1:1": "square", "4:3": "standardLandscape", "3:4": "standardPortrait", "21:9": "cinematic", adaptive: "adaptive" };
export const videoResolutionOptions = resolutionOptions.map((item) => ({ value: item.value, label: item.label })); export const videoResolutionOptions = resolutionOptions.map((item) => ({ value: item.value, label: item.label }));
export const videoSizeOptions = sizeOptions.map((item) => ({ value: item.value, label: item.label })); export const videoSizeOptions = sizeOptions.map((item) => ({ value: item.value, get label() { return i18n.t(`settingsPanels.video.sizes.${item.labelKey}`); } }));
export const videoSecondOptions = secondOptions.map((value) => String(value)); export const videoSecondOptions = secondOptions.map((value) => String(value));
type VideoSettingsPanelProps = { type VideoSettingsPanelProps = {
@@ -35,6 +38,7 @@ type VideoSettingsPanelProps = {
}; };
export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[320px] space-y-4 rounded-2xl px-1 py-0.5" }: VideoSettingsPanelProps) { export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[320px] space-y-4 rounded-2xl px-1 py-0.5" }: VideoSettingsPanelProps) {
const { t } = useTranslation();
if (isSeedanceVideoConfig(config)) { if (isSeedanceVideoConfig(config)) {
return <SeedanceVideoSettingsPanel config={config} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />; return <SeedanceVideoSettingsPanel config={config} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
} }
@@ -51,8 +55,8 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
return ( return (
<ImageSettingsTheme theme={theme}> <ImageSettingsTheme theme={theme}>
<div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}> <div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
{showTitle ? <div className="text-lg font-semibold"></div> : null} {showTitle ? <div className="text-lg font-semibold">{t("settingsPanels.video.title")}</div> : null}
<SettingGroup title="清晰度" color={theme.node.muted}> <SettingGroup title={t("settingsPanels.video.quality")} color={theme.node.muted}>
<div className="grid grid-cols-3 gap-2.5"> <div className="grid grid-cols-3 gap-2.5">
{resolutionOptions.map((item) => ( {resolutionOptions.map((item) => (
<OptionPill key={item.value} selected={resolution === item.value} theme={theme} onClick={() => onConfigChange("vquality", item.value)}> <OptionPill key={item.value} selected={resolution === item.value} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
@@ -62,7 +66,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
<ResolutionInput value={resolution} theme={theme} onChange={(value) => onConfigChange("vquality", value)} /> <ResolutionInput value={resolution} theme={theme} onChange={(value) => onConfigChange("vquality", value)} />
</div> </div>
</SettingGroup> </SettingGroup>
<SettingGroup title="尺寸" color={theme.node.muted}> <SettingGroup title={t("settingsPanels.video.size")} color={theme.node.muted}>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2.5"> <div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2.5">
<DimensionInput prefix="W" value={dimensions.width} disabled={size === "auto"} theme={theme} onChange={(value) => updateDimension("width", value)} /> <DimensionInput prefix="W" value={dimensions.width} disabled={size === "auto"} theme={theme} onChange={(value) => updateDimension("width", value)} />
<span className="text-lg opacity-45"></span> <span className="text-lg opacity-45"></span>
@@ -79,7 +83,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
onClick={() => onConfigChange("size", item.value)} onClick={() => onConfigChange("size", item.value)}
> >
<SizePreview width={item.width} height={item.height} color={theme.node.text} /> <SizePreview width={item.width} height={item.height} color={theme.node.text} />
<span>{item.label}</span> <span>{t(`settingsPanels.video.sizes.${item.labelKey}`)}</span>
{item.value === "auto" ? null : ( {item.value === "auto" ? null : (
<span className="text-[11px] leading-none opacity-55"> <span className="text-[11px] leading-none opacity-55">
{item.value} {item.value}
@@ -89,7 +93,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
))} ))}
</div> </div>
</SettingGroup> </SettingGroup>
<SettingGroup title="秒数" color={theme.node.muted}> <SettingGroup title={t("settingsPanels.video.seconds")} color={theme.node.muted}>
<div className="grid grid-cols-3 gap-2.5"> <div className="grid grid-cols-3 gap-2.5">
{secondOptions.map((value) => ( {secondOptions.map((value) => (
<OptionPill key={value} selected={seconds === String(value)} theme={theme} onClick={() => onConfigChange("videoSeconds", String(value))}> <OptionPill key={value} selected={seconds === String(value)} theme={theme} onClick={() => onConfigChange("videoSeconds", String(value))}>
@@ -105,6 +109,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
} }
function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps) { function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps) {
const { t } = useTranslation();
const resolution = normalizeSeedanceResolution(config.vquality); const resolution = normalizeSeedanceResolution(config.vquality);
const ratio = normalizeSeedanceRatio(config.size); const ratio = normalizeSeedanceRatio(config.size);
const duration = normalizeSeedanceDuration(config.videoSeconds); const duration = normalizeSeedanceDuration(config.videoSeconds);
@@ -114,8 +119,8 @@ function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle,
return ( return (
<ImageSettingsTheme theme={theme}> <ImageSettingsTheme theme={theme}>
<div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}> <div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
{showTitle ? <div className="text-lg font-semibold"></div> : null} {showTitle ? <div className="text-lg font-semibold">{t("settingsPanels.video.title")}</div> : null}
<SettingGroup title="分辨率" color={theme.node.muted}> <SettingGroup title={t("settingsPanels.video.resolution")} color={theme.node.muted}>
<div className="grid grid-cols-3 gap-2.5"> <div className="grid grid-cols-3 gap-2.5">
{seedanceResolutionOptions.map((item) => ( {seedanceResolutionOptions.map((item) => (
<OptionPill key={item.value} selected={resolution === item.value} theme={theme} onClick={() => onConfigChange("vquality", item.value)}> <OptionPill key={item.value} selected={resolution === item.value} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
@@ -124,7 +129,7 @@ function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle,
))} ))}
</div> </div>
</SettingGroup> </SettingGroup>
<SettingGroup title="比例" color={theme.node.muted}> <SettingGroup title={t("settingsPanels.video.ratio")} color={theme.node.muted}>
<div className="grid grid-cols-3 gap-2.5"> <div className="grid grid-cols-3 gap-2.5">
{seedanceRatioOptions.map((item) => ( {seedanceRatioOptions.map((item) => (
<button <button
@@ -136,26 +141,26 @@ function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle,
onClick={() => onConfigChange("size", item.value)} onClick={() => onConfigChange("size", item.value)}
> >
<SizePreview width={ratioPreview(item.value).width} height={ratioPreview(item.value).height} color={theme.node.text} /> <SizePreview width={ratioPreview(item.value).width} height={ratioPreview(item.value).height} color={theme.node.text} />
<span>{item.label}</span> <span>{i18n.t(`settingsPanels.video.ratios.${seedanceRatioLabelKeys[item.value]}`)}</span>
<span className="text-[10px] leading-none opacity-55">{item.value === "adaptive" ? "adaptive" : seedancePixelLabel(resolution, item.value)}</span> <span className="text-[10px] leading-none opacity-55">{item.value === "adaptive" ? "adaptive" : seedancePixelLabel(resolution, item.value)}</span>
</button> </button>
))} ))}
</div> </div>
</SettingGroup> </SettingGroup>
<SettingGroup title="时长" color={theme.node.muted}> <SettingGroup title={t("settingsPanels.video.duration")} color={theme.node.muted}>
<div className="grid grid-cols-4 gap-2.5"> <div className="grid grid-cols-4 gap-2.5">
{seedanceDurationOptions.map((value) => ( {seedanceDurationOptions.map((value) => (
<OptionPill key={value} selected={duration === value} theme={theme} onClick={() => onConfigChange("videoSeconds", String(value))}> <OptionPill key={value} selected={duration === value} theme={theme} onClick={() => onConfigChange("videoSeconds", String(value))}>
{value === -1 ? "智能" : `${value}s`} {value === -1 ? t("settingsPanels.video.smart") : `${value}s`}
</OptionPill> </OptionPill>
))} ))}
</div> </div>
<NumberInput value={String(duration)} min={-1} max={15} theme={theme} onChange={(value) => onConfigChange("videoSeconds", value)} /> <NumberInput value={String(duration)} min={-1} max={15} theme={theme} onChange={(value) => onConfigChange("videoSeconds", value)} />
</SettingGroup> </SettingGroup>
<SettingGroup title="输出" color={theme.node.muted}> <SettingGroup title={t("settingsPanels.video.output")} color={theme.node.muted}>
<div className="grid gap-2 rounded-xl border p-2.5" style={{ borderColor: theme.node.stroke }}> <div className="grid gap-2 rounded-xl border p-2.5" style={{ borderColor: theme.node.stroke }}>
<SwitchRow label="生成声音" checked={generateAudio} theme={theme} onChange={(checked) => onConfigChange("videoGenerateAudio", String(checked))} /> <SwitchRow label={t("settingsPanels.video.generateAudio")} checked={generateAudio} theme={theme} onChange={(checked) => onConfigChange("videoGenerateAudio", String(checked))} />
<SwitchRow label="添加水印" checked={watermark} theme={theme} onChange={(checked) => onConfigChange("videoWatermark", String(checked))} /> <SwitchRow label={t("settingsPanels.video.watermark")} checked={watermark} theme={theme} onChange={(checked) => onConfigChange("videoWatermark", String(checked))} />
</div> </div>
</SettingGroup> </SettingGroup>
</div> </div>
@@ -169,14 +174,15 @@ export function videoResolutionLabel(value: string) {
export function videoSizeLabel(value: string) { export function videoSizeLabel(value: string) {
const ratio = normalizeSeedanceRatio(value); const ratio = normalizeSeedanceRatio(value);
if (value === "adaptive" || value === "auto") return "自适应"; if (value === "adaptive" || value === "auto") return i18n.t("settingsPanels.video.adaptive");
if (ratio === value) return seedanceRatioOptions.find((item) => item.value === ratio)?.label || ratio; if (ratio === value) return i18n.t(`settingsPanels.video.ratios.${seedanceRatioLabelKeys[ratio]}`);
const size = normalizeVideoSizeValue(value); const size = normalizeVideoSizeValue(value);
return sizeOptions.find((item) => item.value === size)?.label || size; const option = sizeOptions.find((item) => item.value === size);
return option ? i18n.t(`settingsPanels.video.sizes.${option.labelKey}`) : size;
} }
export function videoSecondsLabel(value: string) { export function videoSecondsLabel(value: string) {
if (String(value).trim() === "-1") return "智能"; if (String(value).trim() === "-1") return i18n.t("settingsPanels.video.smart");
return `${value || "6"}s`; return `${value || "6"}s`;
} }
+13 -12
View File
@@ -1,3 +1,4 @@
import i18n from "@/i18n";
import { CanvasNodeType } from "@/types/canvas"; import { CanvasNodeType } from "@/types/canvas";
import type { CanvasNodeMetadata } from "@/types/canvas"; import type { CanvasNodeMetadata } from "@/types/canvas";
import { getNodeSpec as getRegistryNodeSpec } from "@/lib/canvas/node-registry"; import { getNodeSpec as getRegistryNodeSpec } from "@/lib/canvas/node-registry";
@@ -10,37 +11,37 @@ type CanvasNodeSpec = {
}; };
export const NODE_DEFAULT_SIZE = { export const NODE_DEFAULT_SIZE = {
[CanvasNodeType.Image]: { width: 340, height: 240, title: "图片" }, [CanvasNodeType.Image]: { width: 340, height: 240, get title() { return i18n.t("canvas.nodeTypes.image"); } },
[CanvasNodeType.Text]: { width: 340, height: 240, title: "文本" }, [CanvasNodeType.Text]: { width: 340, height: 240, get title() { return i18n.t("canvas.nodeTypes.text"); } },
[CanvasNodeType.Config]: { width: 340, height: 240, title: "生成配置" }, [CanvasNodeType.Config]: { width: 340, height: 240, get title() { return i18n.t("canvas.nodeTypes.config"); } },
[CanvasNodeType.Video]: { width: 420, height: 236, title: "视频" }, [CanvasNodeType.Video]: { width: 420, height: 236, get title() { return i18n.t("canvas.nodeTypes.video"); } },
[CanvasNodeType.Audio]: { width: 340, height: 120, title: "音频" }, [CanvasNodeType.Audio]: { width: 340, height: 120, get title() { return i18n.t("canvas.nodeTypes.audio"); } },
[CanvasNodeType.Group]: { width: 760, height: 480, title: "组" }, [CanvasNodeType.Group]: { width: 760, height: 480, get title() { return i18n.t("canvas.nodeTypes.group"); } },
} satisfies Record<CanvasNodeType, { width: number; height: number; title: string }>; } satisfies Record<CanvasNodeType, { width: number; height: number; title: string }>;
export const NODE_SPECS = { export const NODE_SPECS = {
[CanvasNodeType.Image]: { [CanvasNodeType.Image]: {
...NODE_DEFAULT_SIZE[CanvasNodeType.Image], width: 340, height: 240, get title() { return NODE_DEFAULT_SIZE[CanvasNodeType.Image].title; },
metadata: { content: "", status: "idle" }, metadata: { content: "", status: "idle" },
}, },
[CanvasNodeType.Text]: { [CanvasNodeType.Text]: {
...NODE_DEFAULT_SIZE[CanvasNodeType.Text], width: 340, height: 240, get title() { return NODE_DEFAULT_SIZE[CanvasNodeType.Text].title; },
metadata: { content: "", status: "idle", fontSize: 14 }, metadata: { content: "", status: "idle", fontSize: 14 },
}, },
[CanvasNodeType.Config]: { [CanvasNodeType.Config]: {
...NODE_DEFAULT_SIZE[CanvasNodeType.Config], width: 340, height: 240, get title() { return NODE_DEFAULT_SIZE[CanvasNodeType.Config].title; },
metadata: { content: "", status: "idle", generationMode: "image" }, metadata: { content: "", status: "idle", generationMode: "image" },
}, },
[CanvasNodeType.Video]: { [CanvasNodeType.Video]: {
...NODE_DEFAULT_SIZE[CanvasNodeType.Video], width: 420, height: 236, get title() { return NODE_DEFAULT_SIZE[CanvasNodeType.Video].title; },
metadata: { content: "", status: "idle" }, metadata: { content: "", status: "idle" },
}, },
[CanvasNodeType.Audio]: { [CanvasNodeType.Audio]: {
...NODE_DEFAULT_SIZE[CanvasNodeType.Audio], width: 340, height: 120, get title() { return NODE_DEFAULT_SIZE[CanvasNodeType.Audio].title; },
metadata: { content: "", status: "idle" }, metadata: { content: "", status: "idle" },
}, },
[CanvasNodeType.Group]: { [CanvasNodeType.Group]: {
...NODE_DEFAULT_SIZE[CanvasNodeType.Group], width: 760, height: 480, get title() { return NODE_DEFAULT_SIZE[CanvasNodeType.Group].title; },
metadata: { status: "idle" }, metadata: { status: "idle" },
}, },
} satisfies Record<CanvasNodeType, CanvasNodeSpec>; } satisfies Record<CanvasNodeType, CanvasNodeSpec>;
-6
View File
@@ -3,32 +3,26 @@ import { FileText, ImagePlus, Images, Maximize2, Settings2, Video } from "lucide
export const navigationTools = [ export const navigationTools = [
{ {
slug: "canvas", slug: "canvas",
label: "我的画布",
icon: Maximize2, icon: Maximize2,
}, },
{ {
slug: "image", slug: "image",
label: "生图工作台",
icon: ImagePlus, icon: ImagePlus,
}, },
{ {
slug: "video", slug: "video",
label: "视频创作台",
icon: Video, icon: Video,
}, },
{ {
slug: "prompts", slug: "prompts",
label: "提示词库",
icon: FileText, icon: FileText,
}, },
{ {
slug: "assets", slug: "assets",
label: "我的资产",
icon: Images, icon: Images,
}, },
{ {
slug: "config", slug: "config",
label: "配置",
icon: Settings2, icon: Settings2,
}, },
] as const; ] as const;
+3 -1
View File
@@ -1,10 +1,12 @@
import { App } from "antd"; import { App } from "antd";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { useTranslation } from "react-i18next";
export function useCopyText() { export function useCopyText() {
const { message } = App.useApp(); const { message } = App.useApp();
const { t } = useTranslation();
return (value: string, successText = "已复制") => { return (value: string, successText = t("common.copied")) => {
copy(value); copy(value);
message.success(successText); message.success(successText);
}; };
+7 -5
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { App } from "antd"; import { App } from "antd";
import { useTranslation } from "react-i18next";
import { APP_VERSION } from "@/constant/env"; import { APP_VERSION } from "@/constant/env";
import { parseChangelog, type ReleaseInfo } from "@/lib/release"; import { parseChangelog, type ReleaseInfo } from "@/lib/release";
@@ -23,6 +24,7 @@ function isNewerVersion(latestVersion: string, currentVersion: string) {
} }
export function useVersionCheck() { export function useVersionCheck() {
const { t } = useTranslation();
const currentVersion = APP_VERSION; const currentVersion = APP_VERSION;
const { message } = App.useApp(); const { message } = App.useApp();
const localReleases = useMemo(readLocalReleases, []); const localReleases = useMemo(readLocalReleases, []);
@@ -49,23 +51,23 @@ export function useVersionCheck() {
setChecking(true); setChecking(true);
try { try {
const [versionResponse, changelogResponse] = await Promise.all([fetch(latestVersionUrl), fetch(latestChangelogUrl)]); const [versionResponse, changelogResponse] = await Promise.all([fetch(latestVersionUrl), fetch(latestChangelogUrl)]);
if (!versionResponse.ok) throw new Error("版本读取失败"); if (!versionResponse.ok) throw new Error(t("version.readFailed"));
if (!changelogResponse.ok) throw new Error("更新日志读取失败"); if (!changelogResponse.ok) throw new Error(t("version.changelogFailed"));
const [version, changelog] = await Promise.all([versionResponse.text(), changelogResponse.text()]); const [version, changelog] = await Promise.all([versionResponse.text(), changelogResponse.text()]);
setLatestVersion(version.trim() || currentVersion); setLatestVersion(version.trim() || currentVersion);
if (changelog.trim()) setReleases(parseChangelog(changelog)); if (changelog.trim()) setReleases(parseChangelog(changelog));
if (showMessage) message.success("已获取最新版本信息"); if (showMessage) message.success(t("version.updated"));
return true; return true;
} catch { } catch {
setLatestVersion(currentVersion); setLatestVersion(currentVersion);
setReleases(localReleases); setReleases(localReleases);
if (showMessage) message.error("获取最新版本信息失败"); if (showMessage) message.error(t("version.updateFailed"));
return false; return false;
} finally { } finally {
setChecking(false); setChecking(false);
} }
}, },
[currentVersion, localReleases, message], [currentVersion, localReleases, message, t],
); );
useEffect(() => { useEffect(() => {
+29
View File
@@ -0,0 +1,29 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import enUS from "@/i18n/locales/en-US";
import zhCN from "@/i18n/locales/zh-CN";
export type AppLocale = "zh-CN" | "en-US";
const LOCALE_STORAGE_KEY = "infinite-canvas:locale";
i18n.use(initReactI18next).init({
resources: {
"zh-CN": { translation: zhCN },
"en-US": { translation: enUS },
},
lng: (localStorage.getItem(LOCALE_STORAGE_KEY) as AppLocale) || "zh-CN",
fallbackLng: "zh-CN",
supportedLngs: ["zh-CN", "en-US"],
initAsync: false,
interpolation: { escapeValue: false },
react: { useSuspense: false },
});
export function changeAppLocale(locale: AppLocale) {
localStorage.setItem(LOCALE_STORAGE_KEY, locale);
return i18n.changeLanguage(locale);
}
export default i18n;
+589
View File
@@ -0,0 +1,589 @@
export default {
meta: {
title: "Infinite Canvas",
description: "An infinite canvas creation tool",
},
theme: { toggle: "Toggle theme" },
common: {
cancel: "Cancel",
save: "Save",
edit: "Edit",
done: "Done",
delete: "Delete",
copy: "Copy",
copied: "Copied",
details: "Details",
addToAssets: "Add to My Assets",
addedToAssets: "Added to My Assets",
copyPrompt: "Copy prompt",
promptCopied: "Prompt copied",
created: "Created: {{date}}",
updated: "Updated: {{date}}",
all: "All",
view: "View",
download: "Download",
upload: "Upload",
requestCanceled: "Request canceled",
durationMinutes: "{{minutes}}m {{seconds}}s",
durationSeconds: "{{seconds}}s",
imageReadFailed: "Failed to read image",
},
settingsPanels: {
common: { auto: "Auto", low: "Low", medium: "Medium", high: "High", xhigh: "Extra high" },
image: { title: "Image settings", quality: "Quality", size: "Size", align16: "Align to multiples of 16", align16Hint: "Round dimensions up to the next multiple of 16 after input", aspectRatio: "Aspect ratio", transparent: "Transparent background", transparentHint: "Generate an image without a background when supported by the model", count: "Image count", images: "{{count}} images" },
video: { title: "Video settings", quality: "Quality", size: "Size", seconds: "Seconds", resolution: "Resolution", ratio: "Aspect ratio", duration: "Duration", smart: "Smart", output: "Output", generateAudio: "Generate audio", watermark: "Add watermark", adaptive: "Adaptive", sizes: { landscape: "Landscape", portrait: "Portrait", square: "Square", widescreen: "Widescreen", tall: "Tall", auto: "Auto" }, ratios: { landscape: "Landscape", portrait: "Portrait", square: "Square", standardLandscape: "Standard landscape", standardPortrait: "Standard portrait", cinematic: "Cinematic", adaptive: "Adaptive" } },
audio: { title: "Audio settings", voice: "Voice", format: "Format", speed: "Speed", instructions: "Voice instructions", instructionsPlaceholder: "For example: natural, warm, and suitable for narration." },
text: { title: "Text settings", reasoning: "Reasoning effort" },
model: { select: "Select model", assign: "Assign a model for {{capability}} in the provider settings", noMatch: "No matching {{capability}} models", addFirst: "Add a provider and models in Settings first", capabilities: { image: "image generation", video: "video", text: "text", audio: "audio" } },
},
generation: { pending: ["Creating image", "Almost there", "Just a little longer", "Refining details"] },
imageReferences: { label: "Image {{index}}", separator: ", ", promptPrefix: "Reference image labels: {{labels}}. Use these labels to interpret image references in the prompt.\n\n{{prompt}}" },
seedance: { autoMatch: "Automatic", separator: ", ", references: { image: "Image {{index}}", video: "Video {{index}}", audio: "Audio {{index}}" }, promptPrefix: "Reference asset labels: {{labels}}. Use these labels to interpret image, video, and audio references in the prompt.\n\n{{prompt}}", referenceHint: "Reference videos must be mp4/mov, H.264/H.265, and 2460 FPS. Use authorized Volcengine asset:// assets when real faces are included.", errors: { format: "{{label}} supports mp4/mov only", size: "{{label}} exceeds 200 MB. Compress it before uploading.", duration: "{{label}} must be 215 seconds", dimensions: "{{label}} dimensions must be between 300 and 6000px", ratio: "{{label}} aspect ratio must be between 0.4 and 2.5", pixels: "{{label}} total pixel count must be between 409600 and 8295044", totalDuration: "Seedance reference videos cannot exceed 15 seconds in total" } },
modelPlugin: {
pollTimeout: "Plugin polling timed out. Check the request script or try again later.", executionFailed: "Model request script failed: {{message}}", noImages: "The model request script did not return any images",
variables: { prompt: "User prompt with the system prompt already included", images: "Reference images as a data URL array, available for image editing and image-to-video", messages: "Conversation message array including the system message", params: "Generation parameters: image {size,quality,count}, video {seconds,size,resolution,ratio,generateAudio,watermark}, audio {voice,format,speed,instructions}", model: "Model name without the provider prefix", baseUrl: "Provider endpoint as entered, without appending /v1", apiKey: "Provider API key; add it to request headers yourself", systemPrompt: "Original system prompt", reasoningEffort: "Text reasoning effort; auto lets the script decide whether to send it", http: "Convenience client: http.post(path, body, {headers,params,responseType}), http.get(path, opts), and http.url(path). Authorization: Bearer apiKey is included by default and can be overridden. Relative paths append /v1 to baseUrl.", request: "Raw request({ method, url, headers, params, data, responseType }) with no default headers. Add authentication yourself. Relative URLs are joined to baseUrl without /v1.", poll: "poll(request, extract, {intervalMs,timeoutMs}) continues until extract returns a truthy value", sleep: "Delay with sleep(ms)", signal: "Cancellation signal that can be passed to http/request", onDelta: "Push streaming text with onDelta(text) for text models" },
returns: { image: "Text-to-image and image editing use different APIs; distinguish them by whether images is empty. Return an image URL or data URL, an array of them, or [{ dataUrl }] / [{ url }] / [{ b64_json }].", video: "Poll inside the script and return { url }, { blob }, or a video URL string.", audio: "Return a Blob, base64/data URL string, or { b64_json } / { data } / { url }.", text: "Push streaming output with onDelta(text), then return the complete text string." },
templates: { openai: "OpenAI format", gemini: "Gemini format", imageOpenai: "Image generation and editing use different endpoints; distinguish them by whether images is empty.", availableImage: "Available: prompt, images(dataURL[]), params{size,quality,count}, model, baseUrl, apiKey", textToImage: "Text to image: /images/generations (JSON)", imageToImage: "Image editing: /images/edits (multipart/form-data with reference images uploaded as files)", formDataHeader: "Do not set Content-Type manually; let the browser add the boundary", imageGemini: "Gemini image generation and editing both use generateContent, with references in parts.inline_data.", availableImageGemini: "Available: prompt, images(dataURL[]), model, baseUrl, apiKey", videoOpenai: "Video with polling handled inside the script. Available: prompt, images(dataURL[]), params{seconds,size,resolution,ratio}", videoGemini: "Gemini (Veo) video: submit with predictLongRunning and poll the operation for the video URI.", availableVideoGemini: "Available: prompt, images(dataURL[]), params, model, baseUrl, apiKey", geminiNoVideoUri: "Gemini did not return a video URI", audioOpenai: "Audio TTS. Available: prompt, params{voice,format,speed,instructions}, model", audioGemini: "Gemini TTS: generateContent with AUDIO modality, returning base64 PCM in inlineData.data.", availableAudioGemini: "Available: prompt, params{voice}, model, baseUrl, apiKey", geminiNoAudio: "Gemini did not return audio", textOpenai: "Text chat using the OpenAI Responses API. Available: messages([{role,content}]), systemPrompt, model, reasoningEffort", textGemini: "Gemini text: generateContent with the system message in systemInstruction.", availableTextGemini: "Available: messages([{role,content}]), systemPrompt, model, baseUrl, apiKey" },
},
apiErrors: { requestFailed: "Request failed", requestCanceled: "Request canceled", baseUrlRequired: "Configure the Base URL first", apiKeyRequired: "Configure the API key first", authenticationFailed: "Authentication failed. Check the API key, plan permissions, and model permissions.", rateLimited: "The request was rate-limited or the quota is insufficient. Try again later.", notFound: "The endpoint was not found (404). Check the Base URL and selected model.", badGateway: "Gateway error (502). The API service is temporarily unavailable.", serviceBusy: "Service unavailable (503). Try again later.", httpFailed: "Request failed (HTTP {{status}}). Check the Base URL and API key.", htmlError: "The service returned an HTML error page ({{preview}})", audioModelRequired: "Configure an audio model first", audioGenerationFailed: "Audio generation failed", scriptNoAudio: "The model request script did not return audio", geminiAudioUnsupported: "The Gemini API format does not support audio generation. Use an OpenAI-format provider.", invalidImageSizeFormat: "Unsupported image size. Use auto, 9:16, or 1024x1024.", positiveImageRatio: "The image ratio must use positive numbers, such as 9:16.", imageRatioLimit: "The image aspect ratio cannot exceed 3:1. Adjust the dimensions.", positiveImageDimensions: "Image dimensions must be positive integers, such as 1024x1024.", imageDimensionStep: "Image width and height must be multiples of 16.", imageEdgeLimit: "The longest image edge cannot exceed 3840px.", imagePixelLimit: "The total image pixel count must be between 655360 and 8294400.", unknownImageResponse: "The API returned data in an unknown format (fields: {{fields}}). Check model or API compatibility.", noImageReturned: "The API did not return an image. The prompt may have triggered safety review or the model may not support this operation.", geminiRejected: "Gemini rejected the request: {{reason}}", geminiNoImage: "The Gemini API did not return an image", geminiMaskUnsupported: "The Gemini API format does not support mask editing", maskModelUnsupported: "This model does not support mask editing. Use another provider.", noContent: "No content returned", modelReadFailed: "Failed to load models", videoTimeout: "{{provider}}video generation timed out. Try again later.", videoReferencesUnsupported: "This video API does not support reference video or audio. Switch to Seedance 2.0 / Volcengine Agent Plan, or remove the reference assets.", pluginVideoExpired: "The plugin video task has expired. Generate it again.", scriptNoVideo: "The model request script did not return a video", noPlayableVideo: "The video API did not return a playable video", noVideoTaskId: "The video API did not return a task ID", videoTaskCreateFailed: "Failed to create video task", videoGenerationFailed: "Video generation failed", videoTaskQueryFailed: "Failed to query video task", seedanceAudioRequiresVisual: "Seedance reference audio cannot be used alone. Add a reference image or video.", videoPromptRequired: "Enter a video prompt or connect a reference image, video, or audio asset", seedanceNoTaskId: "The Seedance API did not return a task ID", seedanceTaskCreateFailed: "Failed to create Seedance task", seedanceNoVideoUrl: "The Seedance task succeeded but did not return a video URL", seedanceVideoTimeout: "Seedance video generation timed out", seedanceVideoFailed: "Seedance video generation failed", seedanceTaskQueryFailed: "Failed to query Seedance task", seedanceVideoDuration: "Each Seedance reference video must be 215 seconds", seedanceVideoTotalDuration: "Seedance reference videos cannot exceed 15 seconds in total", seedanceAudioDuration: "Each Seedance reference audio file must be 215 seconds", seedanceAudioTotalDuration: "Seedance reference audio cannot exceed 15 seconds in total", referenceImageReadFailed: "Failed to read the reference image. Choose another image or upload it again.", invalidReferenceVideo: "A reference video must be a public URL, asset ID, or locally saved video", invalidReferenceAudio: "Reference audio must be a public URL, asset ID, or locally saved audio file", videoModelRequired: "Configure a video model first", geminiVideoUnsupported: "The Gemini API format does not support video generation. Use an OpenAI-format provider.", noVideoTask: "The API did not return a video task", seedanceNoTask: "The Seedance API did not return a task", videoDownloadFailed: "Failed to download video", localAssetReadFailed: "Failed to read local asset" },
prompts: {
title: "Prompt Center",
library: "Prompt Library",
total: "{{count}} prompts",
category: "Category",
tags: "Tags",
search: "Search titles, content, or tags",
searchTitle: "Search by title",
loadFailed: "Failed to load prompts",
sourceMissing: "Prompt source not found",
use: "Use this prompt",
empty: "No matching prompts found",
loading: "Loading...",
loadMore: "Scroll down to load more",
end: "You've reached the end",
},
assets: {
title: "My Assets",
description: "Save frequently used text and images, then find them quickly by type, title, or tag.",
search: "Search titles, content, tags, or sources",
type: "Type",
export: "Export assets",
import: "Import assets",
add: "Add asset",
edit: "Edit asset",
empty: "No assets found",
manual: "Added manually",
selectImage: "Select an image file",
updated: "Asset updated",
saved: "Asset saved",
textCopied: "Text copied",
noneToExport: "No assets to export",
imported: "Imported {{count}} assets",
importFailed: "Import failed. Select a valid asset package.",
deleted: "Asset deleted",
packageName: "my-assets.zip",
kinds: { text: "Text", image: "Image", video: "Video" },
fields: {
title: "Title",
titleRequired: "Enter a title",
titlePlaceholder: "Give the asset an easy-to-find name",
coverUrl: "Cover URL",
coverPlaceholder: "Paste an image URL or upload a local cover",
tags: "Tags",
tagsPlaceholder: "Type a tag and press Enter",
source: "Source",
sourcePlaceholder: "Added manually / Canvas / Prompt Library",
note: "Note",
optional: "Optional",
textContent: "Text content",
textRequired: "Enter text content",
textPlaceholder: "Save prompts, copy, reference descriptions, and other text assets",
imageContent: "Image content",
},
selectImageFile: "Select image file",
noImageSelected: "No image selected",
preview: "Preview",
noCover: "No cover",
untitled: "Untitled asset",
noTags: "No tags",
untagged: "Untagged",
unknownSource: "Source not specified",
details: "Asset details",
deleteTitle: "Delete asset",
deleteConfirm: "Delete “{{name}}”? It will be removed from My Assets.",
copyText: "Copy text",
downloadImage: "Download image",
downloadVideo: "Download video",
},
notFound: {
title: "Page not found",
description: "There is no page at this address. It may have moved or been merged into another section.",
home: "Back to home",
},
workbench: {
logs: "Generation history",
settings: "Settings",
prompt: "Prompt",
viewPrompts: "Browse prompts",
viewAssets: "Browse My Assets",
clipboard: "Clipboard",
upload: "Upload",
adjust: "Adjust",
generate: "Generate",
results: "Results",
waiting: "Waiting {{time}}",
model: "Model",
generating: "Generating",
success: "Succeeded",
failed: "Generation failed",
retry: "Retry",
new: "New",
selectAll: "Select all",
noLogs: "No generation history",
deleteLogs: "Delete generation history",
deleteLogsConfirm: "Delete the selected {{count}} generation records?",
successCount: "{{count}} succeeded",
failCount: "{{count}} failed",
itemCount: "{{count}} images",
untitled: "Untitled",
configFirst: "Complete the configuration first",
generationFailed: "Generation failed",
retrySuccess: "Retry succeeded",
},
imageWorkbench: {
title: "Image Studio",
promptPlaceholder: "Describe the subject, style, composition, lighting, and intended use",
references: "Reference images",
removeReference: "Remove reference image",
dropReferences: "Drop to add reference images",
noReferences: "No reference images. Drag images here to add them.",
clipboardEmpty: "No readable images found on the clipboard",
clipboardAdded: "Added {{count}} reference images",
promptRequired: "Enter an image prompt",
configIncomplete: "Image generation configuration is incomplete",
invalidParams: "Invalid image generation parameters",
busy: "The Image Studio is already running a task",
generated: "Images generated",
addedReference: "Added to reference images",
resultTitle: "Generated image {{count}}",
source: "Image Studio",
unsupportedAsset: "Image Studio only supports text and image assets",
missingResult: "The API returned no image",
empty: "No images generated yet",
resultAlt: "Generated image {{count}}",
addReference: "Add as reference",
},
videoWorkbench: {
title: "Video Studio",
promptPlaceholder: "Describe camera movement, subject action, atmosphere, and visual style",
references: "Reference images",
videoReferences: "Reference videos",
audioReferences: "Reference audio",
removeImage: "Remove reference image",
removeVideo: "Remove reference video",
removeAudio: "Remove reference audio",
dropReferences: "Drop to upload reference assets",
noImages: "No reference images. Drag in up to 9 files.",
noVideos: "No reference videos. Drag in up to 3 files.",
noAudio: "No reference audio. Drag in up to 3 MP3/WAV files, each under 15 MB.",
unsupportedFiles: "Unsupported reference assets were ignored. Use images, MP4/MOV video, or MP3/WAV audio.",
imageTooLarge: "Reference images over 30 MB were ignored",
videoTooLarge: "Reference videos over 200 MB were ignored",
audioTooLarge: "Reference audio over 15 MB was ignored",
audioDurationInvalid: "Reference audio outside the duration limits was ignored: 215 seconds each, 15 seconds total.",
clipboardEmpty: "No readable images found on the clipboard",
clipboardAdded: "Added {{count}} reference images",
promptRequired: "Enter a video prompt",
referenceError: "{{error}}. {{hint}}",
invalidParams: "Invalid video generation parameters",
busy: "The Video Studio is already running a task",
generated: "Video generated",
timeout: "Video generation timed out. Try again later.",
resultTitle: "Generated video",
source: "Video Studio",
empty: "No video generated yet",
},
canvas: {
defaultTitle: "Infinite Canvas {{count}}",
library: "Canvas library",
title: "Infinite Canvas",
imported: "Imported {{count}} canvases",
importFailed: "Import failed. Select a valid canvas package.",
opening: "Opening canvas...",
loading: "Loading canvases...",
exportSelected: "Export selected",
deleteSelected: "Delete selected",
deleteAll: "Delete all",
import: "Import canvas",
create: "New canvas",
empty: "No canvases yet",
emptyDescription: "Create a canvas to save its nodes, connections, and appearance independently.",
collapsePanel: "Collapse panel",
expandPanel: "Expand panel",
home: "Home",
docs: "Documentation",
projects: "My Canvases",
deleteCurrent: "Delete current canvas",
importAsset: "Import asset",
exportCurrent: "Export current canvas",
undo: "Undo",
redo: "Redo",
openMenu: "Open canvas menu",
renameHint: "Double-click to rename the canvas",
shortcuts: "Keyboard shortcuts",
miniMapOpen: "Open minimap",
miniMapClose: "Close minimap",
resetView: "Reset view",
zoom: "Zoom canvas",
agentConnected: "Codex connected",
agentConnecting: "Codex {{activity}}",
agentDisconnected: "Codex disconnected",
connecting: "Connecting",
openAgent: "Open local Codex panel",
nodeTypes: { image: "Image", text: "Text", config: "Generation config", video: "Video", audio: "Audio", group: "Group" },
toolbar: {
move: "Move/select", text: "Text", image: "Image", video: "Video", audio: "Audio", config: "Generation config", group: "Group", extensions: "Extension nodes", upload: "Upload assets", appearance: "Canvas appearance", clear: "Clear canvas",
themeMode: "Theme", light: "Light", dark: "Dark", gridStyle: "Grid style", dots: "Dots", lines: "Lines", blank: "Blank", imageInfo: "Image info",
},
project: {
untitled: "Untitled canvas", imported: "Imported canvas",
select: "Select {{name}}", stats: "{{nodes}} nodes · {{connections}} connections", updated: "Updated {{date}}", saveName: "Save name", cancelRename: "Cancel rename", export: "Export", rename: "Rename", delete: "Delete",
deleteTitle: "Delete canvases?", deleteDescription: "This will delete {{count}} canvases along with their nodes and connections.",
},
export: { defaultProjectName: "Infinite Canvas", defaultNodesName: "Canvas elements", item: "Element" },
createMenu: {
fromNode: "Generate from this node", close: "Close", text: "Generate text", textDescription: "Scripts, ad copy, and brand content", image: "Generate image", video: "Generate video", audio: "Audio reference", config: "Configuration node", configDescription: "Model, size, count, and input order", select: "Select a node",
},
node: {
node: "Node",
untitled: "Untitled node", renameHint: "Double-click to rename the node", group: "Group", nodeCount: "{{count}} nodes", generating: "Generating", failed: "Generation failed", retry: "Retry", missingPlugin: "Plugin missing", missingPluginDescription: "The plugin for node type “{{type}}” is not installed or enabled", generateImage: "Generate image from text", generate: "Generate", editText: "Double-click to edit text", emptyImage: "Empty image node", emptyVideo: "Empty video node", emptyAudio: "Empty audio node", audio: "Audio", batchExpanded: "Image group expanded", batchCollapsed: "Image group collapsed", setPrimary: "Set as primary",
},
sidePanel: {
canvas: "Canvas", assets: "Assets", prompts: "Prompt Library", resize: "Resize left panel", elements: "Canvas elements", select: "Select", searchNodes: "Search nodes", focusNode: "Focus node", preview: "Large preview", noNodes: "No nodes on this canvas", clearAll: "Clear all", selected: "{{count}} selected", exporting: "Exporting selected elements…", exportName: "canvas-elements-{{count}}", exported: "Exported {{count}} elements", exportFailed: "Export failed. Try again.",
addingAssets: "Adding assets…", addedAssets: "Added {{count}} assets", mediaOnly: "Only image and video files are supported", addFailed: "Failed to add assets. Try again.", searchAssets: "Search assets", add: "Add", noAssets: "No assets", inserted: "Insert into canvas", removeAssetTitle: "Remove this asset?", remove: "Remove", removeAsset: "Remove asset", assetRemoved: "Asset removed",
searchPrompts: "Search prompts", noPrompts: "No prompts", promptCopied: "Prompt copied", copyFailed: "Copy failed", loadFailedRetry: "Load failed. Click to retry.", noMatchingPrompts: "No matching prompts", sourceEmpty: "No prompts from this source", viewDetails: "View details",
filter: { image: "Image", video: "Video", text: "Text", audio: "Audio", config: "Configuration", group: "Group" },
},
assetPicker: { title: "Select assets", insert: "Insert", search: "Search assets", empty: "No assets" },
imageTools: { copyPrompt: "Copy prompt", copyPromptTitle: "Copy the prompt used to generate this image", reversePrompt: "Reverse prompt", reversePromptTitle: "Create text and configuration nodes to infer the prompt", replace: "Replace image", locked: "Lock ratio", free: "Free ratio", lockTitle: "Switch to proportional scaling", freeTitle: "Switch to free resizing", mask: "Local edit", maskTitle: "Paint a mask and edit the selected area", crop: "Crop", cropTitle: "Crop into a new node", split: "Split", splitTitle: "Split the image by rows and columns", upscale: "Upscale", upscaleTitle: "Increase image resolution", superResolve: "Super resolution", superResolveTitle: "AI super resolution", angle: "Multi-angle", angleTitle: "Generate another angle", view: "View image", viewTitle: "View image details", more: "More", configure: "Configure quick tools", customize: "Customize toolbar", showLabels: "Show button labels", description: "Choose the quick tools shown in the image-node toolbar.", preview: "Node preview", imageNode: "Image node", quickTools: "Quick tools" },
nodeToolbar: { noPrompt: "No prompt to copy", infoTitle: "View node information", info: "Info", removeTitle: "Remove node", retryTitle: "Generate again", saveAsset: "Save asset", downloadAudio: "Download audio", downloadVideo: "Download video", downloadImage: "Download image", editTextTitle: "Edit text", editText: "Edit text", decreaseFont: "Decrease font size", increaseFont: "Increase font size", zoomOut: "Smaller", zoomIn: "Larger", uploadImage: "Upload image", replaceVideo: "Replace video", uploadVideo: "Upload video", replaceAudio: "Replace audio", uploadAudio: "Upload audio", nodeInfo: "Node information", name: "Name", type: "Type", size: "Size", position: "Position", status: "Status", imageGroup: "Image group", imageSize: "Image size" },
configNode: { title: "Generation config", image: "Image", text: "Text", video: "Video", audio: "Audio", prompt: "Prompt", references: "Reference images", videoReferences: "Reference videos", audioReferences: "Reference audio", items: "{{count}} items", images: "{{count}} images", compose: "Compose prompt", stop: "Stop", generate: "Generate" },
projectPage: {
stopTitle: "Stop generation?", stopDescription: "The current request will be interrupted. Completed results will be kept.", stop: "Stop", continue: "Continue generating", configConnection: "Configuration nodes cannot be connected to each other", notFound: "Current canvas not found", exporting: "Exporting current canvas…", exported: "Current canvas exported", clipboardText: "Clipboard text", clipboardImageAdded: "Added image from clipboard", clipboardTextAdded: "Added text from clipboard", noTextToSave: "No text to save", canvasText: "Canvas text", noVideoToSave: "No video to save", canvasVideo: "Canvas video", noImageToSave: "No image to save", canvasImage: "Canvas image", emptyReverse: "The image node is empty, so its prompt cannot be inferred", reverseTitle: "Reverse prompt", reverseConfigTitle: "Reverse prompt configuration", splitTitle: "{{name}} {{row}}-{{column}}", splitSuccess: "Split into {{count}} child nodes", maskResult: "Local edit result", maskFailed: "Local edit failed", generationFailed: "Generation failed", partialFailed: "Some images failed to generate", allFailed: "All images failed to generate", retryPromptMissing: "No prompt found for retry", referenceMissing: "The reference image is missing, so retry cannot continue", emptyTextImage: "The text node is empty, so an image cannot be generated", untitledCanvas: "Untitled canvas", superResolve: "AI super resolution", notImplemented: "Not implemented yet", imageDetails: "Image details", clearTitle: "Clear canvas?", clear: "Clear", clearDescription: "This will delete every node and connection on the current canvas.", reversePreset: "Infer a prompt suitable for AI image generation from the reference image.\n\nRequirements:\n1. Output only the prompt, with no explanation.\n2. Cover the subject, composition, style, lighting, color, materials, lens, and atmosphere.\n3. Write a complete prompt that can be used directly with an image model.", maskPrompt: "Only modify the transparent masked area and keep everything else unchanged. {{prompt}}", editTextPrompt: "Revise the text according to the instructions.\n\nOriginal:\n{{source}}\n\nInstructions:\n{{prompt}}"
},
reverseComposer: "Reference image: @[node:{{imageId}}]\nTask: @[node:{{textId}}]",
editors: {
reset: "Reset", zoomOut: "Zoom out", zoomIn: "Zoom in", loading: "Loading", unknown: "Unknown", cancel: "Cancel",
angleTitle: "AI Multi-angle", angleDescription: "The left side previews direction only; the result is regenerated from the original image", horizontal: "Horizontal angle", pitch: "Pitch angle", distance: "Camera distance", lens: "Wide-angle lens", standard: "Standard", wide: "Wide", aiGenerate: "Generate with AI",
upscaleTitle: "Upscale image", source: "Source", targetPixels: "Target pixels", maxReached: "The image is already 4K and does not need upscaling", targetReached: "The image already meets the target pixel count", algorithm: "Upscaling algorithm", outputSize: "Output size", upscale: "Generate upscaled image", high: "High-quality interpolation", highDescription: "Best for photos and detailed images", bilinear: "Bilinear", bilinearDescription: "Smooth and fast", nearest: "Nearest neighbor", nearestDescription: "Best for pixel art",
cropTitle: "Crop image", adjustCrop: "Adjust crop box", cropHint: "Mouse wheel to zoom · middle button or Space + left drag to pan", cropSize: "Crop size {{size}}", ratio: "Ratio {{ratio}}", original: "Original {{width}} x {{height}}", confirmCrop: "Crop", free: "Free", fixed: "Fixed", originalMode: "Original",
maskTitle: "Local mask edit", maskHint: "Mouse wheel to zoom · middle button or Space + left drag to pan · Alt + left/right drag to resize brush · Ctrl/Cmd+Z to undo · Ctrl/Cmd+Shift+Z to redo", brush: "Brush", erase: "Erase", undoMaskTitle: "Undo mask stroke (Ctrl/Cmd+Z)", undoMask: "Undo mask stroke", redoMaskTitle: "Redo mask stroke (Ctrl/Cmd+Shift+Z)", redoMask: "Redo mask stroke", brushSize: "Brush size", editInstructions: "Edit instructions", maskPlaceholder: "For example: change the selected area to metal while preserving the original lighting", maskPromptRequired: "Enter edit instructions", maskRequired: "Paint the area to edit first", aiEdit: "Edit with AI",
splitTitle: "Split image", splitDescription: "Create {{count}} image nodes and arrange them in the original grid to the right of the canvas", splitHint: "Mouse wheel to zoom · middle button or Space + left drag to pan · Delete removes the selected line · Ctrl/Cmd+Z to undo · Ctrl/Cmd+Shift+Z to redo", undoSplitTitle: "Undo split adjustment (Ctrl/Cmd+Z)", undoSplit: "Undo split adjustment", redoSplitTitle: "Redo split adjustment (Ctrl/Cmd+Shift+Z)", redoSplit: "Redo split adjustment", rows: "Rows", columns: "Columns", horizontalLine: "Horizontal line", verticalLine: "Vertical line", deleteLine: "Delete line", resetLines: "Reset lines", pieceCount: "Pieces", pieces: "{{count}}", averageSize: "Average size", generateChildren: "Create child nodes",
},
plugins: { title: "Node plugins", installedPlugin: "Installed plugin {{name}}", installed: "Installed {{name}}", installFailed: "Installation failed: {{error}}", enabled: "Enabled", disabled: "Disabled", upgradeAvailable: "New version available — click to upgrade", updateFromSource: "Update from source", updated: "Updated", uninstallTitle: "Uninstall this plugin?", uninstall: "Uninstall", newVersion: "A new version is available", officialDescription: "Official plugins from this project's registry", refresh: "Refresh", loadFailed: "Failed to load: {{error}}", loadingOfficial: "Loading official plugins…", noOfficial: "No official plugins", install: "Install", urlPlaceholder: "Enter a plugin JavaScript URL, for example https://.../plugin.js", noThirdParty: "No third-party plugins installed", official: "Official", local: "Local", thirdParty: "Third-party", warning: "Plugin code runs directly on this page and can access local data, including your AI API key. Only install plugins from sources you trust.", aiConfigRequired: "AI configuration is not ready. Configure a model and API key in Settings first.", interactiveTitle: "Interaction mode is active. Click to switch to Move mode and drag the node.", movableTitle: "Move mode is active. Click to switch to Interaction mode and operate the node content.", move: "Move", interact: "Interact" },
promptPanel: { video: "Describe the video you want to generate", audio: "Describe the audio you want to generate", image: "Describe the image you want to generate", text: "Describe the text you want to generate", editImage: "Describe how you want to change this image", editText: "Describe how you want to revise this text", stopGeneration: "Stop generation", generate: "Generate", stop: "Stop" },
composer: { title: "Compose prompt", description: "Use @ to reference connected assets; references are renumbered before sending", placeholder: "Enter a prompt and use @ to reference connected images or text", imagePreview: "Referenced image preview", resources: { image: "Image {{index}}", video: "Video {{index}}", audio: "Audio {{index}}", text: "Text {{index}}" } },
controls: { ratio: "Ratio", duplicate: "Duplicate", delete: "Delete", images: "{{count}} images", reasoning: "Reasoning" },
generation: { interrupted: "Generation was interrupted by a page refresh. Generate again.", front: "front view", rotateRight: "rotated {{angle}} degrees right", rotateLeft: "rotated {{angle}} degrees left", level: "eye-level view", topDown: "{{angle}}-degree top-down view", lowAngle: "{{angle}}-degree low-angle view", angleLabel: "AI multi-angle: {{horizontal}}, {{pitch}}, camera distance {{distance}}, {{lens}} lens", anglePrompt: "Regenerate a new view of the same subject from the reference image. Preserve the subject, colors, materials, and visual style; do not merely apply perspective distortion. {{angle}}." },
agentOps: { add_node: "Add node", update_node: "Update node", delete_node: "Delete node", delete_connections: "Delete connections", connect_nodes: "Connect", set_viewport: "Adjust view", select_nodes: "Select nodes", run_generation: "Run generation" },
pluginErrors: { invalidExport: "The plugin did not export a valid object", missingFields: "The plugin is missing id or nodes", downloadFailed: "Download failed (HTTP {{status}})", registryFailed: "Failed to load the official plugin registry (HTTP {{status}})" },
shortcut: {
dragCanvas: "Drag canvas",
pan: "Pan view",
wheel: "Mouse wheel",
zoom: "Zoom canvas",
zoomSlider: "Zoom slider",
preciseZoom: "Adjust zoom precisely",
boxSelect: "Select multiple nodes",
addSelection: "Add nodes to selection",
selectAll: "Select all nodes",
copyPaste: "Copy/paste nodes or paste clipboard text/images",
copyPasteNodes: "Copy/paste nodes",
delete: "Delete selection",
escape: "Clear selection and close overlays",
dropMedia: "Drop images/videos/audio",
upload: "Upload to canvas",
drag: "Drag",
click: "Click",
},
},
navigation: {
canvas: "My Canvases",
image: "Image Studio",
video: "Video Studio",
prompts: "Prompt Library",
assets: "My Assets",
config: "Settings",
},
topNav: {
openMenu: "Open navigation menu",
menu: "Navigation menu",
navigation: "Navigation",
openAgent: "Open Agent",
closeAgent: "Close Agent",
plugins: "Node plugins",
docs: "Documentation",
shortcuts: "Keyboard shortcuts",
lightTheme: "Switch to light theme",
darkTheme: "Switch to dark theme",
},
home: {
promptError: "Failed to load prompts",
description: "Generate, connect, and reshape <content>images, text, and graphics</content> in <canvas>Infinite Canvas</canvas>, turning one-off generations into a continuous creative process.",
start: "Get started",
openCanvas: "Open canvas",
showcaseTitle: "Keep every great result",
showcaseDescription: "Save reliable prompts, visual references, and generated images so your next creation starts from proven ideas.",
viewPrompts: "View prompt library",
},
version: {
viewUpdates: "View release updates",
title: "Release updates",
currentVersion: "Current version",
latestVersion: "Latest version",
checking: "Checking...",
checkUpdates: "Check for updates",
unreleased: "Unreleased",
latest: "Latest",
current: "Current",
readFailed: "Failed to read version",
changelogFailed: "Failed to read changelog",
updated: "Latest version information loaded",
updateFailed: "Failed to load the latest version information",
types: { added: "Added", fixed: "Fixed", changed: "Changed", optimized: "Optimized", docs: "Docs" },
},
config: {
title: "Settings & Preferences",
invalidFile: "The settings file format is invalid",
description: "Provider aggregation, model selection, and sync preferences",
modalDescription: "Provider aggregation, default models, and sync preferences",
tabs: {
channels: "Providers",
preferences: "Preferences",
promptSources: "Prompt sources",
},
promptSources: {
add: "Add source",
deleteTitle: "Delete “{{name}}”?",
deleteDescription: "The source configuration will be removed. Items already saved to My Assets are unaffected.",
refreshed: "Updated {{count}} prompts from “{{name}}”",
refreshFailedCached: "Update failed; the previous cache was kept",
refreshPartial: "Update complete: {{success}} succeeded and {{failed}} failed. Failed sources kept their previous cache.",
refreshAllSuccess: "Updated {{sources}} sources with {{total}} prompts",
refreshFailed: "Update failed",
builtIn: "Built-in",
itemCount: "{{count}} prompts",
failed: "Failed",
healthy: "Healthy",
unsynced: "Not synced",
lastSuccess: "Last successful {{time}}",
neverFetched: "Not fetched yet",
view: "View content",
refresh: "Fetch now",
edit: "Edit source",
schedule: "Scheduled fetch",
interval: "Fetch interval",
refreshAll: "Fetch all now",
lastFetched: "Last fetched {{time}}",
neverScheduled: "No scheduled fetch yet",
scheduleDescription: "When enabled, all active sources are fetched on this interval while the page is open.",
intervals: {
disabled: "Disabled",
minutes30: "Every 30 minutes",
hour1: "Every hour",
hours6: "Every 6 hours",
hours24: "Every 24 hours",
},
editor: {
addTitle: "Add prompt source",
editTitle: "Edit prompt source",
nameRequired: "Enter a source name",
invalidUrl: "Enter a valid JSON URL",
invalidHomepage: "Enter a valid homepage URL",
name: "Source name",
namePlaceholder: "Used for categories and labels",
homepage: "Source homepage (optional)",
enabled: "Enable source",
jsonFormat: "JSON format",
},
content: {
loadFailed: "Failed to fetch prompts",
title: "{{name}} · Prompt content",
count: "{{count}} prompts",
refresh: "Update now",
empty: "No prompts",
cover: "Cover",
titleColumn: "Title",
tags: "Tags",
actions: "Actions",
},
runtime: { requestFailed: "Request failed ({{status}})", urlRequired: "JSON URL is required", fetchFailed: "Failed to fetch “{{name}}”: {{error}}", noPrompts: "No valid prompts were parsed from “{{name}}”", invalidRoot: "Invalid “{{name}}” format: the root value must be an array" },
},
fileSecurity: "The JSON file contains API keys and WebDAV credentials. Keep it secure.",
import: "Import settings",
export: "Export settings",
imported: "Settings and preferences imported",
importedDirectConfig: "Local direct connection settings imported",
importFailed: "Failed to read the settings file",
saved: "Settings saved",
savedContinue: "Settings saved. Continue with your previous request.",
channels: {
description: "Choose a protocol for each provider, fetch its models, assign capabilities, and optionally customize request scripts.",
add: "Add provider",
unnamed: "Unnamed provider",
numberedName: "Provider {{count}}",
modelCount: "{{count}} models",
missingUrl: "API endpoint not set",
keepOne: "Keep at least one provider",
defaultName: "Default provider",
newName: "New provider",
indexedName: "Provider {{index}}",
},
preferences: {
interface: "Interface",
language: "Display language",
languageDescription: "Change the language used by the interface and components.",
defaultModels: "Default models",
defaultImageModel: "Default image model",
defaultVideoModel: "Default video model",
defaultTextModel: "Default text model",
defaultAudioModel: "Default audio model",
generation: "Generation preferences",
canvasImageCount: "Default canvas image count",
canvasImageCountDescription: "Used by new canvas image and configuration nodes. Individual nodes can override it.",
audioVoice: "Default audio voice",
audioFormat: "Default audio format",
audioSpeed: "Default audio speed",
audioInstructions: "Default audio instructions",
audioInstructionsPlaceholder: "For example: natural, warm, and suitable for narration.",
systemPrompt: "System prompt",
systemPromptPlaceholder: "For example: You are a visual director specializing in cinematic, photorealistic imagery.",
},
channelEditor: {
title: "Edit provider",
name: "Provider name",
protocol: "Protocol",
baseUrl: "API endpoint",
models: "Provider models",
modelDescription: "{{count}} selected; assign a capability to each model and optionally customize its request script.",
selectModels: "Select models",
scriptReady: "Script set",
script: "Request script",
empty: "Select models to fetch or manually add models.",
capabilities: {
image: "Image",
video: "Video",
text: "Text",
audio: "Audio",
},
},
modelSelect: {
missingConfig: "Enter an API endpoint and API key first",
fetched: "Fetched {{count}} models",
fetchFailed: "Failed to fetch models",
title: "Select provider models",
selected: "{{selected}} / {{total}} selected",
confirm: "Confirm",
search: "Search models",
modelName: "Enter a model name",
add: "Add model",
fetch: "Fetch model list",
description: "If the provider does not expose an OpenAI /models endpoint, add model names manually here.",
fetchedTab: "Fetched models ({{count}})",
existingTab: "Selected models ({{count}})",
visibleSelected: "{{selected}} / {{total}} selected in this list",
selectVisible: "Select this list",
clearVisible: "Clear this list",
fetchedEmpty: "Fetch models from the provider or add a model name manually.",
existingEmpty: "No models selected.",
},
scriptEditor: {
description: "The script is an async function body. Use the variables below and return the result; leave it empty to use the default request.",
insertTemplate: "Insert {{name}} template",
restoreDefault: "Restore default request",
returnRequirements: "Return requirements",
variables: "Available variables",
insert: "Click to insert",
placeholder: "// Leave empty to use the default request; insert a template to view an example.",
},
webdav: {
title: "WebDAV sync",
description: "Sync canvases, assets, generation history, and local media files. AI API keys are excluded; the browser connects directly to WebDAV.",
lastSynced: "Last synced {{time}}",
neverSynced: "Not synced yet",
url: "WebDAV URL",
directory: "Remote directory",
directoryDescription: "Business directories are created here, each containing {{manifest}} and files/",
username: "Username",
password: "Password / app password",
test: "Test connection",
syncing: "Syncing",
syncNow: "Sync now",
missingUrl: "Enter a WebDAV URL first",
available: "WebDAV connection is available",
testFailed: "WebDAV connection test failed",
preparing: "Preparing to sync",
failed: "WebDAV sync failed",
completed: "Sync complete: {{projects}} canvases, {{assets}} assets, {{records}} records, and {{files}} files ({{bytes}}) uploaded",
domains: {
canvas: "Canvases",
assets: "My Assets",
imageWorkbench: "Image Studio",
videoWorkbench: "Video Studio",
},
stages: {
waiting: "Waiting",
localWaiting: "Waiting for local data",
syncComplete: "Sync complete",
remoteManifest: "Reading remote manifest",
localData: "Reading local data",
downloadMedia: "Downloading missing media",
writeMerge: "Writing merged local data",
uploadMedia: "Uploading new media",
mediaReady: "Media is up to date",
mediaSkipped: "No media to upload",
checkMissingMedia: "Checking for missing media",
downloadMediaFile: "Downloading media",
checkLocalMedia: "Checking local media",
uploadMediaFile: "Uploading media {{size}}",
uploadManifest: "Uploading manifest {{size}}",
complete: "Complete",
},
errors: { testFailed: "WebDAV connection test failed", downloadFailed: "Failed to read the WebDAV sync file", downloadTimeout: "Timed out while reading the WebDAV sync file", emptyUpload: "The upload file is empty; upload canceled", uploadFailed: "Failed to upload the WebDAV sync file", directoryFailed: "Failed to create the remote WebDAV directory", requestTimeout: "The WebDAV request timed out. Check the network or remote service.", connectionFailed: "Could not connect to WebDAV. Check the address, HTTPS certificate, CORS, and network.", urlRequired: "Enter a WebDAV URL first", authenticationFailed: "WebDAV authentication failed. Check the username, password, or app password.", pathMissing: "The WebDAV path does not exist. Check the address and remote directory.", responseFailed: "{{fallback}}: {{status}}{{detail}}", syncFailed: "Sync failed", invalidManifest: "The {{domain}} sync manifest does not belong to this app" },
},
protocols: {
ark: "Volcengine Ark",
},
},
agent: {
status: { failed: "Connection failed", connected: "Connected", connecting: "Connecting", disconnected: "Disconnected" },
state: { ready: "Ready", connectionRequired: "Enter the Local URL and Connect token", invalidUrl: "The Local URL is invalid", offline: "Offline", skillReadFailed: "Failed to read Skill", skillParseFailed: "Failed to parse Skill", requestFailed: "Local Agent request failed" },
siteTools: { canvasList: "Canvas list", generationStatus: "Generation task status", imageConfig: "Image configuration", imageGenerate: "Generate in Image Studio", videoConfig: "Video configuration", videoGenerate: "Generate in Video Studio", promptSearch: "Search prompts", assetList: "Asset list", assetAdd: "Add asset", unknownTool: "Unknown tool: {{name}}", canvasLoading: "The canvas is still loading. Try again shortly.", canvasHint: "Use site_navigate to open /canvas/{id}", assetsLoading: "Assets are still loading. Try again shortly.", assetTitleRequired: "Provide the asset title", textContentRequired: "content is required when kind=text", imageUrlRequired: "imageUrl is required when kind=image", imageReadFailed: "Could not read the image. Use a data URL or a cross-origin accessible image URL.", assetKindUnsupported: "assets_add supports only kind=text or kind=image", imageGenerationStarted: "Opened Image Studio and started generation. Use generation_get_status to query the task.", imageConfigApplied: "Opened Image Studio and applied the parameters without starting generation.", videoGenerationStarted: "Opened Video Studio and started generation. Use generation_get_status to query the task.", videoConfigApplied: "Opened Video Studio and applied the parameters without starting generation." },
connect: { pluginTitle: "Option 1: Use the Codex plugin", pluginText: "Install the Infinite Canvas plugin in the Codex app and launch the canvas through it. The plugin starts the local Agent and supplies the connection details automatically.", directTitle: "Option 2: Run the Agent directly", directText: "Without the Codex plugin, run the command below in a terminal, then return here to connect or enter the Local URL and Connect token manually.", commandCopied: "Command copied", pluginReminder: "Codex plugin note", pluginReminderText: "The tool list enters the Codex context and consumes additional tokens only after installing the Codex plugin or adding MCP manually. Running npx -y @basketikun/canvas-agent alone does not install MCP.", removePlugin: "Remove plugin", removeMcp: "Remove manual MCP", copyCommand: "Copy command", title: "Connect local Agent", description: "Choose the connection method that fits your workflow.", webConnection: "Web connection", autoDiscover: "The Local URL and Connect token are discovered automatically by default. Enter them manually only if discovery fails.", disconnect: "Disconnect", connect: "Connect", localAddress: "Local address", urlPlaceholder: "For example http://127.0.0.1:17371", token: "Connection token", tokenPlaceholder: "Discover automatically or enter the Connect token" },
history: { workspace: "Workspace", defaultWorkspace: "Default canvas directory", selected: "{{count}} selected", count: "{{count}} conversations", empty: "No history", deleteCount: "Delete {{count}}", refresh: "Refresh", newThread: "New chat", selectThread: "Select {{name}}", untitled: "Untitled conversation", current: "Current", noWorkspaceThreads: "No conversations in this workspace yet", connectHint: "Connect the local Agent to view conversation history" },
skills: { selectLocal: "Select local Skill", search: "Search Skills", loading: "Loading Skills…", noMatch: "No matching enabled Skills", none: "No Skills available", select: "Select Skill", connectHint: "Connect the Agent to use Skills" },
skillManager: {
codexBusy: "Codex is running. Finish the current task before extracting a Skill.", noConversation: "This conversation has no completed content to extract", noCanvas: "There is no canvas on this page to extract", connecting: "This page is still connecting to the Agent. Try again shortly.", syncFailed: "Failed to sync the current canvas. Check the Agent connection and try again.", noDraft: "No Skill draft was generated", draftCreated: "Draft generated. Review it on the Skills tab before creating it.", draftFailed: "Failed to generate Skill draft", contentMissing: "Skill content was not returned", readFailed: "Failed to read Skill", defaultPromptMention: "The default prompt must include ${{name}}", created: "Skill created", updated: "Skill updated", saveFailed: "Failed to save Skill", deleteTitle: "Delete {{name}}", deleteDescription: "The local files cannot be recovered after deletion. Continue?", delete: "Delete", deleted: "Skill deleted", deleteFailed: "Failed to delete Skill", statusFailed: "Failed to update Skill status",
fromConversation: "Generate draft from this conversation", availableAfterRun: "Available when Codex finishes running", conversationDescription: "Extract reusable workflows from this conversation", noCompletedContent: "This conversation has no completed content", startConversation: "Start a conversation first", fromCanvas: "Generate draft from this canvas", canvasDescription: "Extract nodes and generation workflows from this page", canvasUnavailable: "There is no available canvas on this page", blankCreate: "Create from scratch", blankDescription: "Start with an empty form", localSkills: "Local Skills", localDescription: "Installed locally and run directly by Codex", reload: "Reload", reloadSkill: "Reload Skills", createSkill: "Create Skill", filterBySource: "Filter Skills by source", scopes: { all: "All sources", repo: "Project", user: "Personal", system: "System", admin: "Admin" }, loadErrors: "{{count}} Skills could not be loaded", externalReadonly: "External Skills can only be used or enabled and disabled", noDescription: "No description", enabled: "Enabled", disabled: "Disabled", selected: "Selected", use: "Use", editNamed: "Edit {{name}}", deleteNamed: "Delete {{name}}", connectToView: "Connect the Agent to view Skills", noMatch: "No matching Skills", none: "No local Skills yet", connectDescription: "Installed local Skills will load after connection", tryAnotherFilter: "Try another keyword or source", createOrInstall: "Create one, or install one locally and refresh", saveChanges: "Save changes", saveLocation: "Save to the local Agent workspace", basicInfo: "Basic information",
identifier: "Skill identifier", identifierExtra: "Used as the folder name and for $skill-name invocation.", identifierRequired: "Enter a Skill identifier", identifierMax: "The Skill identifier cannot exceed 64 characters", identifierPattern: "Use lowercase letters, numbers, and hyphens only; hyphens cannot repeat or appear at either end", identifierPlaceholder: "For example, product-grid", displayName: "Display name", displayNameMax: "The display name cannot exceed 64 characters", displayNamePlaceholder: "For example, Product grid generator", whenToUse: "When to use", whenToUseExtra: "Describe the Skill's capabilities and use cases so Codex can decide when to invoke it.", whenToUseRequired: "Describe when to use this Skill", whenToUseMax: "The use-case description cannot exceed 1,024 characters", noAngleBrackets: "The use-case description cannot contain angle brackets", whenToUsePlaceholder: "For example: Use when planning and generating a set of product images from product information", instructions: "Instructions", instructionsExtra: "Describe the steps, constraints, and output requirements in execution order.", instructionsRequired: "Enter the instructions", instructionsPlaceholder: "Describe execution steps, required checks, and the final output", advanced: "Advanced settings", shortDescription: "Card summary", shortDescriptionExtra: "Use 2564 characters for easy scanning.", shortDescriptionMin: "The card summary must contain at least 25 characters", shortDescriptionMax: "The card summary cannot exceed 64 characters", shortDescriptionPlaceholder: "Optional; shown in the list", defaultPrompt: "Default prompt", defaultPromptExtra: "Must include the exact $skill-name, such as $product-grid.", defaultPromptMax: "The default prompt cannot exceed 1,024 characters", defaultPromptPlaceholder: "Optional; prefilled when the Skill is selected",
},
composer: { removeImage: "Remove image", removeSkill: "Remove Skill", uploadImage: "Upload image", stop: "Stop", send: "Send", model: "Model: {{model}}", selectModel: "Select a Codex model. Current: {{model}}", reasoning: "Reasoning effort: {{effort}}", selectReasoning: "Select reasoning effort. Current: {{effort}}", permissionLabel: "Permissions: {{mode}}", selectPermission: "Select Codex permission mode. Current: {{mode}}", effort: { minimal: "Minimal", low: "Low", medium: "Medium", high: "High", xhigh: "Extra high", max: "Maximum", ultra: "Ultra" }, permission: { request: "Ask for approval", requestDescription: "Always ask before editing outside the workspace or accessing the network", automatic: "Automatic review", automaticDescription: "Codex reviews risky actions and asks only when needed", full: "Full access", fullShort: "Full access", fullDescription: "Unrestricted access to the network and local files" }, tools: { label: "Tool confirmation: {{mode}}", manual: "Manual", manualDescription: "Ask before the Agent writes to the canvas", automatic: "Automatic", automaticDescription: "Let the Agent perform canvas writes automatically", select: "Select tool confirmation mode. Current: {{mode}}" } },
panel: { connectionSettings: "Connection settings · {{status}}", connectionSettingsLabel: "Connection settings. Current status: {{status}}", chat: "Chat", history: "History", skills: "Skills", logs: "Logs", collapse: "Collapse chat", collapseLabel: "Collapse Agent panel", resize: "Resize right panel", content: "Agent content", mcpInitializing: "MCP is initializing. You can send messages when it is ready.", initFailed: "Codex conversation initialization failed. Start or resume a conversation.", placeholder: "Ask Codex or have it operate the site or canvas" },
chat: { latestMessages: "View latest messages", latestCall: "Latest call", input: "Input", cached: "Cached", output: "Output" },
logs: { copied: "Logs copied", selectedManual: "Logs selected; copy them manually", copyFailed: "Copy failed. Switch to raw JSON and copy manually.", title: "Runtime logs", diagnostics: "Diagnostics", rawJson: "Raw JSON", messages: "{{count}} messages", tool: "Tool: {{tool}}", noPendingTool: "No pending tool", all: "All {{count}}", errors: "Errors {{count}}", warnings: "Warnings {{count}}", info: "Info {{count}}", list: "Diagnostics log list", noFiltered: "No logs match this filter", empty: "No event logs", new: "{{count}} new logs", newLabel: "{{count}} new logs. View latest logs.", latest: "View latest logs", fullData: "Full diagnostics · {{count}} entries", copyAll: "Copy all logs", copyLastError: "Copy latest error", lastErrorCopied: "Latest error copied", clear: "Clear logs", repeated: "Repeated {{count}} times", details: "Details", skillLoading: "Skill loading", plugin: "Plugin", terminal: "Terminal", conversationStorage: "Conversation storage" },
message: { close: "Close", copied: "Copied", copyCode: "Copy code", copyLink: "Copy link", externalWarning: "You are about to open an external link. Make sure you trust it.", openExternal: "Open external link?", continueOpen: "Open", revealed: "Revealed in file manager", openLocalFailed: "Could not open the local file", openLocal: "Open local file?", localDescription: "This path will be revealed in your local file manager and will not be opened in the browser.", externalDescription: "You are about to open an external link. Make sure you trust it.", pathCopied: "Path copied", linkCopied: "Link copied", copyPath: "Copy path", showInFolder: "Show in file manager", toolCall: "Tool call", awaitingConfirmation: "Awaiting confirmation", reject: "Reject", approve: "Approve", networkApproval: "Network access request", fileApproval: "File edit request", permissionApproval: "Extended permission request", commandApproval: "Command execution request", decline: "Decline", allowOnce: "Allow once", allowSession: "Allow for session", thinking: "Thinking", commandsRunning: "Running {{count}} commands", commandRunning: "Running command", commandsCompleted: "Ran {{count}} commands{{failed}}", commandsFailed: " · {{count}} failed", failed: "Failed", running: "In progress", completed: "Completed", command: "Command", slowResponse: "The response is taking longer, but the task is still running. You can keep waiting or use the stop button beside the input to end this turn.", waitingSeconds: "Waited {{seconds}} seconds", waitingMinutes: "Waited {{minutes}}m {{seconds}}s", files: "Files", errorInfo: "Error", output: "Output", viewLarge: "View full image", attachmentPreview: "Image attachment preview", noEffect: "No effect", canceled: "Canceled", recorded: "Recorded", stopped: "Stopped", finished: "Finished", pending: "Pending" },
events: { analyzing: "Analyzing the task…", reasoning: "Reasoning summary", plan: "Execution plan", commandFailed: "Command failed", commandCompleted: "Command completed", executeCommand: "Run command", editFiles: "Edit files", searchWeb: "Search the web", viewImage: "View image", imageViewed: "Image viewed", viewingImage: "Viewing image", imageGeneration: "Generate image", imageFailed: "Image generation failed", imageCompleted: "Image generation completed", generatingImage: "Generating image…", compactContext: "Compact context", contextCompacted: "Conversation context compacted; continuing the task", compactingContext: "Compacting conversation context…", collaboration: "Collaborate", collaborationFailed: "Collaboration task failed", collaborationCompleted: "Collaboration task completed", collaborating: "Working with collaborators…", progress: "Task progress", progressCount: "{{completed}}/{{total}} completed", planning: "Preparing execution steps…", executingCommand: "Running command…", workingDirectory: "Working directory", exitStatus: "Exit status", duration: "Duration", seconds: "{{value}} seconds", filesCompleted: "File changes completed", preparingFiles: "Preparing file changes…", fileCompleted: "{{action}} completed: {{path}}", editingFile: "{{action}} in progress: {{path}}", filesEdited: "Edited {{count}} files: {{names}}{{more}}", editingFiles: "Editing {{count}} files: {{names}}{{more}}", andMore: " and more", openPage: "Open page: {{url}}", findInPage: "Find “{{pattern}}” on page", content: "content", search: "Search: {{query}}", relatedInfo: "related information", keyword: "Keyword", webpage: "Web page", add: "Add", delete: "Delete", edit: "Edit", diagnostics: "Infinite Canvas Agent diagnostics", address: "Address: {{endpoint}}", connection: "Connection: {{connection}} · Status: {{status}}", online: "Online", connecting: "Connecting", disabled: "Disabled", messageTool: "Messages: {{messages}} · Tool: {{tool}}", none: "None", noLogs: "No event logs", threadCreated: "Conversation created", turnStarted: "Processing started", progressUpdated: "Task progress updated", turnFailed: "Processing failed", turnStopped: "Processing stopped", turnCompleted: "Processing completed", toolCalled: "Tool called", toolFailed: "Tool failed", toolCompleted: "Tool completed", replyReceived: "Reply received", completed: "Completed", modelBusy: "Model temporarily busy", modelBusyDescription: "The selected model is receiving too many requests. Try again later or switch models.", taskFailed: "Task failed", taskFailedDescription: "Codex could not complete this task. Try again later." },
eventExtra: { toolNamed: "Call tool: {{name}}", toolOperation: "Tool operation", targetPage: "Target page", searchContent: "Search query", textContent: "Text content", operationContent: "Operations", imageCount: "Image count", tools: { generateImage: "Generate image", viewImage: "View image", executeCommand: "Run command", editFiles: "Edit files", searchWeb: "Search the web", canvasOps: "Canvas operations", readCanvas: "Read canvas", readSelection: "Read selection", exportSnapshot: "Export snapshot", createNode: "Create node", addAttachments: "Add attachment images", createText: "Create text", createTexts: "Create text nodes", createConfig: "Create generation config", createImageFlow: "Create image flow", createGenerationFlow: "Create generation flow", generateText: "Generate text", generateVideo: "Generate video", generateAudio: "Generate audio", updateNode: "Update node", updateText: "Update text", moveNodes: "Move nodes", resizeNode: "Resize node", deleteNodes: "Delete nodes", connectNodes: "Connect nodes", selectNodes: "Select nodes", setViewport: "Adjust viewport", runGeneration: "Run generation", openPage: "Open page" } },
eventMore: { toolRunning: "{{action}}…", openedRoute: "Opened {{route}}", canvasCount: "{{count}} canvases", promptCount: "Found {{count}} prompts", assetCount: "{{count}} assets", assetAdded: "Added to My Assets", generationStatus: "{{total}} tasks: {{queued}} queued, {{running}} running, {{succeeded}} succeeded, {{failed}} failed", workbenchExecuted: "Executed in the workbench", workbenchConfigRead: "Workbench configuration read", canvasRead: "Current canvas read", selectionRead: "Current selection read", textNodes: "{{count}} text nodes", imageNodes: "{{count}} images", configNodes: "{{count}} config nodes", videoNodes: "{{count}} videos", audioNodes: "{{count}} audio nodes", groupNodes: "{{count}} groups", otherNodes: "{{count}} other nodes", connections: "{{count}} connections", emptyCanvas: "The canvas is empty", executeTool: "Run {{tool}}", thinking: "Thinking...", operationRunning: "{{operation}} is running...", organizingCanvas: "Canvas read; Codex is organizing the result...", operationCompleted: "{{operation}} completed; Codex is continuing...", attachmentPrompt: "Please process the uploaded image attachments.", routes: { home: "Home", canvas: "Canvas", canvasProject: "Selected canvas", image: "Image Studio", video: "Video Studio", prompts: "Prompt Center", assets: "My Assets", config: "Settings" } },
runtime: {
mcpStarting: "Starting MCP: {{name}}", mcpConnecting: "Connecting tools and loading the available tool list", mcpReadyNamed: "MCP ready: {{name}}", toolsReady: "Tool list loaded. You can start chatting.", mcpFailedNamed: "MCP failed to start: {{name}}", mcpCanceledNamed: "MCP startup canceled: {{name}}", toolInitFailed: "The tool service could not finish initializing", mcpServicesStarting: "Starting MCP services", toolServicesPending: "{{count}} tool services are still initializing", checkingToolServices: "Checking tool service status", conversationInitializing: "Initializing the Codex conversation", conversationCreating: "Creating the conversation and starting canvas tools", someMcpFailed: "Some MCP services failed to initialize", remainingToolsReady: "The remaining tools are ready. You can start chatting.", conversationInitFailed: "Codex conversation initialization failed", conversationCreateFailed: "Could not create the Codex conversation", mcpServicesReady: "{{count}} MCP services initialized", toolInitCanceled: "Tool service initialization was canceled", mcpStatusComplete: "MCP status check completed", mcpListRead: "Loaded the complete tool service list returned by Codex",
historyReadFailed: "Failed to read history", conversationSyncFailed: "Failed to sync conversation", agentOutdated: "The local Agent is outdated. Restart Canvas Agent and reconnect.", restartRequired: "Agent restart required", versionMismatch: "Agent version mismatch", awaitingApproval: "Awaiting approval", codexRunning: "Codex is running", connected: "Connected", localAgentConnected: "Local Agent connected", processingFailed: "Processing failed", completed: "Completed", approvalGranted: "Permission approved", approvalCanceled: "Permission canceled", log: "Log", connectionLostDescription: "The local Agent connection failed or was interrupted", connectionFailedDescription: "Connection failed. Check the address and token.", connectionLost: "Connection lost", connectionFailed: "Connection failed", modelListFailed: "Failed to load model list",
imageTooLarge: "Image too large", imagePayloadTooLarge: "Image attachments exceed 30 MB. Remove some before sending.", imagesSent: "Sent {{count}} images", sending: "Sending", defaultModel: "Default model", defaultEffort: "Default effort", sendTask: "Send task", attachmentCount: "{{count}} attachments", attachmentsOnly: "Attachments only", startConversationFailed: "Failed to start conversation", attachmentHistoryFailed: "Failed to save attachment history", sendFailed: "Send failed", conversationSynced: "Conversation synced", taskStillRunning: "Task is still running", stopping: "Stopping", stopTask: "Stop task", taskStopped: "Task stopped", stopFailed: "Failed to stop", imageLimit: "Image attachments are limited to about 30 MB.", imageReadFailed: "Failed to read image", pendingCanvasTool: "Another canvas tool call is awaiting confirmation", awaitingConfirmation: "Awaiting confirmation", toolCompleted: "{{tool}} completed", toolExecutionFailed: "Tool execution failed", openCanvasFirst: "The canvas is not open. Use site_navigate to open it first.", canvasOperationFailed: "Canvas operation failed", canvasToolCanceled: "The user canceled the canvas tool call",
submittingApproval: "Submitting permission decision", waitingCodexApproval: "Waiting for Codex to confirm permission", approvalFailed: "Permission approval failed", enableFullAccess: "Enable full access", fullAccessDescription: "Codex will run without sandbox restrictions and can access the internet and any local files. Use this only when you trust the current task.", enableFullAccessAction: "Enable full access", offline: "Offline", addressRequired: "Enter the local Agent address", agentNotFound: "No local Agent was found. Use the Codex plugin or start Canvas Agent manually.", invalidAddress: "The local Agent address is invalid", connecting: "Connecting", creatingConversation: "Creating conversation", newConversation: "New conversation", newConversationFailed: "Failed to create conversation", conversationResumed: "Conversation resumed", resumeConversationFailed: "Failed to resume conversation", recordsDeleted: "Deleted {{count}} records", deleteConversationFailed: "Failed to delete conversation", deleteConversations: "Delete {{count}} conversations", deleteConversationsDescription: "This cannot be undone. Continue?", importGeneratedImages: "Import generated images", addedToSourceCanvas: "Added to the source canvas", imageGenerated: "Image generated", noImageAttachments: "No image attachments to add", invalidAttachmentNode: "Invalid image attachment node parameters", attachmentReadFailed: "Failed to read image attachment", referenceImage: "Reference image", generatedImageReadFailed: "Failed to read the image generated by Codex", generatedImageName: "Generated image {{index}}",
},
},
locale: {
zhCN: "简体中文",
enUS: "English",
},
};
+589
View File
@@ -0,0 +1,589 @@
export default {
meta: {
title: "无限画布",
description: "一个无限画布创作工具",
},
theme: { toggle: "切换主题" },
common: {
cancel: "取消",
save: "保存",
edit: "编辑",
done: "完成",
delete: "删除",
copy: "复制",
copied: "已复制",
details: "详情",
addToAssets: "加入我的资产",
addedToAssets: "已加入我的资产",
copyPrompt: "复制提示词",
promptCopied: "提示词已复制",
created: "创建:{{date}}",
updated: "更新:{{date}}",
all: "全部",
view: "查看",
download: "下载",
upload: "上传",
requestCanceled: "请求已取消",
durationMinutes: "{{minutes}}分{{seconds}}秒",
durationSeconds: "{{seconds}}秒",
imageReadFailed: "读取图片失败",
},
settingsPanels: {
common: { auto: "自动", low: "低", medium: "中", high: "高", xhigh: "极高" },
image: { title: "图像设置", quality: "质量", size: "尺寸", align16: "16 倍数对齐", align16Hint: "输入完成后自动向上补成 16 的倍数", aspectRatio: "宽高比", transparent: "透明背景", transparentHint: "开启后生成无背景的透明图像(仅部分模型可用)", count: "生成张数", images: "{{count}} 张" },
video: { title: "视频设置", quality: "清晰度", size: "尺寸", seconds: "秒数", resolution: "分辨率", ratio: "比例", duration: "时长", smart: "智能", output: "输出", generateAudio: "生成声音", watermark: "添加水印", adaptive: "自适应", sizes: { landscape: "横屏", portrait: "竖屏", square: "方形", widescreen: "宽屏", tall: "长图", auto: "自动" }, ratios: { landscape: "横屏", portrait: "竖屏", square: "方形", standardLandscape: "标准横屏", standardPortrait: "标准竖屏", cinematic: "宽银幕", adaptive: "自适应" } },
audio: { title: "音频设置", voice: "声音", format: "格式", speed: "语速", instructions: "声音指令", instructionsPlaceholder: "例如:自然、温暖、适合旁白。" },
text: { title: "文本设置", reasoning: "推理强度" },
model: { select: "选择模型", assign: "请先在渠道里为{{capability}}指定模型", noMatch: "暂无匹配的{{capability}}模型", addFirst: "请先到配置里添加渠道和模型", capabilities: { image: "生图", video: "视频", text: "文本", audio: "音频" } },
},
generation: { pending: ["正在创建图片", "马上就好了", "再等等", "正在整理细节"] },
imageReferences: { label: "图片{{index}}", separator: "、", promptPrefix: "参考图片编号:{{labels}}。请按这些编号理解提示词中的图片引用。\n\n{{prompt}}" },
seedance: { autoMatch: "自动匹配", separator: "、", references: { image: "图片{{index}}", video: "视频{{index}}", audio: "音频{{index}}" }, promptPrefix: "参考资产编号:{{labels}}。请按这些编号理解提示词中的图片、视频和音频引用。\n\n{{prompt}}", referenceHint: "参考视频需为 mp4/movH.264/H.265FPS 24-60;含真人人脸资产请使用火山授权 asset:// 资产。", errors: { format: "{{label}} 仅支持 mp4/mov 格式", size: "{{label}} 超过 200MB,请压缩后再上传", duration: "{{label}} 时长需要在 2-15 秒之间", dimensions: "{{label}} 宽高需要在 300-6000px 之间", ratio: "{{label}} 宽高比需要在 0.4-2.5 之间", pixels: "{{label}} 总像素需要在 409600-8295044 之间", totalDuration: "Seedance 参考视频总时长不能超过 15 秒" } },
modelPlugin: {
pollTimeout: "插件轮询超时,请检查调用脚本或稍后重试", executionFailed: "模型调用脚本执行失败:{{message}}", noImages: "模型调用脚本没有返回图片",
variables: { prompt: "用户输入的提示词(已拼接系统提示词)", images: "参考图,dataURL 数组(改图 / 图生视频时有值)", messages: "对话消息数组,含系统消息", params: "生成参数:生图 {size,quality,count}、视频 {seconds,size,resolution,ratio,generateAudio,watermark}、音频 {voice,format,speed,instructions}", model: "模型名称(不含渠道前缀)", baseUrl: "渠道接口地址(原样,未拼 /v1)", apiKey: "渠道 API Key,请求头里自己带上", systemPrompt: "系统提示词原文", reasoningEffort: "文本推理强度;auto 表示由脚本决定是否传递", http: "便捷请求:http.post(path, body, {headers,params,responseType})、http.get(path, opts)、http.url(path);默认带 Authorization: Bearer apiKey,可用 headers 覆盖;path 相对时按 baseUrl 拼 /v1", request: "原始请求 request({ method, url, headers, params, data, responseType }),不加任何默认头,鉴权头自己写;url 相对时按 baseUrl 拼接(不加 /v1", poll: "轮询 poll(request, extract, {intervalMs,timeoutMs})extract 返回真值即结束", sleep: "sleep(ms) 延时", signal: "取消信号,可透传给 http/request", onDelta: "onDelta(text) 推送流式文本(文本模型)" },
returns: { image: "文生图(images 为空)和图生图(images 有参考图)接口不同,脚本需自行区分;返回图片 URL 或 dataURL 字符串,也可返回它们的数组,或 [{ dataUrl }] / [{ url }] / [{ b64_json }]", video: "脚本内部完成轮询,返回 { url } 或 { blob } 或视频 URL 字符串", audio: "返回 Blob,或 base64 / dataURL 字符串,或 { b64_json } / { data } / { url }", text: "用 onDelta(text) 推送流式,最终 return 完整文本字符串" },
templates: { openai: "OpenAI 规范", gemini: "Gemini 规范", imageOpenai: "生图 / 改图:两者接口不同,用 images 是否为空来区分。", availableImage: "可用:prompt、images(dataURL[])、params{size,quality,count}、model、baseUrl、apiKey", textToImage: "文生图:/images/generationsJSON", imageToImage: "图生图:/images/editsmultipart/form-data,参考图作为文件上传)", formDataHeader: "不要手动设 Content-Type,交给浏览器带 boundary", imageGemini: "Gemini 文生图 / 图生图:都走 generateContent,参考图放进 parts 的 inline_data。", availableImageGemini: "可用:prompt、images(dataURL[])、model、baseUrl、apiKey", videoOpenai: "视频(脚本内部自行轮询)。可用:prompt、images(dataURL[])、params{seconds,size,resolution,ratio}", videoGemini: "Gemini(Veo) 视频:predictLongRunning 提交,轮询 operation 拿视频 URI。", availableVideoGemini: "可用:prompt、images(dataURL[])、params、model、baseUrl、apiKey", geminiNoVideoUri: "Gemini 未返回视频 URI", audioOpenai: "音频 TTS。可用:prompt、params{voice,format,speed,instructions}、model", audioGemini: "Gemini TTSgenerateContent + AUDIO 模态,返回 base64 PCM(音频数据在 inlineData.data)。", availableAudioGemini: "可用:prompt、params{voice}、model、baseUrl、apiKey", geminiNoAudio: "Gemini 未返回音频", textOpenai: "文本对话(OpenAI Responses 接口)。可用:messages([{role,content}])、systemPrompt、model、reasoningEffort", textGemini: "Gemini 文本:generateContentsystem 消息放 systemInstruction。", availableTextGemini: "可用:messages([{role,content}])、systemPrompt、model、baseUrl、apiKey" },
},
apiErrors: { requestFailed: "请求失败", requestCanceled: "请求已取消", baseUrlRequired: "请先配置 Base URL", apiKeyRequired: "请先配置 API Key", authenticationFailed: "鉴权失败,请检查 API Key、套餐权限或模型权限", rateLimited: "请求被限流或额度不足,请稍后重试", notFound: "接口地址不存在(404),请检查 Base URL 和模型选择", badGateway: "网关错误(502),接口服务暂时不可用,请稍后重试", serviceBusy: "服务繁忙(503),请稍后重试", httpFailed: "请求失败(HTTP {{status}}),请检查 Base URL 和 API Key 是否正确", htmlError: "服务返回了 HTML 错误页面({{preview}}", audioModelRequired: "请先配置音频模型", audioGenerationFailed: "音频生成失败", scriptNoAudio: "模型调用脚本没有返回音频", geminiAudioUnsupported: "Gemini 调用格式暂不支持音频生成,请使用 OpenAI 格式渠道", invalidImageSizeFormat: "图像尺寸格式不支持,请使用 auto、9:16 或 1024x1024", positiveImageRatio: "图像比例必须是正数,例如 9:16", imageRatioLimit: "图像宽高比不能超过 3:1,请调整尺寸", positiveImageDimensions: "图像尺寸必须是正整数,例如 1024x1024", imageDimensionStep: "图像尺寸的宽高必须是 16 的倍数,请调整尺寸", imageEdgeLimit: "图像尺寸最长边不能超过 3840px,请调整尺寸", imagePixelLimit: "图像总像素需在 655360 到 8294400 之间,请调整尺寸", unknownImageResponse: "接口返回了未知格式的数据(字段:{{fields}}),请检查模型或接口兼容性", noImageReturned: "接口没有返回图片,请检查提示词是否触发安全审核或模型是否支持该操作", geminiRejected: "Gemini 拒绝了本次请求:{{reason}}", geminiNoImage: "Gemini 接口没有返回图片", geminiMaskUnsupported: "Gemini 调用格式暂不支持蒙版编辑", maskModelUnsupported: "蒙版编辑暂不支持该模型,请使用其他渠道", noContent: "没有返回内容", modelReadFailed: "读取模型失败", videoTimeout: "{{provider}}视频生成超时,请稍后重试", videoReferencesUnsupported: "当前视频接口不支持参考视频或参考音频,请切换到 Seedance 2.0 / 火山 Agent Plan 模型,或移除参考资产", pluginVideoExpired: "插件视频任务已失效,请重新生成", scriptNoVideo: "模型调用脚本没有返回视频", noPlayableVideo: "视频接口没有返回可播放的视频", noVideoTaskId: "视频接口没有返回任务 ID", videoTaskCreateFailed: "视频任务创建失败", videoGenerationFailed: "视频生成失败", videoTaskQueryFailed: "视频任务查询失败", seedanceAudioRequiresVisual: "Seedance 参考音频不能单独使用,请同时添加参考图或参考视频", videoPromptRequired: "请输入视频提示词,或连接参考图片/视频/音频", seedanceNoTaskId: "Seedance 接口没有返回任务 ID", seedanceTaskCreateFailed: "Seedance 任务创建失败", seedanceNoVideoUrl: "Seedance 任务成功但没有返回视频 URL", seedanceVideoTimeout: "Seedance 视频生成超时", seedanceVideoFailed: "Seedance 视频生成失败", seedanceTaskQueryFailed: "Seedance 任务查询失败", seedanceVideoDuration: "Seedance 参考视频单个时长需要在 2-15 秒之间", seedanceVideoTotalDuration: "Seedance 参考视频总时长不能超过 15 秒", seedanceAudioDuration: "Seedance 参考音频单个时长需要在 2-15 秒之间", seedanceAudioTotalDuration: "Seedance 参考音频总时长不能超过 15 秒", referenceImageReadFailed: "参考图读取失败,请换一张图片或重新上传", invalidReferenceVideo: "参考视频必须是公网 URL、资产 ID,或本地已保存的视频", invalidReferenceAudio: "参考音频必须是公网 URL、资产 ID,或本地已保存的音频", videoModelRequired: "请先配置视频模型", geminiVideoUnsupported: "Gemini 调用格式暂不支持视频生成,请使用 OpenAI 格式渠道", noVideoTask: "接口没有返回视频任务", seedanceNoTask: "Seedance 接口没有返回任务", videoDownloadFailed: "视频下载失败", localAssetReadFailed: "读取本地资产失败" },
prompts: {
title: "提示词中心",
library: "提示词库",
total: "当前共 {{count}} 条提示词",
category: "分类",
tags: "标签",
search: "搜索标题、内容或标签",
searchTitle: "按标题查询",
loadFailed: "获取提示词失败",
sourceMissing: "提示词来源不存在",
use: "使用此提示词",
empty: "没有找到匹配的提示词",
loading: "加载中...",
loadMore: "继续向下滚动加载更多",
end: "已经到底了",
},
assets: {
title: "我的资产",
description: "收藏常用文本和图片,按类型、标题和标签快速查找。",
search: "搜索标题、内容、标签或来源",
type: "类型",
export: "导出资产",
import: "导入资产",
add: "新增资产",
edit: "编辑资产",
empty: "没有找到资产",
manual: "手动添加",
selectImage: "请选择图片文件",
updated: "资产已更新",
saved: "资产已保存",
textCopied: "文本已复制",
noneToExport: "暂无资产可导出",
imported: "已导入 {{count}} 个资产",
importFailed: "导入失败,请选择有效的资产压缩包",
deleted: "资产已删除",
packageName: "我的资产.zip",
kinds: { text: "文本", image: "图片", video: "视频" },
fields: {
title: "标题",
titleRequired: "请输入标题",
titlePlaceholder: "给资产起一个容易检索的名字",
coverUrl: "封面 URL",
coverPlaceholder: "可粘贴图片 URL,也可以上传本地封面",
tags: "标签",
tagsPlaceholder: "输入标签后回车",
source: "来源",
sourcePlaceholder: "手动添加 / 画布 / 提示词库",
note: "备注",
optional: "可选",
textContent: "文本内容",
textRequired: "请输入文本内容",
textPlaceholder: "保存提示词、说明文案、参考描述等文本资产",
imageContent: "图片内容",
},
selectImageFile: "选择图片文件",
noImageSelected: "未选择图片",
preview: "预览",
noCover: "暂无封面",
untitled: "未命名资产",
noTags: "无标签",
untagged: "未打标签",
unknownSource: "未标注来源",
details: "资产详情",
deleteTitle: "删除资产",
deleteConfirm: "确定删除「{{name}}」吗?删除后会从我的资产中移除。",
copyText: "复制文本",
downloadImage: "下载图片",
downloadVideo: "下载视频",
},
notFound: {
title: "页面不存在",
description: "这个地址没有对应的页面,可能已经移动或被合并到其他入口。",
home: "返回首页",
},
workbench: {
logs: "生成记录",
settings: "参数",
prompt: "提示词",
viewPrompts: "查看提示词库",
viewAssets: "查看我的资产",
clipboard: "剪切板",
upload: "上传",
adjust: "调整",
generate: "开始生成",
results: "生成结果",
waiting: "等待 {{time}}",
model: "模型",
generating: "生成中",
success: "成功",
failed: "生成失败",
retry: "重试",
new: "新建",
selectAll: "全选",
noLogs: "暂无生成记录",
deleteLogs: "删除生成记录",
deleteLogsConfirm: "确定删除选中的 {{count}} 条生成记录吗?",
successCount: "成功 {{count}}",
failCount: "失败 {{count}}",
itemCount: "{{count}} 张",
untitled: "未命名",
configFirst: "请先完成配置",
generationFailed: "生成失败",
retrySuccess: "重试成功",
},
imageWorkbench: {
title: "生图工作台",
promptPlaceholder: "描述画面主体、风格、构图、光线和用途",
references: "参考图",
removeReference: "移除参考图",
dropReferences: "松开即可添加参考图",
noReferences: "暂无参考图,可将图片拖到这里",
clipboardEmpty: "剪切板里没有可读取的图片",
clipboardAdded: "已读取 {{count}} 张参考图",
promptRequired: "请输入生图提示词",
configIncomplete: "生图配置不完整",
invalidParams: "生图参数无效",
busy: "生图工作台已有任务正在运行",
generated: "图片已生成",
addedReference: "已加入参考图",
resultTitle: "生成结果 {{count}}",
source: "生图工作台",
unsupportedAsset: "生图工作台只能使用文本或图片资产",
missingResult: "接口没有返回图片",
empty: "还没有生成图片",
resultAlt: "生成结果 {{count}}",
addReference: "加入参考图",
},
videoWorkbench: {
title: "视频创作台",
promptPlaceholder: "描述镜头运动、主体动作、场景氛围和画面风格",
references: "参考图",
videoReferences: "参考视频",
audioReferences: "参考音频",
removeImage: "移除参考图",
removeVideo: "移除参考视频",
removeAudio: "移除参考音频",
dropReferences: "松开即可上传参考资产",
noImages: "暂无参考图,可拖入文件,最多 9 张",
noVideos: "暂无参考视频,可拖入文件,最多 3 个",
noAudio: "暂无参考音频,可拖入文件,最多 3 个,mp3/wav,单个 15MB 内",
unsupportedFiles: "已忽略不支持的参考资产,请使用图片、mp4/mov 视频或 mp3/wav 音频",
imageTooLarge: "已忽略超过 30MB 的参考图",
videoTooLarge: "已忽略超过 200MB 的参考视频",
audioTooLarge: "已忽略超过 15MB 的参考音频",
audioDurationInvalid: "已忽略不符合时长要求的参考音频:单个 2-15 秒,总时长不超过 15 秒",
clipboardEmpty: "剪切板里没有可读取的图片",
clipboardAdded: "已读取 {{count}} 张参考图",
promptRequired: "请输入视频提示词",
referenceError: "{{error}}。{{hint}}",
invalidParams: "视频生成参数无效",
busy: "视频工作台已有任务正在运行",
generated: "视频已生成",
timeout: "视频生成超时,请稍后重试",
resultTitle: "生成视频",
source: "视频创作台",
empty: "还没有生成视频",
},
canvas: {
defaultTitle: "无限画布 {{count}}",
library: "画布库",
title: "无限画布",
imported: "已导入 {{count}} 个画布",
importFailed: "导入失败,请选择有效的画布压缩包",
opening: "正在打开画布...",
loading: "正在加载画布...",
exportSelected: "导出选中",
deleteSelected: "删除选中",
deleteAll: "删除全部",
import: "导入画布",
create: "新建画布",
empty: "还没有画布",
emptyDescription: "新建一个画布后,就可以独立保存节点、连线和画布外观。",
collapsePanel: "收起面板",
expandPanel: "展开面板",
home: "主页",
docs: "文档",
projects: "我的画布",
deleteCurrent: "删除当前画布",
importAsset: "导入资产",
exportCurrent: "导出当前画布",
undo: "撤销",
redo: "重做",
openMenu: "打开画布菜单",
renameHint: "双击修改画布名称",
shortcuts: "快捷键",
miniMapOpen: "打开小地图",
miniMapClose: "关闭小地图",
resetView: "重置视图",
zoom: "放大/缩小画布",
agentConnected: "Codex 已连接",
agentConnecting: "Codex {{activity}}",
agentDisconnected: "Codex 未连接",
connecting: "连接中",
openAgent: "打开本地 Codex 面板",
nodeTypes: { image: "图片", text: "文本", config: "生成配置", video: "视频", audio: "音频", group: "组" },
toolbar: {
move: "移动/选择", text: "文本", image: "图片", video: "视频", audio: "音频", config: "生成配置", group: "组", extensions: "扩展节点", upload: "上传资产", appearance: "画布外观", clear: "清空画布",
themeMode: "主题模式", light: "浅色", dark: "深色", gridStyle: "网格样式", dots: "点", lines: "线", blank: "空白", imageInfo: "图片信息",
},
project: {
untitled: "未命名画布", imported: "导入画布",
select: "选择 {{name}}", stats: "{{nodes}} 个节点 · {{connections}} 条连线", updated: "更新于 {{date}}", saveName: "保存名称", cancelRename: "取消重命名", export: "导出", rename: "重命名", delete: "删除",
deleteTitle: "删除画布?", deleteDescription: "将删除 {{count}} 个画布,里面的节点和连线也会一起移除。",
},
export: { defaultProjectName: "无限画布", defaultNodesName: "画布元素", item: "元素" },
createMenu: {
fromNode: "引用该节点生成", close: "关闭", text: "文本生成", textDescription: "脚本、广告词、品牌文案", image: "图片生成", video: "视频生成", audio: "音频参考", config: "配置节点", configDescription: "模型、尺寸、数量和输入顺序", select: "选择节点",
},
node: {
node: "节点",
untitled: "未命名节点", renameHint: "双击修改节点名称", group: "组", nodeCount: "{{count}} 个节点", generating: "生成中", failed: "生成失败", retry: "重试", missingPlugin: "缺少插件", missingPluginDescription: "节点类型“{{type}}”的插件未安装或未启用", generateImage: "用文本生图", generate: "生图", editText: "双击编辑文字", emptyImage: "空图片节点", emptyVideo: "空视频节点", emptyAudio: "空音频节点", audio: "音频", batchExpanded: "图片组已展开", batchCollapsed: "图片组已收起", setPrimary: "设为主图",
},
sidePanel: {
canvas: "画布", assets: "资产", prompts: "提示词库", resize: "调整左侧面板宽度", elements: "画布元素", select: "选择", searchNodes: "搜索节点", focusNode: "定位到节点", preview: "放大预览", noNodes: "画布暂无节点", clearAll: "取消全选", selected: "已选 {{count}}", exporting: "正在导出选中元素…", exportName: "画布元素-{{count}}个", exported: "已导出 {{count}} 个元素", exportFailed: "导出失败,请重试",
addingAssets: "正在添加资产…", addedAssets: "已添加 {{count}} 个资产", mediaOnly: "仅支持图片或视频文件", addFailed: "添加失败,请重试", searchAssets: "搜索资产", add: "添加", noAssets: "暂无资产", inserted: "插入画布", removeAssetTitle: "移除该资产?", remove: "移除", removeAsset: "移除资产", assetRemoved: "资产已移除",
searchPrompts: "搜索提示词", noPrompts: "暂无提示词", promptCopied: "已复制提示词", copyFailed: "复制失败", loadFailedRetry: "加载失败,点击重试", noMatchingPrompts: "无匹配提示词", sourceEmpty: "该来源暂无提示词", viewDetails: "查看详情",
filter: { image: "图片", video: "视频", text: "文本", audio: "音频", config: "配置", group: "分组" },
},
assetPicker: { title: "选择资产", insert: "插入", search: "搜索资产", empty: "没有资产" },
imageTools: { copyPrompt: "复制提示词", copyPromptTitle: "复制生成该图片的提示词", reversePrompt: "反推提示词", reversePromptTitle: "创建反推提示词的文本和配置节点", replace: "替换图片", locked: "锁比例", free: "自由比例", lockTitle: "切换为等比缩放", freeTitle: "切换为自由比例", mask: "局部编辑", maskTitle: "添加蒙版遮罩后局部修改", crop: "裁剪", cropTitle: "裁剪并生成新节点", split: "切图", splitTitle: "按行列切分图片", upscale: "放大", upscaleTitle: "放大图片分辨率", superResolve: "超分", superResolveTitle: "AI 超分", angle: "多角度", angleTitle: "生成角度", view: "查看大图", viewTitle: "查看图片详情", more: "更多", configure: "配置快捷工具", customize: "自定义工具栏", showLabels: "显示按钮文字", description: "选择你想在图片节点编辑栏中使用的快捷工具。", preview: "节点预览", imageNode: "图片节点", quickTools: "快捷工具" },
nodeToolbar: { noPrompt: "暂无可复制的提示词", infoTitle: "查看节点信息", info: "信息", removeTitle: "移除节点", retryTitle: "重新生成", saveAsset: "存资产", downloadAudio: "下载音频", downloadVideo: "下载视频", downloadImage: "下载图片", editTextTitle: "编辑文本", editText: "编辑文字", decreaseFont: "减小字号", increaseFont: "增大字号", zoomOut: "缩小", zoomIn: "放大", uploadImage: "上传图片", replaceVideo: "替换视频", uploadVideo: "上传视频", replaceAudio: "替换音频", uploadAudio: "上传音频", nodeInfo: "节点信息", name: "名称", type: "类型", size: "尺寸", position: "位置", status: "状态", imageGroup: "图片组", imageSize: "图片大小" },
configNode: { title: "生成配置", image: "生图", text: "文本", video: "视频", audio: "音频", prompt: "提示词", references: "参考图", videoReferences: "参考视频", audioReferences: "参考音频", items: "{{count}} 个", images: "{{count}} 张", compose: "组装提示词", stop: "停止", generate: "开始生成" },
projectPage: {
stopTitle: "停止生成?", stopDescription: "当前生成请求会被中断,已经生成完成的内容会保留。", stop: "停止", continue: "继续生成", configConnection: "配置节点之间不能连接", notFound: "未找到当前画布", exporting: "正在导出当前画布…", exported: "已导出当前画布", clipboardText: "剪切板文本", clipboardImageAdded: "已从剪切板添加图片", clipboardTextAdded: "已从剪切板添加文本", noTextToSave: "没有可保存的文本", canvasText: "画布文本", noVideoToSave: "没有可保存的视频", canvasVideo: "画布视频", noImageToSave: "没有可保存的图片", canvasImage: "画布图片", emptyReverse: "图片节点为空,无法反推提示词", reverseTitle: "反推提示词", reverseConfigTitle: "反推提示词配置", splitTitle: "{{name}} {{row}}-{{column}}", splitSuccess: "已切分为 {{count}} 个子节点", maskResult: "局部编辑结果", maskFailed: "局部修改失败", generationFailed: "生成失败", partialFailed: "部分图片生成失败", allFailed: "全部图片生成失败", retryPromptMissing: "找不到提示词,无法重试", referenceMissing: "参考图片已丢失,无法继续重试", emptyTextImage: "文本节点为空,无法生图", untitledCanvas: "未命名画布", superResolve: "AI 超分", notImplemented: "暂未实现", imageDetails: "图片详情", clearTitle: "清空画布?", clear: "清空", clearDescription: "这会删除当前画布上的所有节点和连线。", reversePreset: "请根据参考图片反推一段适合用于 AI 生图的提示词。\n\n要求:\n1. 只输出提示词正文,不要解释。\n2. 覆盖主体、构图、风格、光线、色彩、材质、镜头和氛围。\n3. 尽量写成可直接用于生图模型的完整提示词。", maskPrompt: "只修改蒙版透明区域,其他区域保持不变。{{prompt}}", editTextPrompt: "请根据要求修改以下文本。\n\n原文:\n{{source}}\n\n修改要求:\n{{prompt}}"
},
reverseComposer: "参考图片:@[node:{{imageId}}]\n任务说明:@[node:{{textId}}]",
editors: {
reset: "重置", zoomOut: "缩小", zoomIn: "放大", loading: "读取中", unknown: "未知", cancel: "取消",
angleTitle: "AI 多角度", angleDescription: "左侧只预览方向,结果会基于原图重新生成", horizontal: "左右角度", pitch: "俯仰角度", distance: "镜头距离", lens: "广角镜头", standard: "标准", wide: "广角", aiGenerate: "AI 生成",
upscaleTitle: "图片放大", source: "源图", targetPixels: "目标像素", maxReached: "图片已达到 4K,无需放大", targetReached: "图片已达到当前目标像素,无需放大", algorithm: "放大算法", outputSize: "输出尺寸", upscale: "生成放大图", high: "高清插值", highDescription: "适合照片和细节图", bilinear: "双线性", bilinearDescription: "平滑、速度快", nearest: "最近邻", nearestDescription: "适合像素风格",
cropTitle: "裁剪图片", adjustCrop: "调整裁剪框", cropHint: "滚轮缩放 · 中键或空格+左键拖动画面", cropSize: "裁剪尺寸 {{size}}", ratio: "比例 {{ratio}}", original: "原图 {{width}} x {{height}}", confirmCrop: "确认裁剪", free: "自由", fixed: "固定", originalMode: "原图",
maskTitle: "局部遮罩编辑", maskHint: "滚轮缩放 · 中键或空格+左键拖动画面 · Alt+左/右键横拖调笔刷 · Ctrl/Cmd+Z 撤回 · Ctrl/Cmd+Shift+Z 重做", brush: "画笔", erase: "擦除", undoMaskTitle: "撤回局部涂抹 (Ctrl/Cmd+Z)", undoMask: "撤回局部涂抹", redoMaskTitle: "重做局部涂抹 (Ctrl/Cmd+Shift+Z)", redoMask: "重做局部涂抹", brushSize: "笔刷大小", editInstructions: "修改要求", maskPlaceholder: "例如:把选中区域改成金属材质,保持原图光影", maskPromptRequired: "请输入修改要求", maskRequired: "请先涂抹局部区域", aiEdit: "AI 修改",
splitTitle: "切分图片", splitDescription: "生成 {{count}} 个图片子节点,并按原图网格排列到画布右侧", splitHint: "滚轮缩放 · 中键或空格+左键拖动画面 · Delete 删除选中线 · Ctrl/Cmd+Z 撤回 · Ctrl/Cmd+Shift+Z 重做", undoSplitTitle: "撤回切图调整 (Ctrl/Cmd+Z)", undoSplit: "撤回切图调整", redoSplitTitle: "重做切图调整 (Ctrl/Cmd+Shift+Z)", redoSplit: "重做切图调整", rows: "行数", columns: "列数", horizontalLine: "横向线", verticalLine: "纵向线", deleteLine: "删除线", resetLines: "重置线", pieceCount: "切片数量", pieces: "{{count}} 个", averageSize: "平均约", generateChildren: "生成子节点",
},
plugins: { title: "节点插件", installedPlugin: "已安装插件 {{name}}", installed: "已安装 {{name}}", installFailed: "安装失败:{{error}}", enabled: "已启用", disabled: "已禁用", upgradeAvailable: "有新版本,点击升级", updateFromSource: "从来源更新", updated: "已更新", uninstallTitle: "卸载该插件?", uninstall: "卸载", newVersion: "有新版本可升级", officialDescription: "本项目官方插件,来自仓库注册表", refresh: "刷新", loadFailed: "加载失败:{{error}}", loadingOfficial: "正在获取官方插件…", noOfficial: "暂无官方插件", install: "安装", urlPlaceholder: "输入插件 JS 文件 URL,例如 https://.../plugin.js", noThirdParty: "还没有安装第三方插件", official: "官方插件", local: "本地插件", thirdParty: "第三方插件", warning: "插件代码会在当前页面内直接执行,可访问本地数据(包含 AI API Key)。请仅安装你信任来源的插件。", aiConfigRequired: "AI 配置未就绪,请先在设置里配置模型与密钥", interactiveTitle: "当前:交互中。点击切回「移动」——拖动可移动节点", movableTitle: "当前:可移动。点击切到「交互」——可操作节点内容(如转动全景)", move: "移动", interact: "交互" },
promptPanel: { video: "描述要生成的视频内容", audio: "描述要生成的音频内容", image: "描述要生成的图片内容", text: "请输入你想要生成的文本内容", editImage: "请输入你想要把这张图修改成什么", editText: "请输入你想要将本段文本修改成什么", stopGeneration: "停止生成", generate: "生成", stop: "停止" },
composer: { title: "组装提示词", description: "@ 引用已连接资产,发送前按当前连接重新编号", placeholder: "输入提示词,按 @ 引用连接的图片或文本", imagePreview: "引用图片预览", resources: { image: "图片{{index}}", video: "视频{{index}}", audio: "音频{{index}}", text: "文本{{index}}" } },
controls: { ratio: "比例", duplicate: "复制", delete: "删除", images: "{{count}} 张", reasoning: "推理" },
generation: { interrupted: "页面刷新后生成已中断,请重新生成。", front: "正面视角", rotateRight: "向右旋转 {{angle}} 度", rotateLeft: "向左旋转 {{angle}} 度", level: "水平视角", topDown: "俯视 {{angle}} 度", lowAngle: "仰视 {{angle}} 度", angleLabel: "AI 多角度:{{horizontal}}{{pitch}},镜头距离 {{distance}}{{lens}}镜头", anglePrompt: "基于参考图重新生成同一主体的新视角,保持主体、颜色、材质和画面风格一致,不要只做透视变形。{{angle}}。" },
agentOps: { add_node: "新增节点", update_node: "更新节点", delete_node: "删除节点", delete_connections: "删除连线", connect_nodes: "连接", set_viewport: "调整视图", select_nodes: "选择节点", run_generation: "触发生成" },
pluginErrors: { invalidExport: "插件未导出有效对象", missingFields: "插件缺少 id 或 nodes", downloadFailed: "下载失败 (HTTP {{status}})", registryFailed: "获取官方插件列表失败 (HTTP {{status}})" },
shortcut: {
dragCanvas: "拖动画布",
pan: "平移视图",
wheel: "滚轮",
zoom: "缩放画布",
zoomSlider: "缩放滑杆",
preciseZoom: "精确调整缩放",
boxSelect: "框选多个节点",
addSelection: "追加选择节点",
selectAll: "全选节点",
copyPaste: "复制 / 粘贴节点,或粘贴剪切板文本/图片",
copyPasteNodes: "复制 / 粘贴节点",
delete: "删除选中",
escape: "取消选择并关闭浮层",
dropMedia: "拖入图片/视频/音频",
upload: "上传到画布",
drag: "拖动",
click: "点击",
},
},
navigation: {
canvas: "我的画布",
image: "生图工作台",
video: "视频创作台",
prompts: "提示词库",
assets: "我的资产",
config: "配置",
},
topNav: {
openMenu: "打开导航菜单",
menu: "导航菜单",
navigation: "导航",
openAgent: "打开 Agent",
closeAgent: "收起 Agent",
plugins: "节点插件",
docs: "文档",
shortcuts: "快捷键",
lightTheme: "切换到浅色主题",
darkTheme: "切换到深色主题",
},
home: {
promptError: "获取提示词失败",
description: "在 <canvas>无限画布</canvas> 中生成、连接和重组 <content>图片、文字与图形</content>,让创作从单次生成变成连续推演。",
start: "开始使用",
openCanvas: "打开画布",
showcaseTitle: "沉淀每一次好结果",
showcaseDescription: "收藏稳定出图的提示词、参考风格和结果图片,让下一次创作从已有经验开始。",
viewPrompts: "查看提示词库",
},
version: {
viewUpdates: "查看版本更新",
title: "版本更新",
currentVersion: "当前版本",
latestVersion: "最新版本",
checking: "检查中...",
checkUpdates: "检查更新",
unreleased: "未发布",
latest: "最新",
current: "当前",
readFailed: "版本读取失败",
changelogFailed: "更新日志读取失败",
updated: "已获取最新版本信息",
updateFailed: "获取最新版本信息失败",
types: { added: "新增", fixed: "修复", changed: "调整", optimized: "优化", docs: "文档" },
},
config: {
title: "配置与用户偏好",
invalidFile: "配置文件格式不正确",
description: "渠道聚合、模型选择和同步偏好",
modalDescription: "渠道聚合、默认模型和同步偏好",
tabs: {
channels: "渠道",
preferences: "偏好设置",
promptSources: "提示词来源",
},
promptSources: {
add: "新增来源",
deleteTitle: "删除「{{name}}」?",
deleteDescription: "来源配置会被移除,已经加入我的资产的内容不受影响。",
refreshed: "「{{name}}」已更新 {{count}} 条",
refreshFailedCached: "更新失败,已保留旧缓存",
refreshPartial: "更新完成:{{success}} 个成功,{{failed}} 个失败,失败来源已保留旧缓存",
refreshAllSuccess: "已更新 {{sources}} 个来源,共 {{total}} 条",
refreshFailed: "更新失败",
builtIn: "内置",
itemCount: "{{count}} 条",
failed: "失败",
healthy: "正常",
unsynced: "未同步",
lastSuccess: "上次成功 {{time}}",
neverFetched: "尚未拉取",
view: "查看内容",
refresh: "立即拉取",
edit: "编辑来源",
schedule: "定时拉取",
interval: "拉取周期",
refreshAll: "全部立即拉取",
lastFetched: "上次拉取 {{time}}",
neverScheduled: "尚未定时拉取",
scheduleDescription: "开启周期后,页面打开期间会按周期自动拉取所有启用的来源。",
intervals: {
disabled: "关闭定时",
minutes30: "每 30 分钟",
hour1: "每 1 小时",
hours6: "每 6 小时",
hours24: "每 24 小时",
},
editor: {
addTitle: "新增提示词来源",
editTitle: "编辑提示词来源",
nameRequired: "请输入来源名称",
invalidUrl: "请输入有效的 JSON URL",
invalidHomepage: "请输入有效的主页地址",
name: "来源名称",
namePlaceholder: "用于分类展示",
homepage: "来源主页(可选)",
enabled: "启用来源",
jsonFormat: "JSON 格式",
},
content: {
loadFailed: "拉取提示词失败",
title: "{{name}} · 提示词内容",
count: "共 {{count}} 条",
refresh: "立即更新",
empty: "暂无提示词",
cover: "封面",
titleColumn: "标题",
tags: "标签",
actions: "操作",
},
runtime: { requestFailed: "请求失败({{status}}", urlRequired: "JSON URL 不能为空", fetchFailed: "「{{name}}」拉取失败:{{error}}", noPrompts: "「{{name}}」未解析到有效提示词", invalidRoot: "「{{name}}」格式错误:根节点必须是数组" },
},
fileSecurity: "JSON 文件包含 API Key 和 WebDAV 凭据,请妥善保管。",
import: "导入配置",
export: "导出配置",
imported: "配置与用户偏好已导入",
importedDirectConfig: "已导入本地直连配置",
importFailed: "配置文件读取失败",
saved: "配置已保存",
savedContinue: "配置已保存,请继续刚才的请求",
channels: {
description: "每个渠道选择一个协议并拉取模型,为每个模型指定能力(生图/视频/文本/音频),并可自定义调用脚本。",
add: "新增渠道",
unnamed: "未命名渠道",
numberedName: "渠道 {{count}}",
modelCount: "{{count}} 个模型",
missingUrl: "未填写接口地址",
keepOne: "至少保留一个渠道",
defaultName: "默认渠道",
newName: "新渠道",
indexedName: "渠道 {{index}}",
},
preferences: {
interface: "界面偏好",
language: "界面语言",
languageDescription: "切换应用界面及组件语言。",
defaultModels: "默认模型",
defaultImageModel: "默认生图模型",
defaultVideoModel: "默认视频模型",
defaultTextModel: "默认文本模型",
defaultAudioModel: "默认音频模型",
generation: "生成偏好",
canvasImageCount: "画布默认生图张数",
canvasImageCountDescription: "新建画布生图和配置节点默认使用,单个节点仍可单独覆盖。",
audioVoice: "默认音频声音",
audioFormat: "默认音频格式",
audioSpeed: "默认音频语速",
audioInstructions: "默认音频指令",
audioInstructionsPlaceholder: "例如:自然、温暖、适合旁白。",
systemPrompt: "系统提示词",
systemPromptPlaceholder: "例如:你是一位擅长电影感写实摄影的视觉导演。",
},
channelEditor: {
title: "编辑渠道",
name: "渠道名称",
protocol: "协议",
baseUrl: "接口地址",
models: "渠道模型",
modelDescription: "已选 {{count}} 个;为每个模型指定能力并可自定义调用脚本。",
selectModels: "选择模型",
scriptReady: "脚本已设",
script: "调用脚本",
empty: "点击「选择模型」拉取或手动增加模型。",
capabilities: {
image: "生图",
video: "视频",
text: "文本",
audio: "音频",
},
},
modelSelect: {
missingConfig: "请先填写接口地址和 API Key",
fetched: "已拉取 {{count}} 个模型",
fetchFailed: "拉取模型失败",
title: "选择渠道模型",
selected: "已选择 {{selected}} / {{total}}",
confirm: "确定",
search: "搜索模型",
modelName: "输入模型名称",
add: "增加模型",
fetch: "拉取模型列表",
description: "如果上游不提供 OpenAI /models 模型列表接口,请在这里手动增加模型名称。",
fetchedTab: "新获取的模型 ({{count}})",
existingTab: "已有的模型 ({{count}})",
visibleSelected: "当前列表已选择 {{selected}} / {{total}}",
selectVisible: "全选当前列表",
clearVisible: "取消当前列表",
fetchedEmpty: "点击「拉取模型列表」获取上游模型,或手动增加模型名称。",
existingEmpty: "暂无已选择的模型。",
},
scriptEditor: {
description: "脚本是一段异步函数体,直接使用下方变量,最后 return 结果;留空则使用系统默认调用。",
insertTemplate: "插入{{name}}模板",
restoreDefault: "恢复默认调用",
returnRequirements: "返回要求",
variables: "可用变量",
insert: "点击插入",
placeholder: "// 留空使用系统默认调用;点击右下角「插入模板」查看示例。",
},
webdav: {
title: "WebDAV 同步",
description: "同步画布、我的资产、生成记录和本地媒体文件,不包含 AI API Key;浏览器会直接连接 WebDAV 服务。",
lastSynced: "上次同步 {{time}}",
neverSynced: "尚未同步",
url: "WebDAV 地址",
directory: "远程目录",
directoryDescription: "会在该目录下分业务目录保存,每个目录包含 {{manifest}} 和 files/",
username: "用户名",
password: "密码 / 应用密码",
test: "测试连接",
syncing: "同步中",
syncNow: "立即同步",
missingUrl: "请先填写 WebDAV 地址",
available: "WebDAV 连接可用",
testFailed: "WebDAV 连接测试失败",
preparing: "准备同步",
failed: "WebDAV 同步失败",
completed: "同步完成:{{projects}} 个画布,{{assets}} 个资产,{{records}} 条记录,本次上传 {{files}} 个文件 {{bytes}}",
domains: {
canvas: "画布",
assets: "我的资产",
imageWorkbench: "生图工作台",
videoWorkbench: "视频创作台",
},
stages: {
waiting: "等待同步",
localWaiting: "等待本地数据加载",
syncComplete: "同步完成",
remoteManifest: "读取远端清单",
localData: "读取本地数据",
downloadMedia: "下载缺失媒体",
writeMerge: "写入本地合并结果",
uploadMedia: "上传新增媒体",
mediaReady: "媒体已齐全",
mediaSkipped: "媒体无需上传",
checkMissingMedia: "检查缺失媒体",
downloadMediaFile: "下载媒体",
checkLocalMedia: "检查本地媒体",
uploadMediaFile: "上传媒体 {{size}}",
uploadManifest: "上传清单 {{size}}",
complete: "完成",
},
errors: { testFailed: "WebDAV 连接测试失败", downloadFailed: "读取 WebDAV 同步文件失败", downloadTimeout: "读取 WebDAV 同步文件超时", emptyUpload: "上传文件为空,已取消上传", uploadFailed: "上传 WebDAV 同步文件失败", directoryFailed: "创建 WebDAV 远程目录失败", requestTimeout: "WebDAV 请求超时,请检查网络或远端服务状态", connectionFailed: "无法连接 WebDAV,请检查地址、HTTPS 证书、CORS 或网络状态", urlRequired: "请先填写 WebDAV 地址", authenticationFailed: "WebDAV 认证失败,请检查用户名、密码或应用密码", pathMissing: "WebDAV 路径不存在,请检查地址和远程目录", responseFailed: "{{fallback}}{{status}}{{detail}}", syncFailed: "同步失败", invalidManifest: "{{domain}} 同步清单不是当前应用的数据" },
},
protocols: {
ark: "火山方舟",
},
},
agent: {
status: { failed: "连接失败", connected: "已连接", connecting: "连接中", disconnected: "未连接" },
state: { ready: "就绪", connectionRequired: "请填写 Local URL 和 Connect token", invalidUrl: "Local URL 格式不正确", offline: "离线", skillReadFailed: "读取 Skill 失败", skillParseFailed: "Skill 解析失败", requestFailed: "本地 Agent 请求失败" },
siteTools: { canvasList: "画布列表", generationStatus: "生成任务状态", imageConfig: "生图配置", imageGenerate: "生图工作台生成", videoConfig: "视频配置", videoGenerate: "视频创作台生成", promptSearch: "搜索提示词", assetList: "资产列表", assetAdd: "添加资产", unknownTool: "未知工具:{{name}}", canvasLoading: "画布还在加载中,请稍后重试", canvasHint: "用 site_navigate 跳转 /canvas/{id} 打开对应画布", assetsLoading: "资产还在加载中,请稍后重试", assetTitleRequired: "请提供资产标题 title", textContentRequired: "kind=text 时需要提供 content 文本内容", imageUrlRequired: "kind=image 时需要提供 imageUrl(图片地址或 dataURL", imageReadFailed: "无法读取该图片地址,请改用 dataURL 或可跨域访问的图片链接", assetKindUnsupported: "assets_add 仅支持 kind=text 或 kind=image", imageGenerationStarted: "已跳转生图工作台并触发生成,可用 generation_get_status 查询任务", imageConfigApplied: "已跳转生图工作台并填入参数,未触发生成", videoGenerationStarted: "已跳转视频创作台并触发生成,可用 generation_get_status 查询任务", videoConfigApplied: "已跳转视频创作台并填入参数,未触发生成" },
connect: { pluginTitle: "方式一:在 Codex 中使用插件", pluginText: "在 Codex app 安装 Infinite Canvas 插件后,通过插件启动画布,插件会自动启动本地 Agent 并带上连接信息。", directTitle: "方式二:直接运行 Agent", directText: "不使用 Codex 插件时,在终端运行下面命令,再回到网页里连接或手动填入 Local URL 和 Connect token。", commandCopied: "命令已复制", pluginReminder: "Codex 插件提醒", pluginReminderText: "只有安装 Codex 插件或手动添加 MCP 后,工具列表才会进入 Codex 上下文并增加 token 消耗;仅运行 npx -y @basketikun/canvas-agent 启动本地 Agent 不会安装 MCP。", removePlugin: "移除插件", removeMcp: "移除手动 MCP", copyCommand: "复制命令", title: "连接本地 Agent", description: "按使用场景选择一种连接方式。", webConnection: "网页连接", autoDiscover: "默认自动读取 Local URL 和 Connect token,失败时再手动填写。", disconnect: "断开", connect: "连接", localAddress: "本地地址", urlPlaceholder: "例如 http://127.0.0.1:17371", token: "连接 Token", tokenPlaceholder: "自动发现,或手动填入 Connect token" },
history: { workspace: "工作空间", defaultWorkspace: "默认画布目录", selected: "已选 {{count}} 条", count: "{{count}} 条历史", empty: "暂无历史", deleteCount: "删除 {{count}} 条", refresh: "刷新", newThread: "新对话", selectThread: "选择{{name}}", untitled: "未命名对话", current: "当前", noWorkspaceThreads: "当前工作空间还没有对话记录", connectHint: "连接本地 Agent 后显示历史记录" },
skills: { selectLocal: "选择本地 Skill", search: "搜索 Skill", loading: "正在读取 Skill…", noMatch: "没有匹配的已启用 Skill", none: "还没有可用的 Skill", select: "选择 Skill", connectHint: "连接 Agent 后使用 Skill" },
skillManager: {
codexBusy: "Codex 正在运行,请完成当前任务后再提炼 Skill", noConversation: "当前对话还没有可提炼的已完成内容", noCanvas: "当前页面没有可提炼的画布", connecting: "当前页面仍在连接 Agent,请稍后再试", syncFailed: "同步当前画布失败,请检查 Agent 连接后重试", noDraft: "未生成 Skill 草稿", draftCreated: "草稿已生成,可在技能页确认后创建", draftFailed: "生成 Skill 草稿失败", contentMissing: "未读取到 Skill 内容", readFailed: "读取 Skill 失败", defaultPromptMention: "默认提示词需要包含 ${{name}}", created: "Skill 已创建", updated: "Skill 已更新", saveFailed: "保存 Skill 失败", deleteTitle: "删除 {{name}}", deleteDescription: "删除后本地文件无法恢复,确定继续吗?", delete: "删除", deleted: "Skill 已删除", deleteFailed: "删除 Skill 失败", statusFailed: "更新 Skill 状态失败",
fromConversation: "从当前对话生成草稿", availableAfterRun: "Codex 运行结束后可用", conversationDescription: "整理当前对话中的可复用流程", noCompletedContent: "当前对话还没有已完成内容", startConversation: "请先开始一段对话", fromCanvas: "从当前画布生成草稿", canvasDescription: "整理当前页面的节点与生成流程", canvasUnavailable: "当前页面没有可用画布", blankCreate: "空白创建", blankDescription: "从空白表单开始编写", localSkills: "本地 Skill", localDescription: "安装在本机,由 Codex 直接执行", reload: "重新读取", reloadSkill: "重新读取 Skill", createSkill: "创建 Skill", filterBySource: "按来源筛选 Skill", scopes: { all: "全部来源", repo: "项目", user: "个人", system: "系统", admin: "管理员" }, loadErrors: "{{count}} 个 Skill 未能加载", externalReadonly: "外部 Skill 只能使用或启停", noDescription: "暂无说明", enabled: "已启用", disabled: "已停用", selected: "已选择", use: "使用", editNamed: "编辑 {{name}}", deleteNamed: "删除 {{name}}", connectToView: "连接 Agent 后查看 Skill", noMatch: "没有匹配的 Skill", none: "还没有本地 Skill", connectDescription: "连接成功后会读取本机已安装的 Skill", tryAnotherFilter: "换个关键词或来源试试", createOrInstall: "创建一个,或在本机安装后刷新", saveChanges: "保存更改", saveLocation: "保存到本地 Agent 工作区", basicInfo: "基本信息",
identifier: "Skill 标识", identifierExtra: "用于文件夹名和 $skill-name 调用。", identifierRequired: "请输入 Skill 标识", identifierMax: "Skill 标识不能超过 64 个字符", identifierPattern: "仅支持小写字母、数字和连字符,连字符不能连续或位于首尾", identifierPlaceholder: "例如 product-grid", displayName: "显示名称", displayNameMax: "显示名称不能超过 64 个字符", displayNamePlaceholder: "例如 产品九宫格生成", whenToUse: "何时使用", whenToUseExtra: "说明这个 Skill 的能力和适用场景,Codex 会据此判断是否调用。", whenToUseRequired: "请输入使用场景", whenToUseMax: "使用场景不能超过 1024 个字符", noAngleBrackets: "使用场景不能包含尖括号", whenToUsePlaceholder: "例如:当用户需要基于商品信息规划并生成一组产品图时使用", instructions: "执行说明", instructionsExtra: "按实际执行顺序写清步骤、约束和输出要求。", instructionsRequired: "请输入执行说明", instructionsPlaceholder: "写清楚执行步骤、必要检查和最终输出", advanced: "高级设置", shortDescription: "卡片短说明", shortDescriptionExtra: "填写时控制在 25–64 个字符,便于快速浏览。", shortDescriptionMin: "卡片短说明不能少于 25 个字符", shortDescriptionMax: "卡片短说明不能超过 64 个字符", shortDescriptionPlaceholder: "可选,用于列表展示", defaultPrompt: "默认提示词", defaultPromptExtra: "填写时必须准确包含 $skill-name,例如 $product-grid。", defaultPromptMax: "默认提示词不能超过 1024 个字符", defaultPromptPlaceholder: "可选,选择 Skill 时预填到输入框",
},
composer: { removeImage: "移除图片", removeSkill: "移除 Skill", uploadImage: "上传图片", stop: "停止", send: "发送", model: "模型:{{model}}", selectModel: "选择 Codex 模型,当前为 {{model}}", reasoning: "思考程度:{{effort}}", selectReasoning: "选择思考程度,当前为 {{effort}}", permissionLabel: "权限:{{mode}}", selectPermission: "选择 Codex 权限模式,当前为 {{mode}}", effort: { minimal: "最低", low: "轻度", medium: "中", high: "高", xhigh: "极高", max: "最高", ultra: "Ultra" }, permission: { request: "请求批准", requestDescription: "编辑工作区外文件或联网时始终询问", automatic: "自动审查", automaticDescription: "由 Codex 审查风险操作,必要时再询问", full: "完全访问权限", fullShort: "完全访问", fullDescription: "不受限制地访问网络和本机文件" }, tools: { label: "工具确认:{{mode}}", manual: "手动确认", manualDescription: "Agent 执行画布写入前会请求确认", automatic: "自动确认", automaticDescription: "Agent 会自动执行画布写入操作", select: "选择工具确认模式,当前为 {{mode}}" } },
panel: { connectionSettings: "连接设置 · {{status}}", connectionSettingsLabel: "连接设置,当前{{status}}", chat: "对话", history: "历史", skills: "技能", logs: "日志", collapse: "收起对话", collapseLabel: "收起 Agent 面板", resize: "调整右侧面板宽度", content: "Agent 内容", mcpInitializing: "MCP 初始化中,完成后即可发送", initFailed: "Codex 对话初始化失败,请新建或恢复对话", placeholder: "询问 Codex,或让它操作网站/画布" },
chat: { latestMessages: "查看最新消息", latestCall: "最新调用", input: "输入", cached: "缓存", output: "输出" },
logs: { copied: "日志已复制", selectedManual: "已选中日志,请手动复制", copyFailed: "复制失败,请切换到原始 JSON 后手动复制", title: "运行日志", diagnostics: "排查日志", rawJson: "原始 JSON", messages: "{{count}} 条消息", tool: "工具:{{tool}}", noPendingTool: "无待处理工具", all: "全部 {{count}}", errors: "错误 {{count}}", warnings: "警告 {{count}}", info: "信息 {{count}}", list: "排查日志列表", noFiltered: "当前筛选下没有日志", empty: "暂无事件日志", new: "{{count}} 条新日志", newLabel: "{{count}} 条新日志,查看最新日志", latest: "查看最新日志", fullData: "完整诊断数据 · {{count}} 条", copyAll: "复制全部日志", copyLastError: "复制最近错误", lastErrorCopied: "最近错误已复制", clear: "清空日志", repeated: "重复 {{count}} 次", details: "详细信息", skillLoading: "技能加载", plugin: "插件", terminal: "终端", conversationStorage: "会话存储" },
message: { close: "关闭", copied: "已复制", copyCode: "复制代码", copyLink: "复制链接", externalWarning: "即将打开以下外部链接,请确认链接可信。", openExternal: "打开外部链接?", continueOpen: "继续打开", revealed: "已在文件管理器中定位", openLocalFailed: "无法打开本地文件", openLocal: "打开本地文件?", localDescription: "将在本机文件管理器中定位该路径,不会通过浏览器打开。", externalDescription: "即将打开以下外部链接,请确认链接可信。", pathCopied: "路径已复制", linkCopied: "链接已复制", copyPath: "复制路径", showInFolder: "在文件管理器中显示", toolCall: "工具调用", awaitingConfirmation: "等待确认", reject: "拒绝执行", approve: "批准执行", networkApproval: "请求网络访问", fileApproval: "请求编辑文件", permissionApproval: "请求扩展权限", commandApproval: "请求执行命令", decline: "拒绝", allowOnce: "允许一次", allowSession: "本会话允许", thinking: "正在思考", commandsRunning: "正在执行 {{count}} 条命令", commandRunning: "正在执行命令", commandsCompleted: "已执行 {{count}} 条命令{{failed}}", commandsFailed: " · {{count}} 条失败", failed: "执行失败", running: "进行中", completed: "已完成", command: "命令", slowResponse: "响应时间较长,但任务仍在运行。可以继续等待,或点击输入框右侧的停止按钮结束本轮。", waitingSeconds: "已等待 {{seconds}} 秒", waitingMinutes: "已等待 {{minutes}} 分 {{seconds}} 秒", files: "涉及文件", errorInfo: "错误信息", output: "运行输出", viewLarge: "点击查看大图", attachmentPreview: "图片附件预览", noEffect: "未生效", canceled: "已取消", recorded: "已记录", stopped: "已停止", finished: "已结束", pending: "待处理" },
events: { analyzing: "正在分析任务…", reasoning: "思考摘要", plan: "执行计划", commandFailed: "命令执行失败", commandCompleted: "命令已完成", executeCommand: "执行命令", editFiles: "修改文件", searchWeb: "搜索资料", viewImage: "查看图片", imageViewed: "已查看图片", viewingImage: "正在查看图片", imageGeneration: "内置生图", imageFailed: "图片生成失败", imageCompleted: "图片生成完成", generatingImage: "正在生成图片…", compactContext: "整理上下文", contextCompacted: "已整理当前对话,继续处理任务", compactingContext: "正在整理当前对话…", collaboration: "协作处理", collaborationFailed: "协作任务失败", collaborationCompleted: "已完成协作任务", collaborating: "正在协作处理任务…", progress: "任务进度", progressCount: "已完成 {{completed}}/{{total}} 项", planning: "正在整理执行步骤…", executingCommand: "正在执行命令…", workingDirectory: "工作目录", exitStatus: "退出状态", duration: "耗时", seconds: "{{value}} 秒", filesCompleted: "已完成文件修改", preparingFiles: "正在准备文件修改…", fileCompleted: "已{{action}} {{path}}", editingFile: "正在{{action}} {{path}}", filesEdited: "已修改 {{count}} 个文件:{{names}}{{more}}", editingFiles: "正在修改 {{count}} 个文件:{{names}}{{more}}", andMore: " 等", openPage: "打开网页:{{url}}", findInPage: "在网页中查找“{{pattern}}”", content: "内容", search: "搜索:{{query}}", relatedInfo: "相关资料", keyword: "关键词", webpage: "网页", add: "新增", delete: "删除", edit: "修改", diagnostics: "Infinite Canvas Agent 诊断", address: "地址:{{endpoint}}", connection: "连接:{{connection}} · 状态:{{status}}", online: "在线", connecting: "连接中", disabled: "未启用", messageTool: "消息:{{messages}} · 工具:{{tool}}", none: "无", noLogs: "暂无事件日志", threadCreated: "创建会话", turnStarted: "开始处理", progressUpdated: "更新任务进度", turnFailed: "处理失败", turnStopped: "处理已停止", turnCompleted: "处理完成", toolCalled: "调用工具", toolFailed: "工具失败", toolCompleted: "工具完成", replyReceived: "收到回复", completed: "完成", modelBusy: "模型暂时繁忙", modelBusyDescription: "当前选择的模型请求量过大,暂时无法处理。请稍后重试,或切换其他模型后再试。", taskFailed: "任务失败", taskFailedDescription: "Codex 未能完成本次任务,请稍后重试。" },
eventExtra: { toolNamed: "调用工具:{{name}}", toolOperation: "工具操作", targetPage: "目标页面", searchContent: "搜索内容", textContent: "文本内容", operationContent: "操作内容", imageCount: "图片数量", tools: { generateImage: "生成图片", viewImage: "查看图片", executeCommand: "执行命令", editFiles: "修改文件", searchWeb: "搜索资料", canvasOps: "画布操作", readCanvas: "读取画布", readSelection: "读取选区", exportSnapshot: "导出快照", createNode: "创建节点", addAttachments: "添加附件图片", createText: "创建文本", createTexts: "批量创建文本", createConfig: "创建生成配置", createImageFlow: "创建生图流程", createGenerationFlow: "创建生成流程", generateText: "生成文本", generateVideo: "生成视频", generateAudio: "生成音频", updateNode: "更新节点", updateText: "更新文本", moveNodes: "移动节点", resizeNode: "调整节点尺寸", deleteNodes: "删除节点", connectNodes: "连接节点", selectNodes: "选择节点", setViewport: "调整视口", runGeneration: "触发生成", openPage: "打开页面" } },
eventMore: { toolRunning: "{{action}}中…", openedRoute: "已打开{{route}}", canvasCount: "共 {{count}} 个画布", promptCount: "找到 {{count}} 条提示词", assetCount: "共 {{count}} 个资产", assetAdded: "已加入我的素材", generationStatus: "共 {{total}} 个任务,排队 {{queued}},运行中 {{running}},成功 {{succeeded}},失败 {{failed}}", workbenchExecuted: "已在工作台执行", workbenchConfigRead: "已读取工作台配置", canvasRead: "已读取当前画布内容", selectionRead: "已读取当前选中内容", textNodes: "{{count}} 个文本", imageNodes: "{{count}} 张图片", configNodes: "{{count}} 个配置", videoNodes: "{{count}} 个视频", audioNodes: "{{count}} 个音频", groupNodes: "{{count}} 个分组", otherNodes: "{{count}} 个其他节点", connections: "{{count}} 条连线", emptyCanvas: "当前画布为空", executeTool: "执行{{tool}}", thinking: "正在思考...", operationRunning: "{{operation}}正在进行...", organizingCanvas: "画布已读取,Codex 正在整理结果...", operationCompleted: "{{operation}}已完成,Codex 正在继续处理...", attachmentPrompt: "请处理上传的图片附件。", routes: { home: "首页", canvas: "画布页面", canvasProject: "指定画布", image: "生图工作台", video: "视频工作台", prompts: "提示词中心", assets: "我的素材", config: "配置页面" } },
runtime: {
mcpStarting: "正在启动 MCP{{name}}", mcpConnecting: "正在建立工具连接并读取可用工具列表", mcpReadyNamed: "MCP 已就绪:{{name}}", toolsReady: "工具列表加载完成,可以开始对话", mcpFailedNamed: "MCP 启动失败:{{name}}", mcpCanceledNamed: "MCP 启动已取消:{{name}}", toolInitFailed: "工具服务未能完成初始化", mcpServicesStarting: "正在启动 MCP 服务", toolServicesPending: "还有 {{count}} 个工具服务正在初始化", checkingToolServices: "正在确认工具服务状态", conversationInitializing: "正在初始化 Codex 对话", conversationCreating: "正在创建会话并启动画布工具服务", someMcpFailed: "部分 MCP 服务初始化失败", remainingToolsReady: "其余工具已就绪,可以开始对话", conversationInitFailed: "Codex 对话初始化失败", conversationCreateFailed: "无法创建 Codex 会话", mcpServicesReady: "{{count}} 个 MCP 服务已完成初始化", toolInitCanceled: "工具服务初始化已取消", mcpStatusComplete: "MCP 状态确认完成", mcpListRead: "已读取 Codex 返回的完整工具服务清单",
historyReadFailed: "读取历史失败", conversationSyncFailed: "同步会话失败", agentOutdated: "本地 Agent 版本过旧,请重启 Canvas Agent 后重新连接", restartRequired: "需要重启 Agent", versionMismatch: "Agent 版本不匹配", awaitingApproval: "等待权限确认", codexRunning: "Codex 正在运行", connected: "已连接", localAgentConnected: "本地 Agent 已连接", processingFailed: "处理失败", completed: "完成", approvalGranted: "已批准权限", approvalCanceled: "已取消权限", log: "日志", connectionLostDescription: "本地 Agent 连接失败或已断开", connectionFailedDescription: "连接失败,请检查地址和 token", connectionLost: "连接断开", connectionFailed: "连接失败", modelListFailed: "读取模型列表失败",
imageTooLarge: "图片过大", imagePayloadTooLarge: "图片附件超过 30MB,请删减后再发送。", imagesSent: "发送了 {{count}} 张图片", sending: "发送中", defaultModel: "默认模型", defaultEffort: "默认强度", sendTask: "发送任务", attachmentCount: "附件 {{count}}", attachmentsOnly: "仅附件", startConversationFailed: "启动对话失败", attachmentHistoryFailed: "保存附件历史失败", sendFailed: "发送失败", conversationSynced: "会话已同步", taskStillRunning: "任务仍在运行", stopping: "停止中", stopTask: "停止任务", taskStopped: "任务已停止", stopFailed: "停止失败", imageLimit: "图片附件最多约 30MB。", imageReadFailed: "图片读取失败", pendingCanvasTool: "仍有待确认的画布工具调用", awaitingConfirmation: "等待确认", toolCompleted: "{{tool}}完成", toolExecutionFailed: "工具执行失败", openCanvasFirst: "当前不在画布页,请先用 site_navigate 打开画布", canvasOperationFailed: "画布操作失败", canvasToolCanceled: "用户取消了画布工具调用",
submittingApproval: "正在提交权限决定", waitingCodexApproval: "等待 Codex 确认权限", approvalFailed: "权限审批失败", enableFullAccess: "启用完全访问权限", fullAccessDescription: "Codex 将不受沙箱限制,可访问互联网及本机任意文件。请仅在信任当前任务时使用。", enableFullAccessAction: "启用完全访问", offline: "离线", addressRequired: "请填写本地 Agent 地址", agentNotFound: "没有发现本地 Agent,请先在 Codex 使用插件或手动启动 Canvas Agent", invalidAddress: "本地 Agent 地址格式不正确", connecting: "连接中", creatingConversation: "正在新建对话", newConversation: "新对话", newConversationFailed: "新建对话失败", conversationResumed: "已恢复会话", resumeConversationFailed: "恢复对话失败", recordsDeleted: "已删除 {{count}} 条记录", deleteConversationFailed: "删除对话失败", deleteConversations: "删除 {{count}} 条对话记录", deleteConversationsDescription: "删除后无法恢复,确定继续吗?", importGeneratedImages: "导入生成图片", addedToSourceCanvas: "已添加到发起任务的画布", imageGenerated: "图片已生成", noImageAttachments: "没有可添加的图片附件", invalidAttachmentNode: "图片附件节点参数无效", attachmentReadFailed: "读取图片附件失败", referenceImage: "参考图", generatedImageReadFailed: "读取 Codex 生成图片失败", generatedImageName: "生成图片 {{index}}",
},
},
locale: {
zhCN: "简体中文",
enUS: "English",
},
};
+26 -21
View File
@@ -1,5 +1,6 @@
import type { NavigateFunction } from "react-router-dom"; import type { NavigateFunction } from "react-router-dom";
import i18n from "@/i18n";
import { fetchPrompts } from "@/services/api/prompts"; import { fetchPrompts } from "@/services/api/prompts";
import { uploadImage } from "@/services/image-storage"; import { uploadImage } from "@/services/image-storage";
import { imageAspectOptions, imageQualityOptions } from "@/components/image-settings-panel"; import { imageAspectOptions, imageQualityOptions } from "@/components/image-settings-panel";
@@ -31,16 +32,20 @@ export function isSiteTool(name: string): name is SiteToolName {
return (SITE_TOOL_NAMES as readonly string[]).includes(name); return (SITE_TOOL_NAMES as readonly string[]).includes(name);
} }
function siteText(key: string, options?: Record<string, unknown>) {
return i18n.t(`agent.siteTools.${key}`, options);
}
export const SITE_TOOL_LABELS: Record<SiteToolName, string> = { export const SITE_TOOL_LABELS: Record<SiteToolName, string> = {
canvas_list_projects: "画布列表", get canvas_list_projects() { return siteText("canvasList"); },
generation_get_status: "生成任务状态", get generation_get_status() { return siteText("generationStatus"); },
workbench_image_get_config: "生图配置", get workbench_image_get_config() { return siteText("imageConfig"); },
workbench_image_generate: "生图工作台生成", get workbench_image_generate() { return siteText("imageGenerate"); },
workbench_video_get_config: "视频配置", get workbench_video_get_config() { return siteText("videoConfig"); },
workbench_video_generate: "视频创作台生成", get workbench_video_generate() { return siteText("videoGenerate"); },
prompts_search: "搜索提示词", get prompts_search() { return siteText("promptSearch"); },
assets_list: "资产列表", get assets_list() { return siteText("assetList"); },
assets_add: "添加资产", get assets_add() { return siteText("assetAdd"); },
}; };
type SiteToolInput = Record<string, unknown>; type SiteToolInput = Record<string, unknown>;
@@ -69,7 +74,7 @@ export async function runSiteTool(name: SiteToolName, input: SiteToolInput, navi
case "assets_add": case "assets_add":
return addAsset(input); return addAsset(input);
default: default:
throw new Error(`未知工具:${name}`); throw new Error(siteText("unknownTool", { name }));
} }
} }
@@ -125,7 +130,7 @@ function compactPrompt(prompt: unknown) {
function listCanvasProjects(input: SiteToolInput) { function listCanvasProjects(input: SiteToolInput) {
const { projects, hydrated } = useCanvasStore.getState(); const { projects, hydrated } = useCanvasStore.getState();
if (!hydrated) throw new Error("画布还在加载中,请稍后重试"); if (!hydrated) throw new Error(siteText("canvasLoading"));
const keyword = String(input.keyword || "").trim().toLowerCase(); const keyword = String(input.keyword || "").trim().toLowerCase();
const filtered = keyword ? projects.filter((project) => project.title.toLowerCase().includes(keyword)) : projects; const filtered = keyword ? projects.filter((project) => project.title.toLowerCase().includes(keyword)) : projects;
const { page, pageSize, start, end } = paginate(input, filtered.length, 20); const { page, pageSize, start, end } = paginate(input, filtered.length, 20);
@@ -137,7 +142,7 @@ function listCanvasProjects(input: SiteToolInput) {
nodeCount: project.nodes.length, nodeCount: project.nodes.length,
connectionCount: project.connections.length, connectionCount: project.connections.length,
})); }));
return { total: filtered.length, page, pageSize, items, hint: "用 site_navigate 跳转 /canvas/{id} 打开对应画布" }; return { total: filtered.length, page, pageSize, items, hint: siteText("canvasHint") };
} }
function getImageConfig() { function getImageConfig() {
@@ -177,7 +182,7 @@ function runImageWorkbench(input: SiteToolInput, navigate: NavigateFunction) {
const run = input.run !== false; const run = input.run !== false;
navigate("/image"); navigate("/image");
const taskId = useWorkbenchAgentStore.getState().dispatchImage({ prompt, run }); const taskId = useWorkbenchAgentStore.getState().dispatchImage({ prompt, run });
return { ok: true, navigated: "/image", prompt, run, taskId, applied, note: run ? "已跳转生图工作台并触发生成,可用 generation_get_status 查询任务" : "已跳转生图工作台并填入参数,未触发生成" }; return { ok: true, navigated: "/image", prompt, run, taskId, applied, note: siteText(run ? "imageGenerationStarted" : "imageConfigApplied") };
} }
function getVideoConfig() { function getVideoConfig() {
@@ -232,14 +237,14 @@ function runVideoWorkbench(input: SiteToolInput, navigate: NavigateFunction) {
const run = input.run !== false; const run = input.run !== false;
navigate("/video"); navigate("/video");
const taskId = useWorkbenchAgentStore.getState().dispatchVideo({ prompt, run }); const taskId = useWorkbenchAgentStore.getState().dispatchVideo({ prompt, run });
return { ok: true, navigated: "/video", prompt, run, taskId, applied, note: run ? "已跳转视频创作台并触发生成,可用 generation_get_status 查询任务" : "已跳转视频创作台并填入参数,未触发生成" }; return { ok: true, navigated: "/video", prompt, run, taskId, applied, note: siteText(run ? "videoGenerationStarted" : "videoConfigApplied") };
} }
async function searchPrompts(input: SiteToolInput) { async function searchPrompts(input: SiteToolInput) {
const page = Math.max(1, Math.floor(Number(input.page)) || 1); const page = Math.max(1, Math.floor(Number(input.page)) || 1);
const pageSize = Math.max(1, Math.min(50, Math.floor(Number(input.pageSize)) || 20)); const pageSize = Math.max(1, Math.min(50, Math.floor(Number(input.pageSize)) || 20));
const tags = Array.isArray(input.tags) ? input.tags.filter((tag): tag is string => typeof tag === "string") : []; const tags = Array.isArray(input.tags) ? input.tags.filter((tag): tag is string => typeof tag === "string") : [];
const result = await fetchPrompts({ keyword: String(input.keyword || ""), category: String(input.category || "全部"), tag: tags, page, pageSize }); const result = await fetchPrompts({ keyword: String(input.keyword || ""), category: String(input.category || i18n.t("common.all")), tag: tags, page, pageSize });
return { return {
total: result.total, total: result.total,
page, page,
@@ -252,7 +257,7 @@ async function searchPrompts(input: SiteToolInput) {
function listAssets(input: SiteToolInput) { function listAssets(input: SiteToolInput) {
const { assets, hydrated } = useAssetStore.getState(); const { assets, hydrated } = useAssetStore.getState();
if (!hydrated) throw new Error("资产还在加载中,请稍后重试"); if (!hydrated) throw new Error(siteText("assetsLoading"));
const kind = input.kind === "text" || input.kind === "image" || input.kind === "video" ? input.kind : "all"; const kind = input.kind === "text" || input.kind === "image" || input.kind === "video" ? input.kind : "all";
const keyword = String(input.keyword || "").trim().toLowerCase(); const keyword = String(input.keyword || "").trim().toLowerCase();
const filtered = assets.filter((asset) => { const filtered = assets.filter((asset) => {
@@ -279,30 +284,30 @@ function listAssets(input: SiteToolInput) {
async function addAsset(input: SiteToolInput) { async function addAsset(input: SiteToolInput) {
const kind = input.kind; const kind = input.kind;
const title = String(input.title || "").trim(); const title = String(input.title || "").trim();
if (!title) throw new Error("请提供资产标题 title"); if (!title) throw new Error(siteText("assetTitleRequired"));
const tags = Array.isArray(input.tags) ? input.tags.filter((tag): tag is string => typeof tag === "string") : []; const tags = Array.isArray(input.tags) ? input.tags.filter((tag): tag is string => typeof tag === "string") : [];
const source = typeof input.source === "string" ? input.source : "Agent"; const source = typeof input.source === "string" ? input.source : "Agent";
const note = typeof input.note === "string" ? input.note : undefined; const note = typeof input.note === "string" ? input.note : undefined;
const store = useAssetStore.getState(); const store = useAssetStore.getState();
if (kind === "text") { if (kind === "text") {
const content = String(input.content || "").trim(); const content = String(input.content || "").trim();
if (!content) throw new Error("kind=text 时需要提供 content 文本内容"); if (!content) throw new Error(siteText("textContentRequired"));
const id = store.addAsset({ kind: "text", title, coverUrl: "", tags, source, note, data: { content } }); const id = store.addAsset({ kind: "text", title, coverUrl: "", tags, source, note, data: { content } });
return { ok: true, id, kind: "text" }; return { ok: true, id, kind: "text" };
} }
if (kind === "image") { if (kind === "image") {
const imageUrl = String(input.imageUrl || "").trim(); const imageUrl = String(input.imageUrl || "").trim();
if (!imageUrl) throw new Error("kind=image 时需要提供 imageUrl(图片地址或 dataURL"); if (!imageUrl) throw new Error(siteText("imageUrlRequired"));
let stored; let stored;
try { try {
stored = await uploadImage(imageUrl); stored = await uploadImage(imageUrl);
} catch { } catch {
throw new Error("无法读取该图片地址,请改用 dataURL 或可跨域访问的图片链接"); throw new Error(siteText("imageReadFailed"));
} }
const id = store.addAsset({ kind: "image", title, coverUrl: stored.url, tags, source, note, data: { dataUrl: stored.url, storageKey: stored.storageKey, width: stored.width, height: stored.height, bytes: stored.bytes, mimeType: stored.mimeType } }); const id = store.addAsset({ kind: "image", title, coverUrl: stored.url, tags, source, note, data: { dataUrl: stored.url, storageKey: stored.storageKey, width: stored.width, height: stored.height, bytes: stored.bytes, mimeType: stored.mimeType } });
return { ok: true, id, kind: "image" }; return { ok: true, id, kind: "image" };
} }
throw new Error("assets_add 仅支持 kind=text 或 kind=image"); throw new Error(siteText("assetKindUnsupported"));
} }
function paginate(input: SiteToolInput, total: number, defaultSize: number) { function paginate(input: SiteToolInput, total: number, defaultSize: number) {
+2 -9
View File
@@ -1,5 +1,6 @@
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import i18n from "@/i18n";
import { getNodeSpec, isRegisteredNodeType } from "@/lib/canvas/node-registry"; import { getNodeSpec, isRegisteredNodeType } from "@/lib/canvas/node-registry";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ViewportTransform } from "@/types/canvas"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ViewportTransform } from "@/types/canvas";
@@ -84,13 +85,5 @@ export function applyCanvasAgentOps(snapshot: CanvasAgentSnapshot, ops?: CanvasA
} }
function opLabel(type: string) { function opLabel(type: string) {
if (type === "add_node") return "新增节点"; return i18n.t(`canvas.agentOps.${type}`, { defaultValue: type });
if (type === "update_node") return "更新节点";
if (type === "delete_node") return "删除节点";
if (type === "delete_connections") return "删除连线";
if (type === "connect_nodes") return "连接";
if (type === "set_viewport") return "调整视图";
if (type === "select_nodes") return "选择节点";
if (type === "run_generation") return "触发生成";
return type;
} }
+4 -3
View File
@@ -1,5 +1,6 @@
import { saveAs } from "file-saver"; import { saveAs } from "file-saver";
import i18n from "@/i18n";
import { createZip } from "@/lib/zip"; import { createZip } from "@/lib/zip";
import { getMediaBlob } from "@/services/file-storage"; import { getMediaBlob } from "@/services/file-storage";
import { getImageBlob } from "@/services/image-storage"; import { getImageBlob } from "@/services/image-storage";
@@ -7,7 +8,7 @@ import type { CanvasExportAsset, CanvasExportFile } from "@/types/canvas-export"
import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
export async function exportCanvasProjects(projects: CanvasProject[], fileName = "无限画布") { export async function exportCanvasProjects(projects: CanvasProject[], fileName = i18n.t("canvas.export.defaultProjectName")) {
const zipFiles: { name: string; data: BlobPart }[] = []; const zipFiles: { name: string; data: BlobPart }[] = [];
const exportedProjects = await Promise.all( const exportedProjects = await Promise.all(
projects.map(async (project) => { projects.map(async (project) => {
@@ -30,11 +31,11 @@ export async function exportCanvasProjects(projects: CanvasProject[], fileName =
saveAs(zip, `${safeFileName(fileName)}.zip`); saveAs(zip, `${safeFileName(fileName)}.zip`);
} }
export async function exportCanvasNodes(nodes: CanvasNodeData[], fileName = "画布元素") { export async function exportCanvasNodes(nodes: CanvasNodeData[], fileName = i18n.t("canvas.export.defaultNodesName")) {
const zipFiles: { name: string; data: BlobPart }[] = []; const zipFiles: { name: string; data: BlobPart }[] = [];
const used = new Set<string>(); const used = new Set<string>();
const uniqueName = (base: string, ext: string) => { const uniqueName = (base: string, ext: string) => {
const safe = safeFileName(base) || "元素"; const safe = safeFileName(base) || i18n.t("canvas.export.item");
let name = `${safe}.${ext}`; let name = `${safe}.${ext}`;
for (let i = 1; used.has(name); i += 1) name = `${safe}-${i}.${ext}`; for (let i = 1; used.has(name); i += 1) name = `${safe}-${i}.${ext}`;
used.add(name); used.add(name);
@@ -1,4 +1,5 @@
import { defaultConfig, resolveModelForCapability, type AiConfig } from "@/stores/use-config-store"; import { defaultConfig, resolveModelForCapability, type AiConfig } from "@/stores/use-config-store";
import i18n from "@/i18n";
import { resolveImageUrl, uploadImage } from "@/services/image-storage"; import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { resolveMediaUrl } from "@/services/file-storage"; import { resolveMediaUrl } from "@/services/file-storage";
import { imageMetadata, referenceUrl } from "@/lib/canvas/canvas-node-factory"; import { imageMetadata, referenceUrl } from "@/lib/canvas/canvas-node-factory";
@@ -110,11 +111,11 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
} }
export function resetInterruptedGeneration(nodes: CanvasNodeData[]) { export function resetInterruptedGeneration(nodes: CanvasNodeData[]) {
return nodes.map((node) => (node.metadata?.status === "loading" ? { ...node, metadata: { ...node.metadata, status: "error" as const, errorDetails: "页面刷新后生成已中断,请重新生成。" } } : node)); return nodes.map((node) => (node.metadata?.status === "loading" ? { ...node, metadata: { ...node.metadata, status: "error" as const, errorDetails: i18n.t("canvas.generation.interrupted") } } : node));
} }
export function isGenerationCanceled(error: unknown) { export function isGenerationCanceled(error: unknown) {
return error instanceof Error && (error.message === "请求已取消" || error.name === "AbortError"); return error instanceof Error && (error.message === i18n.t("common.requestCanceled") || error.name === "AbortError");
} }
export function findRetrySourceNode(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) { export function findRetrySourceNode(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
@@ -149,11 +150,11 @@ export function isAudioFile(file: File) {
} }
export function buildAngleLabel(params: CanvasImageAngleParams) { export function buildAngleLabel(params: CanvasImageAngleParams) {
const horizontal = params.horizontalAngle === 0 ? "正面视角" : params.horizontalAngle > 0 ? `向右旋转 ${params.horizontalAngle}` : `向左旋转 ${Math.abs(params.horizontalAngle)}`; const horizontal = params.horizontalAngle === 0 ? i18n.t("canvas.generation.front") : params.horizontalAngle > 0 ? i18n.t("canvas.generation.rotateRight", { angle: params.horizontalAngle }) : i18n.t("canvas.generation.rotateLeft", { angle: Math.abs(params.horizontalAngle) });
const pitch = params.pitchAngle === 0 ? "水平视角" : params.pitchAngle > 0 ? `俯视 ${params.pitchAngle}` : `仰视 ${Math.abs(params.pitchAngle)}`; const pitch = params.pitchAngle === 0 ? i18n.t("canvas.generation.level") : params.pitchAngle > 0 ? i18n.t("canvas.generation.topDown", { angle: params.pitchAngle }) : i18n.t("canvas.generation.lowAngle", { angle: Math.abs(params.pitchAngle) });
return `AI 多角度:${horizontal}${pitch},镜头距离 ${params.cameraDistance.toFixed(1)}${params.wideAngle ? "广角" : "标准"}镜头`; return i18n.t("canvas.generation.angleLabel", { horizontal, pitch, distance: params.cameraDistance.toFixed(1), lens: i18n.t(params.wideAngle ? "canvas.editors.wide" : "canvas.editors.standard") });
} }
export function buildAnglePrompt(params: CanvasImageAngleParams) { export function buildAnglePrompt(params: CanvasImageAngleParams) {
return `基于参考图重新生成同一主体的新视角,保持主体、颜色、材质和画面风格一致,不要只做透视变形。${buildAngleLabel(params)}`; return i18n.t("canvas.generation.anglePrompt", { angle: buildAngleLabel(params) });
} }
@@ -1,4 +1,5 @@
import { imageReferenceLabel } from "@/lib/image-reference-prompt"; import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import i18n from "@/i18n";
import { seedanceReferenceLabel } from "@/lib/seedance-video"; import { seedanceReferenceLabel } from "@/lib/seedance-video";
import { getNodeDefinition } from "@/lib/canvas/node-registry"; import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
@@ -76,7 +77,7 @@ function labelForKind(kind: CanvasResourceKind, index: number) {
if (kind === "image") return imageReferenceLabel(index); if (kind === "image") return imageReferenceLabel(index);
if (kind === "video") return seedanceReferenceLabel("video", index); if (kind === "video") return seedanceReferenceLabel("video", index);
if (kind === "audio") return seedanceReferenceLabel("audio", index); if (kind === "audio") return seedanceReferenceLabel("audio", index);
return `文本${index + 1}`; return i18n.t("canvas.composer.resources.text", { index: index + 1 });
} }
function isResourceNode(node: CanvasNodeData) { function isResourceNode(node: CanvasNodeData) {
+3 -1
View File
@@ -1,5 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import i18n from "@/i18n";
import type { CanvasNodeDefinition } from "@/types/canvas-plugin"; import type { CanvasNodeDefinition } from "@/types/canvas-plugin";
import { CanvasNodeType } from "@/types/canvas"; import { CanvasNodeType } from "@/types/canvas";
@@ -45,7 +47,7 @@ export function isRegisteredNodeType(type: string) {
return definitions.has(type); return definitions.has(type);
} }
const FALLBACK_SPEC = { width: 340, height: 240, title: "节点", metadata: {} as CanvasNodeDefinition["defaultMetadata"] }; const FALLBACK_SPEC = { width: 340, height: 240, title: i18n.t("canvas.node.node"), metadata: {} as CanvasNodeDefinition["defaultMetadata"] };
// 提供默认尺寸/标题/初始 metadata,createCanvasNode 与 agent-ops 复用 // 提供默认尺寸/标题/初始 metadata,createCanvasNode 与 agent-ops 复用
export function getNodeSpec(type: string) { export function getNodeSpec(type: string) {
+4 -3
View File
@@ -2,6 +2,7 @@ import { registerNodeDefinitions, unregisterPluginNodes } from "@/lib/canvas/nod
import { getPluginRuntime } from "@/lib/canvas/plugin-runtime"; import { getPluginRuntime } from "@/lib/canvas/plugin-runtime";
import { usePluginStore, type InstalledPlugin } from "@/stores/canvas/use-plugin-store"; import { usePluginStore, type InstalledPlugin } from "@/stores/canvas/use-plugin-store";
import type { CanvasPlugin } from "@/types/canvas-plugin"; import type { CanvasPlugin } from "@/types/canvas-plugin";
import i18n from "@/i18n";
const cleanups = new Map<string, () => void>(); const cleanups = new Map<string, () => void>();
@@ -23,8 +24,8 @@ async function evaluatePluginSource(source: string): Promise<CanvasPlugin> {
function assertPlugin(plugin: unknown): asserts plugin is CanvasPlugin { function assertPlugin(plugin: unknown): asserts plugin is CanvasPlugin {
const value = plugin as Partial<CanvasPlugin> | null; const value = plugin as Partial<CanvasPlugin> | null;
if (!value || typeof value !== "object") throw new Error("插件未导出有效对象"); if (!value || typeof value !== "object") throw new Error(i18n.t("canvas.pluginErrors.invalidExport"));
if (!value.id || !Array.isArray(value.nodes) || !value.nodes.length) throw new Error("插件缺少 id 或 nodes"); if (!value.id || !Array.isArray(value.nodes) || !value.nodes.length) throw new Error(i18n.t("canvas.pluginErrors.missingFields"));
} }
export function activatePlugin(plugin: CanvasPlugin) { export function activatePlugin(plugin: CanvasPlugin) {
@@ -46,7 +47,7 @@ export function deactivatePlugin(pluginId: string) {
async function fetchPluginSource(url: string) { async function fetchPluginSource(url: string) {
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error(`下载失败 (HTTP ${response.status})`); if (!response.ok) throw new Error(i18n.t("canvas.pluginErrors.downloadFailed", { status: response.status }));
return response.text(); return response.text();
} }
+2 -1
View File
@@ -16,7 +16,7 @@ type RawManifest = { plugins?: RawEntry[] };
// 拉取官方插件清单;entry(相对文件名)按清单地址解析成绝对 URL,再走既有 URL 安装流程 // 拉取官方插件清单;entry(相对文件名)按清单地址解析成绝对 URL,再走既有 URL 安装流程
export async function fetchOfficialPlugins(registryUrl: string = PLUGIN_REGISTRY_URL): Promise<OfficialPluginEntry[]> { export async function fetchOfficialPlugins(registryUrl: string = PLUGIN_REGISTRY_URL): Promise<OfficialPluginEntry[]> {
const response = await fetch(registryUrl, { headers: { accept: "application/json" } }); const response = await fetch(registryUrl, { headers: { accept: "application/json" } });
if (!response.ok) throw new Error(`获取官方插件列表失败 (HTTP ${response.status})`); if (!response.ok) throw new Error(i18n.t("canvas.pluginErrors.registryFailed", { status: response.status }));
const data = (await response.json()) as RawManifest; const data = (await response.json()) as RawManifest;
const list = Array.isArray(data?.plugins) ? data.plugins : []; const list = Array.isArray(data?.plugins) ? data.plugins : [];
return list return list
@@ -47,3 +47,4 @@ function compareSemver(a: string, b: string): number {
export function hasUpgrade(installedVersion: string, remoteVersion: string): boolean { export function hasUpgrade(installedVersion: string, remoteVersion: string): boolean {
return compareSemver(remoteVersion, installedVersion) > 0; return compareSemver(remoteVersion, installedVersion) > 0;
} }
import i18n from "@/i18n";
+4 -2
View File
@@ -1,12 +1,14 @@
import type { ReferenceImage } from "@/types/image"; import type { ReferenceImage } from "@/types/image";
import i18n from "@/i18n";
export function imageReferenceLabel(index: number) { export function imageReferenceLabel(index: number) {
return `图片${index + 1}`; return i18n.t("imageReferences.label", { index: index + 1 });
} }
export function buildImageReferencePromptText(prompt: string, references: ReferenceImage[]) { export function buildImageReferencePromptText(prompt: string, references: ReferenceImage[]) {
const text = prompt.trim(); const text = prompt.trim();
if (!references.length) return text; if (!references.length) return text;
const labels = references.map((_, index) => imageReferenceLabel(index)); const labels = references.map((_, index) => imageReferenceLabel(index));
return `参考图片编号:${labels.join("、")}。请按这些编号理解提示词中的图片引用。\n\n${text}`; return i18n.t("imageReferences.promptPrefix", { labels: labels.join(i18n.t("imageReferences.separator")), prompt: text });
} }
+3 -2
View File
@@ -1,3 +1,4 @@
import i18n from "@/i18n";
import type { ReferenceImage } from "@/types/image"; import type { ReferenceImage } from "@/types/image";
export function formatBytes(bytes: number) { export function formatBytes(bytes: number) {
@@ -18,7 +19,7 @@ export function formatDuration(ms: number) {
const value = Math.max(0, Math.floor(ms / 1000)); const value = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(value / 60); const minutes = Math.floor(value / 60);
const seconds = value % 60; const seconds = value % 60;
return minutes ? `${minutes}${String(seconds).padStart(2, "0")}` : `${seconds}`; return minutes ? i18n.t("common.durationMinutes", { minutes, seconds: String(seconds).padStart(2, "0") }) : i18n.t("common.durationSeconds", { seconds });
} }
export function getDataUrlByteSize(dataUrl: string) { export function getDataUrlByteSize(dataUrl: string) {
@@ -34,7 +35,7 @@ export function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => { return new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || "")); reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取图片失败")); reader.onerror = () => reject(new Error(i18n.t("common.imageReadFailed")));
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
} }
+21 -20
View File
@@ -1,3 +1,4 @@
import i18n from "@/i18n";
import { resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; import { resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import type { ReferenceImage } from "@/types/image"; import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media"; import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
@@ -19,13 +20,13 @@ export const seedanceResolutionOptions = [
] as const; ] as const;
export const seedanceRatioOptions = [ export const seedanceRatioOptions = [
{ value: "16:9", label: "横屏" }, { value: "16:9" },
{ value: "9:16", label: "竖屏" }, { value: "9:16" },
{ value: "1:1", label: "方形" }, { value: "1:1" },
{ value: "4:3", label: "标准横屏" }, { value: "4:3" },
{ value: "3:4", label: "标准竖屏" }, { value: "3:4" },
{ value: "21:9", label: "宽银幕" }, { value: "21:9" },
{ value: "adaptive", label: "自适应" }, { value: "adaptive" },
] as const; ] as const;
export const seedanceDurationOptions = [-1, 4, 5, 6, 8, 10, 12, 15] as const; export const seedanceDurationOptions = [-1, 4, 5, 6, 8, 10, 12, 15] as const;
@@ -103,7 +104,7 @@ export function normalizeSeedanceRatio(value: string) {
export function seedancePixelLabel(resolution: string, ratio: string) { export function seedancePixelLabel(resolution: string, ratio: string) {
const normalizedResolution = normalizeSeedanceResolution(resolution) as keyof typeof seedancePixels; const normalizedResolution = normalizeSeedanceResolution(resolution) as keyof typeof seedancePixels;
const normalizedRatio = normalizeSeedanceRatio(ratio) as keyof (typeof seedancePixels)[typeof normalizedResolution] | "adaptive"; const normalizedRatio = normalizeSeedanceRatio(ratio) as keyof (typeof seedancePixels)[typeof normalizedResolution] | "adaptive";
if (normalizedRatio === "adaptive") return "自动匹配"; if (normalizedRatio === "adaptive") return i18n.t("seedance.autoMatch");
return seedancePixels[normalizedResolution][normalizedRatio] || ""; return seedancePixels[normalizedResolution][normalizedRatio] || "";
} }
@@ -114,9 +115,7 @@ export function boolConfig(value: string | undefined, fallback: boolean) {
} }
export function seedanceReferenceLabel(kind: "image" | "video" | "audio", index: number) { export function seedanceReferenceLabel(kind: "image" | "video" | "audio", index: number) {
if (kind === "image") return `图片${index + 1}`; return i18n.t(`seedance.references.${kind}`, { index: index + 1 });
if (kind === "video") return `视频${index + 1}`;
return `音频${index + 1}`;
} }
export function buildSeedancePromptText(prompt: string, images: ReferenceImage[], videos: ReferenceVideo[], audios: ReferenceAudio[]) { export function buildSeedancePromptText(prompt: string, images: ReferenceImage[], videos: ReferenceVideo[], audios: ReferenceAudio[]) {
@@ -127,7 +126,7 @@ export function buildSeedancePromptText(prompt: string, images: ReferenceImage[]
]; ];
const text = prompt.trim(); const text = prompt.trim();
if (!labels.length) return text; if (!labels.length) return text;
return `参考资产编号:${labels.join("、")}。请按这些编号理解提示词中的图片、视频和音频引用。\n\n${text}`; return i18n.t("seedance.promptPrefix", { labels: labels.join(i18n.t("seedance.separator")), prompt: text });
} }
export function seedanceVideoReferenceError(videos: ReferenceVideo[]) { export function seedanceVideoReferenceError(videos: ReferenceVideo[]) {
@@ -135,22 +134,24 @@ export function seedanceVideoReferenceError(videos: ReferenceVideo[]) {
for (let index = 0; index < videos.length; index += 1) { for (let index = 0; index < videos.length; index += 1) {
const video = videos[index]; const video = videos[index];
const label = seedanceReferenceLabel("video", index); const label = seedanceReferenceLabel("video", index);
if (!SEEDANCE_VIDEO_MIME_TYPES.includes(video.type)) return `${label} 仅支持 mp4/mov 格式`; if (!SEEDANCE_VIDEO_MIME_TYPES.includes(video.type)) return i18n.t("seedance.errors.format", { label });
if (video.bytes && video.bytes > SEEDANCE_REFERENCE_LIMITS.videoMaxBytes) return `${label} 超过 200MB,请压缩后再上传`; if (video.bytes && video.bytes > SEEDANCE_REFERENCE_LIMITS.videoMaxBytes) return i18n.t("seedance.errors.size", { label });
if (video.durationMs) { if (video.durationMs) {
if (video.durationMs < 2000 || video.durationMs > 15000) return `${label} 时长需要在 2-15 秒之间`; if (video.durationMs < 2000 || video.durationMs > 15000) return i18n.t("seedance.errors.duration", { label });
totalDurationMs += video.durationMs; totalDurationMs += video.durationMs;
} }
if (video.width && video.height) { if (video.width && video.height) {
if (video.width < 300 || video.width > 6000 || video.height < 300 || video.height > 6000) return `${label} 宽高需要在 300-6000px 之间`; if (video.width < 300 || video.width > 6000 || video.height < 300 || video.height > 6000) return i18n.t("seedance.errors.dimensions", { label });
const ratio = video.width / video.height; const ratio = video.width / video.height;
if (ratio < 0.4 || ratio > 2.5) return `${label} 宽高比需要在 0.4-2.5 之间`; if (ratio < 0.4 || ratio > 2.5) return i18n.t("seedance.errors.ratio", { label });
const pixels = video.width * video.height; const pixels = video.width * video.height;
if (pixels < 640 * 640 || pixels > 3326 * 2494) return `${label} 总像素需要在 409600-8295044 之间`; if (pixels < 640 * 640 || pixels > 3326 * 2494) return i18n.t("seedance.errors.pixels", { label });
} }
} }
if (totalDurationMs > 15000) return "Seedance 参考视频总时长不能超过 15 秒"; if (totalDurationMs > 15000) return i18n.t("seedance.errors.totalDuration");
return ""; return "";
} }
export const seedanceVideoReferenceHint = "参考视频需为 mp4/movH.264/H.265FPS 24-60;含真人人脸资产请使用火山授权 asset:// 资产。"; export function seedanceVideoReferenceHint() {
return i18n.t("seedance.referenceHint");
}
+1
View File
@@ -6,6 +6,7 @@ import "./styles/globals.css";
import { RouterProvider } from "react-router-dom"; import { RouterProvider } from "react-router-dom";
import { AppProviders } from "@/components/layout/app-providers"; import { AppProviders } from "@/components/layout/app-providers";
import "@/i18n";
import { initAnalytics } from "@/lib/analytics"; import { initAnalytics } from "@/lib/analytics";
import { router } from "@/router"; import { router } from "@/router";
+2 -2
View File
@@ -20,7 +20,7 @@ type AssetExportItem = {
bytes: number; bytes: number;
}; };
export async function exportAssets(assets: Asset[]) { export async function exportAssets(assets: Asset[], filename: string) {
const files: AssetExportItem[] = []; const files: AssetExportItem[] = [];
const zipFiles: { name: string; data: BlobPart }[] = []; const zipFiles: { name: string; data: BlobPart }[] = [];
@@ -39,7 +39,7 @@ export async function exportAssets(assets: Asset[]) {
const data: AssetExportFile = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), assets, files }; const data: AssetExportFile = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), assets, files };
const zip = await createZip([{ name: "assets.json", data: JSON.stringify(data, null, 2) }, ...zipFiles]); const zip = await createZip([{ name: "assets.json", data: JSON.stringify(data, null, 2) }, ...zipFiles]);
saveAs(zip, "我的资产.zip"); saveAs(zip, filename);
} }
export async function readAssetPackage(file: File) { export async function readAssetPackage(file: File) {
+69 -70
View File
@@ -2,6 +2,7 @@ import { Copy, Download, PencilLine, Search, Trash2, Upload } from "lucide-react
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { App, Button, Card, Drawer, Empty, Form, Image, Input, Modal, Pagination, Select, Space, Tag, Typography } from "antd"; import { App, Button, Card, Drawer, Empty, Form, Image, Input, Modal, Pagination, Select, Space, Tag, Typography } from "antd";
import { saveAs } from "file-saver"; import { saveAs } from "file-saver";
import { useTranslation } from "react-i18next";
import { useCopyText } from "@/hooks/use-copy-text"; import { useCopyText } from "@/hooks/use-copy-text";
import { formatBytes, readFileAsDataUrl } from "@/lib/image-utils"; import { formatBytes, readFileAsDataUrl } from "@/lib/image-utils";
@@ -22,15 +23,11 @@ type AssetFormValues = {
type ImageDraft = ImageAsset["data"] | null; type ImageDraft = ImageAsset["data"] | null;
const kindOptions = [ const kindOptions = ["all", "text", "image", "video"] as const;
{ label: "全部", value: "all" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
{ label: "视频", value: "video" },
];
export default function AssetsPage() { export default function AssetsPage() {
const { message } = App.useApp(); const { message } = App.useApp();
const { t } = useTranslation();
const copyText = useCopyText(); const copyText = useCopyText();
const [form] = Form.useForm<AssetFormValues>(); const [form] = Form.useForm<AssetFormValues>();
const coverInputRef = useRef<HTMLInputElement>(null); const coverInputRef = useRef<HTMLInputElement>(null);
@@ -79,7 +76,7 @@ export default function AssetsPage() {
setEditingAsset(null); setEditingAsset(null);
setImageDraft(null); setImageDraft(null);
setFormKind("text"); setFormKind("text");
form.setFieldsValue({ kind: "text", title: "", coverUrl: "", tags: [], source: "手动添加", note: "", content: "" }); form.setFieldsValue({ kind: "text", title: "", coverUrl: "", tags: [], source: t("assets.manual"), note: "", content: "" });
setIsAssetOpen(true); setIsAssetOpen(true);
}; };
@@ -115,14 +112,14 @@ export default function AssetsPage() {
editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset); editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset);
} else { } else {
if (!imageDraft) { if (!imageDraft) {
message.error("请选择图片文件"); message.error(t("assets.selectImage"));
return; return;
} }
const asset = { ...base, kind: "image" as const, data: imageDraft }; const asset = { ...base, kind: "image" as const, data: imageDraft };
editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset); editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset);
} }
message.success(editingAsset ? "资产已更新" : "资产已保存"); message.success(editingAsset ? t("assets.updated") : t("assets.saved"));
setIsAssetOpen(false); setIsAssetOpen(false);
}; };
@@ -143,7 +140,7 @@ export default function AssetsPage() {
const copyAssetText = async (asset: Asset) => { const copyAssetText = async (asset: Asset) => {
if (asset.kind !== "text") return; if (asset.kind !== "text") return;
copyText(asset.data.content, "文本已复制"); copyText(asset.data.content, t("assets.textCopied"));
}; };
const downloadImage = (asset: Asset) => { const downloadImage = (asset: Asset) => {
@@ -153,10 +150,10 @@ export default function AssetsPage() {
const exportAllAssets = async () => { const exportAllAssets = async () => {
if (!validAssets.length) { if (!validAssets.length) {
message.warning("暂无资产可导出"); message.warning(t("assets.noneToExport"));
return; return;
} }
await exportAssets(validAssets); await exportAssets(validAssets, t("assets.packageName"));
}; };
const importAssetZip = async (file?: File) => { const importAssetZip = async (file?: File) => {
@@ -170,9 +167,9 @@ export default function AssetsPage() {
delete payload.updatedAt; delete payload.updatedAt;
addAsset(payload as Parameters<typeof addAsset>[0]); addAsset(payload as Parameters<typeof addAsset>[0]);
}); });
message.success(`已导入 ${importedAssets.length} 个资产`); message.success(t("assets.imported", { count: importedAssets.length }));
} catch { } catch {
message.error("导入失败,请选择有效的资产压缩包"); message.error(t("assets.importFailed"));
} finally { } finally {
if (assetInputRef.current) assetInputRef.current.value = ""; if (assetInputRef.current) assetInputRef.current.value = "";
} }
@@ -181,7 +178,7 @@ export default function AssetsPage() {
const confirmDelete = () => { const confirmDelete = () => {
if (!deletingAsset) return; if (!deletingAsset) return;
removeAsset(deletingAsset.id); removeAsset(deletingAsset.id);
message.success("资产已删除"); message.success(t("assets.deleted"));
setDeletingAsset(null); setDeletingAsset(null);
}; };
@@ -190,8 +187,8 @@ export default function AssetsPage() {
<main className="min-h-0 flex-1 overflow-y-auto bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.14)_1px,transparent_1px)]"> <main className="min-h-0 flex-1 overflow-y-auto bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.14)_1px,transparent_1px)]">
<div className="pb-8"> <div className="pb-8">
<div className="mx-auto max-w-5xl text-center"> <div className="mx-auto max-w-5xl text-center">
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100"></h1> <h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100">{t("assets.title")}</h1>
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400"></p> <p className="mt-3 text-sm text-stone-500 dark:text-stone-400">{t("assets.description")}</p>
</div> </div>
<div className="mx-auto mt-8 w-full max-w-2xl"> <div className="mx-auto mt-8 w-full max-w-2xl">
@@ -201,7 +198,7 @@ export default function AssetsPage() {
allowClear allowClear
prefix={<Search className="size-4 text-stone-400" />} prefix={<Search className="size-4 text-stone-400" />}
value={keyword} value={keyword}
placeholder="搜索标题、内容、标签或来源" placeholder={t("assets.search")}
onChange={(event) => { onChange={(event) => {
setPage(1); setPage(1);
setKeyword(event.target.value); setKeyword(event.target.value);
@@ -216,19 +213,19 @@ export default function AssetsPage() {
<div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left"> <div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-center"> <div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-center">
<div className="text-xs font-medium text-stone-500 dark:text-stone-400"></div> <div className="text-xs font-medium text-stone-500 dark:text-stone-400">{t("assets.type")}</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{kindOptions.map((option) => ( {kindOptions.map((option) => (
<Tag.CheckableTag <Tag.CheckableTag
key={option.value} key={option}
checked={kindFilter === option.value} checked={kindFilter === option}
className={cn("prompt-filter-tag", kindFilter === option.value && "is-active")} className={cn("prompt-filter-tag", kindFilter === option && "is-active")}
onChange={() => { onChange={() => {
setPage(1); setPage(1);
setKindFilter(option.value as AssetKind | "all"); setKindFilter(option);
}} }}
> >
{option.label} {option === "all" ? t("common.all") : t(`assets.kinds.${option}`)}
</Tag.CheckableTag> </Tag.CheckableTag>
))} ))}
</div> </div>
@@ -239,21 +236,21 @@ export default function AssetsPage() {
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300" className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
onClick={() => void exportAllAssets()} onClick={() => void exportAllAssets()}
> >
{t("assets.export")}
</button> </button>
<button <button
type="button" type="button"
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300" className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
onClick={() => assetInputRef.current?.click()} onClick={() => assetInputRef.current?.click()}
> >
{t("assets.import")}
</button> </button>
<button <button
type="button" type="button"
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300" className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
onClick={openCreate} onClick={openCreate}
> >
{t("assets.add")}
</button> </button>
</div> </div>
</div> </div>
@@ -267,7 +264,7 @@ export default function AssetsPage() {
))} ))}
</div> </div>
{!visibleAssets.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到资产" className="py-20" /> : null} {!visibleAssets.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("assets.empty")} className="py-20" /> : null}
<div className="flex justify-center"> <div className="flex justify-center">
<Pagination <Pagination
@@ -285,49 +282,49 @@ export default function AssetsPage() {
</div> </div>
</main> </main>
<Modal title={editingAsset ? "编辑资产" : "新增资产"} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden> <Modal title={editingAsset ? t("assets.edit") : t("assets.add")} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText={t("common.save")} cancelText={t("common.cancel")} destroyOnHidden>
<div className="grid gap-6 pt-1 lg:grid-cols-[minmax(0,1fr)_320px]"> <div className="grid gap-6 pt-1 lg:grid-cols-[minmax(0,1fr)_320px]">
<Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", tags: [] }}> <Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", tags: [] }}>
<Form.Item name="kind" label="类型"> <Form.Item name="kind" label={t("assets.type")}>
<Select <Select
options={[ options={[
{ label: "文本", value: "text" }, { label: t("assets.kinds.text"), value: "text" },
{ label: "图片", value: "image" }, { label: t("assets.kinds.image"), value: "image" },
]} ]}
onChange={(value) => setFormKind(value)} onChange={(value) => setFormKind(value)}
/> />
</Form.Item> </Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}> <Form.Item name="title" label={t("assets.fields.title")} rules={[{ required: true, message: t("assets.fields.titleRequired") }]}>
<Input size="large" placeholder="给资产起一个容易检索的名字" /> <Input size="large" placeholder={t("assets.fields.titlePlaceholder")} />
</Form.Item> </Form.Item>
<Form.Item name="coverUrl" label="封面 URL"> <Form.Item name="coverUrl" label={t("assets.fields.coverUrl")}>
<Space.Compact className="w-full"> <Space.Compact className="w-full">
<Input placeholder="可粘贴图片 URL,也可以上传本地封面" /> <Input placeholder={t("assets.fields.coverPlaceholder")} />
<Button icon={<Upload className="size-3.5" />} onClick={() => coverInputRef.current?.click()}> <Button icon={<Upload className="size-3.5" />} onClick={() => coverInputRef.current?.click()}>
{t("common.upload")}
</Button> </Button>
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
<Form.Item name="tags" label="标签"> <Form.Item name="tags" label={t("assets.fields.tags")}>
<Select mode="tags" tokenSeparators={[",", ""]} placeholder="输入标签后回车" /> <Select mode="tags" tokenSeparators={[",", ""]} placeholder={t("assets.fields.tagsPlaceholder")} />
</Form.Item> </Form.Item>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Form.Item name="source" label="来源"> <Form.Item name="source" label={t("assets.fields.source")}>
<Input placeholder="手动添加 / 画布 / 提示词库" /> <Input placeholder={t("assets.fields.sourcePlaceholder")} />
</Form.Item> </Form.Item>
<Form.Item name="note" label="备注"> <Form.Item name="note" label={t("assets.fields.note")}>
<Input placeholder="可选" /> <Input placeholder={t("assets.fields.optional")} />
</Form.Item> </Form.Item>
</div> </div>
{formKind === "text" ? ( {formKind === "text" ? (
<Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}> <Form.Item name="content" label={t("assets.fields.textContent")} rules={[{ required: true, message: t("assets.fields.textRequired") }]}>
<Input.TextArea rows={8} placeholder="保存提示词、说明文案、参考描述等文本资产" /> <Input.TextArea rows={8} placeholder={t("assets.fields.textPlaceholder")} />
</Form.Item> </Form.Item>
) : ( ) : (
<Form.Item label="图片内容" required> <Form.Item label={t("assets.fields.imageContent")} required>
<div className="rounded-lg border border-dashed border-stone-300 p-4 dark:border-stone-700"> <div className="rounded-lg border border-dashed border-stone-300 p-4 dark:border-stone-700">
<Button icon={<Upload className="size-4" />} onClick={() => imageInputRef.current?.click()}> <Button icon={<Upload className="size-4" />} onClick={() => imageInputRef.current?.click()}>
{t("assets.selectImageFile")}
</Button> </Button>
{imageDraft ? ( {imageDraft ? (
<Typography.Text type="secondary" className="ml-3 text-xs"> <Typography.Text type="secondary" className="ml-3 text-xs">
@@ -335,7 +332,7 @@ export default function AssetsPage() {
</Typography.Text> </Typography.Text>
) : ( ) : (
<Typography.Text type="secondary" className="ml-3 text-xs"> <Typography.Text type="secondary" className="ml-3 text-xs">
{t("assets.noImageSelected")}
</Typography.Text> </Typography.Text>
)} )}
</div> </div>
@@ -343,16 +340,16 @@ export default function AssetsPage() {
)} )}
</Form> </Form>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4 dark:border-stone-800 dark:bg-stone-950"> <div className="rounded-xl border border-stone-200 bg-stone-50 p-4 dark:border-stone-800 dark:bg-stone-950">
<Typography.Text strong></Typography.Text> <Typography.Text strong>{t("assets.preview")}</Typography.Text>
<div className="mt-3 overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800"> <div className="mt-3 overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
{coverUrl || imageDraft?.dataUrl ? ( {coverUrl || imageDraft?.dataUrl ? (
<img src={coverUrl || imageDraft?.dataUrl} alt="" className="aspect-[4/3] w-full object-cover" /> <img src={coverUrl || imageDraft?.dataUrl} alt="" className="aspect-[4/3] w-full object-cover" />
) : ( ) : (
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm text-stone-500 dark:bg-stone-900">{content || "暂无封面"}</div> <div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm text-stone-500 dark:bg-stone-900">{content || t("assets.noCover")}</div>
)} )}
<div className="p-4"> <div className="p-4">
<Typography.Text strong ellipsis className="block"> <Typography.Text strong ellipsis className="block">
{title || "未命名资产"} {title || t("assets.untitled")}
</Typography.Text> </Typography.Text>
<div className="mt-2 flex flex-wrap gap-1.5"> <div className="mt-2 flex flex-wrap gap-1.5">
{tags.length ? ( {tags.length ? (
@@ -362,7 +359,7 @@ export default function AssetsPage() {
</Tag> </Tag>
)) ))
) : ( ) : (
<Tag className="m-0"></Tag> <Tag className="m-0">{t("assets.untagged")}</Tag>
)} )}
</div> </div>
</div> </div>
@@ -395,14 +392,15 @@ export default function AssetsPage() {
<input ref={assetInputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importAssetZip(event.target.files?.[0])} /> <input ref={assetInputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importAssetZip(event.target.files?.[0])} />
<Modal title="删除资产" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> <Modal title={t("assets.deleteTitle")} open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText={t("common.delete")} okButtonProps={{ danger: true }} cancelText={t("common.cancel")}>
{deletingAsset?.title} {t("assets.deleteConfirm", { name: deletingAsset?.title })}
</Modal> </Modal>
</div> </div>
); );
} }
function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { asset: Asset; onOpen: () => void; onEdit: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void; onDelete: () => void }) { function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { asset: Asset; onOpen: () => void; onEdit: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void; onDelete: () => void }) {
const { t } = useTranslation();
const cover = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : ""); const cover = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "");
const summary = assetSummary(asset); const summary = assetSummary(asset);
return ( return (
@@ -415,7 +413,7 @@ function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { as
{cover ? ( {cover ? (
<img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" /> <img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" />
) : ( ) : (
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div> <div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : t("assets.noCover")}</div>
)} )}
</button> </button>
} }
@@ -426,10 +424,10 @@ function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { as
<div className="min-w-0"> <div className="min-w-0">
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2> <h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2>
<Typography.Text type="secondary" className="mt-1 block text-xs"> <Typography.Text type="secondary" className="mt-1 block text-xs">
{asset.source || "未标注来源"} {asset.source || t("assets.unknownSource")}
</Typography.Text> </Typography.Text>
</div> </div>
<Tag className="m-0 shrink-0 text-[11px]">{asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : "文本"}</Tag> <Tag className="m-0 shrink-0 text-[11px]">{t(`assets.kinds.${asset.kind}`)}</Tag>
</div> </div>
<Typography.Paragraph type="secondary" ellipsis={{ rows: 3 }} className="!mb-0 !mt-2 !text-xs !leading-5"> <Typography.Paragraph type="secondary" ellipsis={{ rows: 3 }} className="!mb-0 !mt-2 !text-xs !leading-5">
{summary} {summary}
@@ -440,31 +438,31 @@ function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { as
{tag} {tag}
</Tag> </Tag>
))} ))}
{!asset.tags?.length ? <Tag className="m-0 text-[11px]"></Tag> : null} {!asset.tags?.length ? <Tag className="m-0 text-[11px]">{t("assets.noTags")}</Tag> : null}
</div> </div>
</div> </div>
</button> </button>
<div className="flex items-center gap-2 px-4 pb-4"> <div className="flex items-center gap-2 px-4 pb-4">
<Button size="small" onClick={onOpen}> <Button size="small" onClick={onOpen}>
{t("common.view")}
</Button> </Button>
{asset.kind !== "video" ? ( {asset.kind !== "video" ? (
<Button size="small" icon={<PencilLine className="size-3.5" />} onClick={onEdit}> <Button size="small" icon={<PencilLine className="size-3.5" />} onClick={onEdit}>
{t("common.edit")}
</Button> </Button>
) : null} ) : null}
{asset.kind === "text" ? ( {asset.kind === "text" ? (
<Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void onCopy(asset)}> <Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void onCopy(asset)}>
{t("common.copy")}
</Button> </Button>
) : null} ) : null}
{asset.kind === "image" || asset.kind === "video" ? ( {asset.kind === "image" || asset.kind === "video" ? (
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(asset)}> <Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(asset)}>
{t("common.download")}
</Button> </Button>
) : null} ) : null}
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={onDelete}> <Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={onDelete}>
{t("common.delete")}
</Button> </Button>
</div> </div>
</Card> </Card>
@@ -472,22 +470,23 @@ function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { as
} }
function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | null; onClose: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void }) { function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | null; onClose: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void }) {
const { t } = useTranslation();
const cover = asset ? asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "") : ""; const cover = asset ? asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "") : "";
return ( return (
<Drawer title="资产详情" open={Boolean(asset)} size="large" onClose={onClose}> <Drawer title={t("assets.details")} open={Boolean(asset)} size="large" onClose={onClose}>
{asset ? ( {asset ? (
<div className="space-y-5"> <div className="space-y-5">
{cover ? ( {cover ? (
<Image src={cover} alt={asset.title} className="rounded-lg" /> <Image src={cover} alt={asset.title} className="rounded-lg" />
) : ( ) : (
<div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div> <div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : t("assets.noCover")}</div>
)} )}
<div> <div>
<Typography.Title level={4} className="!mb-2"> <Typography.Title level={4} className="!mb-2">
{asset.title} {asset.title}
</Typography.Title> </Typography.Title>
<Space size={[4, 4]} wrap> <Space size={[4, 4]} wrap>
<Tag>{asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : "文本"}</Tag> <Tag>{t(`assets.kinds.${asset.kind}`)}</Tag>
{(asset.tags || []).map((tag) => ( {(asset.tags || []).map((tag) => (
<Tag key={tag}>{tag}</Tag> <Tag key={tag}>{tag}</Tag>
))} ))}
@@ -495,7 +494,7 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | nu
</div> </div>
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800"> <div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
<Typography.Text type="secondary" className="block text-xs"> <Typography.Text type="secondary" className="block text-xs">
{t("assets.fields.textContent")}
</Typography.Text> </Typography.Text>
{asset.kind === "text" ? ( {asset.kind === "text" ? (
<Typography.Paragraph className="mt-2 whitespace-pre-wrap">{asset.data.content}</Typography.Paragraph> <Typography.Paragraph className="mt-2 whitespace-pre-wrap">{asset.data.content}</Typography.Paragraph>
@@ -509,19 +508,19 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | nu
</div> </div>
{asset.note ? ( {asset.note ? (
<div> <div>
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary">{t("assets.fields.note")}</Typography.Text>
<Typography.Paragraph className="mt-1">{asset.note}</Typography.Paragraph> <Typography.Paragraph className="mt-1">{asset.note}</Typography.Paragraph>
</div> </div>
) : null} ) : null}
<Space> <Space>
{asset.kind === "text" ? ( {asset.kind === "text" ? (
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(asset)}> <Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(asset)}>
{t("assets.copyText")}
</Button> </Button>
) : null} ) : null}
{asset.kind === "image" || asset.kind === "video" ? ( {asset.kind === "image" || asset.kind === "video" ? (
<Button type="primary" icon={<Download className="size-4" />} onClick={() => onDownload(asset)}> <Button type="primary" icon={<Download className="size-4" />} onClick={() => onDownload(asset)}>
{asset.kind === "video" ? "下载视频" : "下载图片"} {asset.kind === "video" ? t("assets.downloadVideo") : t("assets.downloadImage")}
</Button> </Button>
) : null} ) : null}
</Space> </Space>
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; import { useCallback, useEffect, useMemo, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import i18n from "@/i18n";
import { useAgentStore } from "@/stores/use-agent-store"; import { useAgentStore } from "@/stores/use-agent-store";
import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops"; import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel"; import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
@@ -36,7 +37,7 @@ export function useAgentBridge(params: AgentBridgeParams) {
params; params;
const setAgentCanvasContext = useAgentStore((state) => state.setCanvasContext); const setAgentCanvasContext = useAgentStore((state) => state.setCanvasContext);
const [agentUndoSnapshot, setAgentUndoSnapshot] = useState<CanvasAgentSnapshot | null>(null); const [agentUndoSnapshot, setAgentUndoSnapshot] = useState<CanvasAgentSnapshot | null>(null);
const projectTitle = title || "未命名画布"; const projectTitle = title || i18n.t("canvas.project.untitled");
const agentSnapshot = useMemo<CanvasAgentSnapshot>(() => ({ projectId, title: projectTitle, nodes, connections, selectedNodeIds: Array.from(selectedNodeIds), viewport }), [connections, projectTitle, nodes, projectId, selectedNodeIds, viewport]); const agentSnapshot = useMemo<CanvasAgentSnapshot>(() => ({ projectId, title: projectTitle, nodes, connections, selectedNodeIds: Array.from(selectedNodeIds), viewport }), [connections, projectTitle, nodes, projectId, selectedNodeIds, viewport]);
const applyAgentOps = useCallback( const applyAgentOps = useCallback(
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; import { useCallback, useEffect, useMemo, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { requestEdit, requestGeneration, requestImageQuestion, type AiTextMessage } from "@/services/api/image"; import { requestEdit, requestGeneration, requestImageQuestion, type AiTextMessage } from "@/services/api/image";
import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video"; import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
@@ -33,6 +34,7 @@ type PluginHostParams = {
* *
*/ */
export function usePluginHost(params: PluginHostParams) { export function usePluginHost(params: PluginHostParams) {
const { t } = useTranslation();
const { effectiveConfig, isAiConfigReady, openConfigDialog, theme, nodesRef, connectionsRef, viewportRef, setNodes, setDialogNodeId, applyAgentOps } = params; const { effectiveConfig, isAiConfigReady, openConfigDialog, theme, nodesRef, connectionsRef, viewportRef, setNodes, setDialogNodeId, applyAgentOps } = params;
// 提供给插件节点的宿主能力(节点无关,方法接收 nodeId) // 提供给插件节点的宿主能力(节点无关,方法接收 nodeId)
@@ -43,7 +45,7 @@ export function usePluginHost(params: PluginHostParams) {
const ensureReady = (config: AiConfig) => { const ensureReady = (config: AiConfig) => {
if (!isAiConfigReady(config, config.model)) { if (!isAiConfigReady(config, config.model)) {
openConfigDialog(true); openConfigDialog(true);
throw new Error("AI 配置未就绪,请先在设置里配置模型与密钥"); throw new Error(t("canvas.plugins.aiConfigRequired"));
} }
}; };
return { return {
@@ -76,7 +78,7 @@ export function usePluginHost(params: PluginHostParams) {
listModels: (capability) => selectableModelsByCapability(effectiveConfig, capability as ModelCapability | undefined).map((value) => ({ value, label: decodeChannelModel(value)?.model || value })), listModels: (capability) => selectableModelsByCapability(effectiveConfig, capability as ModelCapability | undefined).map((value) => ({ value, label: decodeChannelModel(value)?.model || value })),
defaultModel: (capability) => buildGenerationConfig(effectiveConfig, undefined, capability).model, defaultModel: (capability) => buildGenerationConfig(effectiveConfig, undefined, capability).model,
}; };
}, [effectiveConfig, isAiConfigReady, openConfigDialog]); }, [effectiveConfig, isAiConfigReady, openConfigDialog, t]);
const pluginHost = useMemo<CanvasPluginHost>( const pluginHost = useMemo<CanvasPluginHost>(
() => ({ () => ({
@@ -124,15 +126,15 @@ export function usePluginHost(params: PluginHostParams) {
const interactive = Boolean(node.metadata?.interactive); const interactive = Boolean(node.metadata?.interactive);
const toggle: CanvasNodeToolbarItem = { const toggle: CanvasNodeToolbarItem = {
id: "node-interaction-toggle", id: "node-interaction-toggle",
title: interactive ? "当前:交互中。点击切回「移动」——拖动可移动节点" : "当前:可移动。点击切到「交互」——可操作节点内容(如转动全景)", title: t(interactive ? "canvas.plugins.interactiveTitle" : "canvas.plugins.movableTitle"),
label: interactive ? "移动" : "交互", label: t(interactive ? "canvas.plugins.move" : "canvas.plugins.interact"),
icon: interactive ? "✋" : "🖐", icon: interactive ? "✋" : "🖐",
active: interactive, active: interactive,
onClick: () => pluginHost.updateMetadata(node.id, { interactive: !interactive }), onClick: () => pluginHost.updateMetadata(node.id, { interactive: !interactive }),
}; };
return [toggle, ...custom]; return [toggle, ...custom];
}, },
[pluginHost, theme], [pluginHost, t, theme],
); );
// 启动时加载已安装的远程插件 // 启动时加载已安装的远程插件
+20 -18
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { App, Button } from "antd"; import { App, Button } from "antd";
import { Download, FileUp, Plus } from "lucide-react"; import { Download, FileUp, Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { readZip } from "@/lib/zip"; import { readZip } from "@/lib/zip";
import { setMediaBlob } from "@/services/file-storage"; import { setMediaBlob } from "@/services/file-storage";
@@ -15,6 +16,7 @@ import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
export default function CanvasPage() { export default function CanvasPage() {
const { message } = App.useApp(); const { message } = App.useApp();
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -32,7 +34,7 @@ export default function CanvasPage() {
const enterProject = (id: string) => { const enterProject = (id: string) => {
navigate(`/canvas/${id}${agentQuery}`); navigate(`/canvas/${id}${agentQuery}`);
}; };
const createAndEnter = () => enterProject(createProject(`无限画布 ${projects.length + 1}`)); const createAndEnter = () => enterProject(createProject(t("canvas.defaultTitle", { count: projects.length + 1 })));
const importCanvas = async (file?: File) => { const importCanvas = async (file?: File) => {
if (!file) return; if (!file) return;
try { try {
@@ -51,9 +53,9 @@ export default function CanvasPage() {
), ),
); );
data.projects.forEach((item) => importProject(item.project)); data.projects.forEach((item) => importProject(item.project));
message.success(`已导入 ${data.projects.length} 个画布`); message.success(t("canvas.imported", { count: data.projects.length }));
} catch { } catch {
message.error("导入失败,请选择有效的画布压缩包"); message.error(t("canvas.importFailed"));
} finally { } finally {
if (inputRef.current) inputRef.current.value = ""; if (inputRef.current) inputRef.current.value = "";
} }
@@ -62,46 +64,46 @@ export default function CanvasPage() {
useEffect(() => { useEffect(() => {
if (!hydrated || autoOpenRef.current || (mode !== "new" && mode !== "recent")) return; if (!hydrated || autoOpenRef.current || (mode !== "new" && mode !== "recent")) return;
autoOpenRef.current = true; autoOpenRef.current = true;
enterProject(mode === "new" ? createProject(`无限画布 ${projects.length + 1}`) : projects[0]?.id || createProject(`无限画布 ${projects.length + 1}`)); enterProject(mode === "new" ? createProject(t("canvas.defaultTitle", { count: projects.length + 1 })) : projects[0]?.id || createProject(t("canvas.defaultTitle", { count: projects.length + 1 })));
}, [createProject, hydrated, mode, projects]); }, [createProject, hydrated, mode, projects, t]);
if (hydrated && (mode === "new" || mode === "recent")) return <main className="flex h-full items-center justify-center bg-background text-sm text-stone-500">...</main>; if (hydrated && (mode === "new" || mode === "recent")) return <main className="flex h-full items-center justify-center bg-background text-sm text-stone-500">{t("canvas.opening")}</main>;
return ( return (
<main className="h-full overflow-auto bg-background text-stone-950 dark:text-stone-100"> <main className="h-full overflow-auto bg-background text-stone-950 dark:text-stone-100">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-10"> <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-10">
<header className="flex flex-wrap items-end justify-between gap-4 border-b border-stone-200 pb-6 dark:border-stone-800"> <header className="flex flex-wrap items-end justify-between gap-4 border-b border-stone-200 pb-6 dark:border-stone-800">
<div> <div>
<p className="text-xs text-stone-500"></p> <p className="text-xs text-stone-500">{t("canvas.library")}</p>
<h1 className="mt-3 text-3xl font-semibold"></h1> <h1 className="mt-3 text-3xl font-semibold">{t("canvas.title")}</h1>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{selectedIds.length ? ( {selectedIds.length ? (
<> <>
<Button disabled={!hydrated} icon={<Download className="size-4" />} onClick={() => void exportCanvasProjects(projects.filter((project) => selectedIds.includes(project.id)), `无限画布-${selectedIds.length}个项目`)}> <Button disabled={!hydrated} icon={<Download className="size-4" />} onClick={() => void exportCanvasProjects(projects.filter((project) => selectedIds.includes(project.id)), `${t("canvas.title")}-${selectedIds.length}`)}>
{t("canvas.exportSelected")}
</Button> </Button>
<Button disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}> <Button disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}>
{t("canvas.deleteSelected")}
</Button> </Button>
</> </>
) : null} ) : null}
{projects.length ? ( {projects.length ? (
<Button disabled={!hydrated} onClick={() => setDeleteIds(projects.map((project) => project.id))}> <Button disabled={!hydrated} onClick={() => setDeleteIds(projects.map((project) => project.id))}>
{t("canvas.deleteAll")}
</Button> </Button>
) : null} ) : null}
<Button disabled={!hydrated} icon={<FileUp className="size-4" />} onClick={() => inputRef.current?.click()}> <Button disabled={!hydrated} icon={<FileUp className="size-4" />} onClick={() => inputRef.current?.click()}>
{t("canvas.import")}
</Button> </Button>
<Button disabled={!hydrated} type="primary" icon={<Plus className="size-4" />} onClick={createAndEnter}> <Button disabled={!hydrated} type="primary" icon={<Plus className="size-4" />} onClick={createAndEnter}>
{t("canvas.create")}
</Button> </Button>
</div> </div>
</header> </header>
{!hydrated ? ( {!hydrated ? (
<section className="flex min-h-[360px] items-center justify-center border-y border-stone-200 text-sm text-stone-500 dark:border-stone-800">...</section> <section className="flex min-h-[360px] items-center justify-center border-y border-stone-200 text-sm text-stone-500 dark:border-stone-800">{t("canvas.loading")}</section>
) : projects.length ? ( ) : projects.length ? (
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{projects.map((project) => ( {projects.map((project) => (
@@ -110,10 +112,10 @@ export default function CanvasPage() {
</div> </div>
) : ( ) : (
<section className="flex min-h-[360px] flex-col items-center justify-center border-y border-stone-200 text-center dark:border-stone-800"> <section className="flex min-h-[360px] flex-col items-center justify-center border-y border-stone-200 text-center dark:border-stone-800">
<h2 className="text-xl font-medium"></h2> <h2 className="text-xl font-medium">{t("canvas.empty")}</h2>
<p className="mt-3 text-sm text-stone-500">线</p> <p className="mt-3 text-sm text-stone-500">{t("canvas.emptyDescription")}</p>
<Button type="primary" className="mt-6" icon={<Plus className="size-4" />} onClick={createAndEnter}> <Button type="primary" className="mt-6" icon={<Plus className="size-4" />} onClick={createAndEnter}>
{t("canvas.create")}
</Button> </Button>
</section> </section>
)} )}
+76 -81
View File
@@ -3,6 +3,7 @@ import type { ChangeEvent as ReactChangeEvent, DragEvent as ReactDragEvent, Mous
import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { Group, Video } from "lucide-react"; import { Group, Video } from "lucide-react";
import { saveAs } from "file-saver"; import { saveAs } from "file-saver";
import { useTranslation } from "react-i18next";
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image"; import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
import { requestAudioGeneration, storeGeneratedAudio } from "@/services/api/audio"; import { requestAudioGeneration, storeGeneratedAudio } from "@/services/api/audio";
@@ -124,13 +125,6 @@ const NODE_STATUS_IDLE = "idle" as const;
const NODE_STATUS_LOADING = "loading" as const; const NODE_STATUS_LOADING = "loading" as const;
const NODE_STATUS_SUCCESS = "success" as const; const NODE_STATUS_SUCCESS = "success" as const;
const NODE_STATUS_ERROR = "error" as const; const NODE_STATUS_ERROR = "error" as const;
const IMAGE_PROMPT_REVERSE_PRESET = `请根据参考图片反推一段适合用于 AI 生图的提示词。
1.
2. 线
3. `;
export default function CanvasPage() { export default function CanvasPage() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@@ -145,6 +139,7 @@ export default function CanvasPage() {
function InfiniteCanvasPage() { function InfiniteCanvasPage() {
const { message, modal } = App.useApp(); const { message, modal } = App.useApp();
const { t } = useTranslation();
// 订阅节点注册表版本,插件动态注册/卸载后驱动画布重渲染 // 订阅节点注册表版本,插件动态注册/卸载后驱动画布重渲染
const nodeRegistryVersion = useNodeRegistryVersion((state) => state.version); const nodeRegistryVersion = useNodeRegistryVersion((state) => state.version);
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
@@ -304,15 +299,15 @@ function InfiniteCanvasPage() {
const confirmStopGeneration = useCallback( const confirmStopGeneration = useCallback(
(nodeId: string) => { (nodeId: string) => {
modal.confirm({ modal.confirm({
title: "停止生成?", title: t("canvas.projectPage.stopTitle"),
content: "当前生成请求会被中断,已经生成完成的内容会保留。", content: t("canvas.projectPage.stopDescription"),
okText: "停止", okText: t("canvas.projectPage.stop"),
cancelText: "继续生成", cancelText: t("canvas.projectPage.continue"),
okButtonProps: { danger: true }, okButtonProps: { danger: true },
onOk: () => stopGenerationByRunningId(nodeId), onOk: () => stopGenerationByRunningId(nodeId),
}); });
}, },
[modal, stopGenerationByRunningId], [modal, stopGenerationByRunningId, t],
); );
useEffect(() => { useEffect(() => {
@@ -488,7 +483,7 @@ function InfiniteCanvasPage() {
const connection = normalizeConnection(current.nodeId, targetNodeId, nodesRef.current, current.handleType); const connection = normalizeConnection(current.nodeId, targetNodeId, nodesRef.current, current.handleType);
if (!connection) { if (!connection) {
message.warning("配置节点之间不能连接"); message.warning(t("canvas.projectPage.configConnection"));
return; return;
} }
const { fromNodeId, toNodeId } = connection; const { fromNodeId, toNodeId } = connection;
@@ -498,7 +493,7 @@ function InfiniteCanvasPage() {
} }
setContextMenu(null); setContextMenu(null);
}, },
[message], [message, t],
); );
const createConnectedNode = useCallback( const createConnectedNode = useCallback(
@@ -507,7 +502,7 @@ function InfiniteCanvasPage() {
const newNode = createCanvasNode(type, pending.position, metadata); const newNode = createCanvasNode(type, pending.position, metadata);
const connection = normalizeConnection(pending.connection.nodeId, newNode.id, [...nodesRef.current, newNode], pending.connection.handleType); const connection = normalizeConnection(pending.connection.nodeId, newNode.id, [...nodesRef.current, newNode], pending.connection.handleType);
if (!connection) { if (!connection) {
message.warning("配置节点之间不能连接"); message.warning(t("canvas.projectPage.configConnection"));
return; return;
} }
setNodes((prev) => [...prev, newNode]); setNodes((prev) => [...prev, newNode]);
@@ -518,7 +513,7 @@ function InfiniteCanvasPage() {
setPendingConnectionCreate(null); setPendingConnectionCreate(null);
setConnecting(null); setConnecting(null);
}, },
[effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message, setConnecting], [effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message, setConnecting, t],
); );
const cancelPendingConnectionCreate = useCallback(() => { const cancelPendingConnectionCreate = useCallback(() => {
@@ -980,9 +975,9 @@ function InfiniteCanvasPage() {
}, [applyHistory]); }, [applyHistory]);
const createAndOpenProject = useCallback(() => { const createAndOpenProject = useCallback(() => {
const id = createProject(`无限画布 ${useCanvasStore.getState().projects.length + 1}`); const id = createProject(t("canvas.defaultTitle", { count: useCanvasStore.getState().projects.length + 1 }));
navigate(`/canvas/${id}`); navigate(`/canvas/${id}`);
}, [createProject, navigate]); }, [createProject, navigate, t]);
const deleteCurrentProject = useCallback(() => { const deleteCurrentProject = useCallback(() => {
deleteProjects([projectId]); deleteProjects([projectId]);
@@ -992,18 +987,18 @@ function InfiniteCanvasPage() {
const exportCurrentProject = useCallback(async () => { const exportCurrentProject = useCallback(async () => {
const project = useCanvasStore.getState().projects.find((item) => item.id === projectId); const project = useCanvasStore.getState().projects.find((item) => item.id === projectId);
if (!project) return message.error("未找到当前画布"); if (!project) return message.error(t("canvas.projectPage.notFound"));
const hide = message.loading("正在导出当前画布…", 0); const hide = message.loading(t("canvas.projectPage.exporting"), 0);
try { try {
await exportCanvasProjects([project], project.title || "无限画布"); await exportCanvasProjects([project], project.title || t("canvas.title"));
message.success("已导出当前画布"); message.success(t("canvas.projectPage.exported"));
} catch (error) { } catch (error) {
console.error(error); console.error(error);
message.error("导出失败,请重试"); message.error(t("canvas.sidePanel.exportFailed"));
} finally { } finally {
hide(); hide();
} }
}, [message, projectId]); }, [message, projectId, t]);
const handleCanvasMouseDown = useCallback( const handleCanvasMouseDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => { (event: ReactPointerEvent<HTMLDivElement>) => {
@@ -1342,7 +1337,7 @@ function InfiniteCanvasPage() {
const node = { const node = {
...createCanvasNode(CanvasNodeType.Text, getCanvasCenter(), { content: trimmed, status: NODE_STATUS_SUCCESS }), ...createCanvasNode(CanvasNodeType.Text, getCanvasCenter(), { content: trimmed, status: NODE_STATUS_SUCCESS }),
title: trimmed.slice(0, 32) || "剪切板文本", title: trimmed.slice(0, 32) || t("canvas.projectPage.clipboardText"),
}; };
setNodes((prev) => [...prev, node]); setNodes((prev) => [...prev, node]);
@@ -1352,7 +1347,7 @@ function InfiniteCanvasPage() {
setDialogNodeId(node.id); setDialogNodeId(node.id);
return true; return true;
}, },
[getCanvasCenter], [getCanvasCenter, t],
); );
const pasteSystemClipboard = useCallback(async () => { const pasteSystemClipboard = useCallback(async () => {
@@ -1366,13 +1361,13 @@ function InfiniteCanvasPage() {
const blob = await imageItem.getType(imageType); const blob = await imageItem.getType(imageType);
const file = new File([blob], "clipboard-image.png", { type: imageType }); const file = new File([blob], "clipboard-image.png", { type: imageType });
void createImageFileNode(file, getCanvasCenter()); void createImageFileNode(file, getCanvasCenter());
message.success("已从剪切板添加图片"); message.success(t("canvas.projectPage.clipboardImageAdded"));
return; return;
} }
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText();
if (createTextNodeFromClipboard(text)) message.success("已从剪切板添加文本"); if (createTextNodeFromClipboard(text)) message.success(t("canvas.projectPage.clipboardTextAdded"));
}, [createImageFileNode, createTextNodeFromClipboard, getCanvasCenter, message]); }, [createImageFileNode, createTextNodeFromClipboard, getCanvasCenter, message, t]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@@ -1567,30 +1562,30 @@ function InfiniteCanvasPage() {
async (node: CanvasNodeData) => { async (node: CanvasNodeData) => {
if (node.type === CanvasNodeType.Text) { if (node.type === CanvasNodeType.Text) {
const content = node.metadata?.content?.trim(); const content = node.metadata?.content?.trim();
if (!content) return message.error("没有可保存的文本"); if (!content) return message.error(t("canvas.projectPage.noTextToSave"));
addAsset({ kind: "text", title: node.metadata?.prompt?.slice(0, 24) || "画布文本", coverUrl: "", tags: [], source: "Canvas", data: { content }, metadata: { source: "canvas", nodeId: node.id } }); addAsset({ kind: "text", title: node.metadata?.prompt?.slice(0, 24) || t("canvas.projectPage.canvasText"), coverUrl: "", tags: [], source: "Canvas", data: { content }, metadata: { source: "canvas", nodeId: node.id } });
message.success("已加入我的资产"); message.success(t("common.addedToAssets"));
return; return;
} }
if (node.type === CanvasNodeType.Video) { if (node.type === CanvasNodeType.Video) {
if (!node.metadata?.content) return message.error("没有可保存的视频"); if (!node.metadata?.content) return message.error(t("canvas.projectPage.noVideoToSave"));
addAsset({ addAsset({
kind: "video", kind: "video",
title: node.metadata?.prompt?.slice(0, 24) || "画布视频", title: node.metadata?.prompt?.slice(0, 24) || t("canvas.projectPage.canvasVideo"),
coverUrl: "", coverUrl: "",
tags: [], tags: [],
source: "Canvas", source: "Canvas",
data: { url: node.metadata.content, storageKey: node.metadata.storageKey, width: node.width, height: node.height, bytes: node.metadata.bytes || 0, mimeType: node.metadata.mimeType || "video/mp4" }, data: { url: node.metadata.content, storageKey: node.metadata.storageKey, width: node.width, height: node.height, bytes: node.metadata.bytes || 0, mimeType: node.metadata.mimeType || "video/mp4" },
metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt }, metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt },
}); });
message.success("已加入我的资产"); message.success(t("common.addedToAssets"));
return; return;
} }
if (!node.metadata?.content) return message.error("没有可保存的图片"); if (!node.metadata?.content) return message.error(t("canvas.projectPage.noImageToSave"));
const dataUrl = node.metadata.storageKey ? "" : node.metadata.content; const dataUrl = node.metadata.storageKey ? "" : node.metadata.content;
addAsset({ addAsset({
kind: "image", kind: "image",
title: node.metadata?.prompt?.slice(0, 24) || "画布图片", title: node.metadata?.prompt?.slice(0, 24) || t("canvas.projectPage.canvasImage"),
coverUrl: node.metadata.content, coverUrl: node.metadata.content,
tags: [], tags: [],
source: "Canvas", source: "Canvas",
@@ -1604,15 +1599,15 @@ function InfiniteCanvasPage() {
}, },
metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt }, metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt },
}); });
message.success("已加入我的资产"); message.success(t("common.addedToAssets"));
}, },
[addAsset, message], [addAsset, message, t],
); );
const createImageReversePromptNodes = useCallback( const createImageReversePromptNodes = useCallback(
(node: CanvasNodeData) => { (node: CanvasNodeData) => {
if (node.type !== CanvasNodeType.Image || !node.metadata?.content) { if (node.type !== CanvasNodeType.Image || !node.metadata?.content) {
message.warning("图片节点为空,无法反推提示词"); message.warning(t("canvas.projectPage.emptyReverse"));
return; return;
} }
@@ -1621,8 +1616,8 @@ function InfiniteCanvasPage() {
const configSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Config]; const configSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Config];
const centerY = node.position.y + node.height / 2; const centerY = node.position.y + node.height / 2;
const textNode = { const textNode = {
...createCanvasNode(CanvasNodeType.Text, { x: node.position.x + node.width + gap + textSpec.width / 2, y: centerY }, { content: IMAGE_PROMPT_REVERSE_PRESET, prompt: IMAGE_PROMPT_REVERSE_PRESET, status: NODE_STATUS_SUCCESS, fontSize: 14 }), ...createCanvasNode(CanvasNodeType.Text, { x: node.position.x + node.width + gap + textSpec.width / 2, y: centerY }, { content: t("canvas.projectPage.reversePreset"), prompt: t("canvas.projectPage.reversePreset"), status: NODE_STATUS_SUCCESS, fontSize: 14 }),
title: "反推提示词", title: t("canvas.projectPage.reverseTitle"),
}; };
const configNode = { const configNode = {
...createCanvasNode( ...createCanvasNode(
@@ -1632,10 +1627,10 @@ function InfiniteCanvasPage() {
generationMode: "text", generationMode: "text",
model: effectiveConfig.textModel || effectiveConfig.model || defaultConfig.textModel, model: effectiveConfig.textModel || effectiveConfig.model || defaultConfig.textModel,
count: 1, count: 1,
composerContent: `参考图片:@[node:${node.id}]\n任务说明:@[node:${textNode.id}]`, composerContent: t("canvas.reverseComposer", { imageId: node.id, textId: textNode.id }),
}, },
), ),
title: "反推提示词配置", title: t("canvas.projectPage.reverseConfigTitle"),
}; };
setNodes((prev) => [...prev, textNode, configNode]); setNodes((prev) => [...prev, textNode, configNode]);
@@ -1645,7 +1640,7 @@ function InfiniteCanvasPage() {
setDialogNodeId(configNode.id); setDialogNodeId(configNode.id);
setContextMenu(null); setContextMenu(null);
}, },
[effectiveConfig.model, effectiveConfig.textModel, message], [effectiveConfig.model, effectiveConfig.textModel, message, t],
); );
const cropImageNode = useCallback(async (node: CanvasNodeData, crop: CanvasImageCropRect) => { const cropImageNode = useCallback(async (node: CanvasNodeData, crop: CanvasImageCropRect) => {
@@ -1690,7 +1685,7 @@ function InfiniteCanvasPage() {
return { return {
id, id,
type: CanvasNodeType.Image, type: CanvasNodeType.Image,
title: `${node.title || "图片"} ${piece.row + 1}-${piece.column + 1}`, title: t("canvas.projectPage.splitTitle", { name: node.title || t("assets.kinds.image"), row: piece.row + 1, column: piece.column + 1 }),
position: { x: startX + piece.column * (cellWidth + gap), y: startY + piece.row * (cellHeight + gap) }, position: { x: startX + piece.column * (cellWidth + gap), y: startY + piece.row * (cellHeight + gap) },
width: cellWidth, width: cellWidth,
height: cellHeight, height: cellHeight,
@@ -1706,9 +1701,9 @@ function InfiniteCanvasPage() {
setSelectedNodeIds(new Set(childNodes.map((child) => child.id))); setSelectedNodeIds(new Set(childNodes.map((child) => child.id)));
setSelectedConnectionId(null); setSelectedConnectionId(null);
setDialogNodeId(null); setDialogNodeId(null);
message.success(`已切分为 ${childNodes.length} 个子节点`); message.success(t("canvas.projectPage.splitSuccess", { count: childNodes.length }));
}, },
[message], [message, t],
); );
const maskEditImageNode = useCallback( const maskEditImageNode = useCallback(
@@ -1720,7 +1715,7 @@ function InfiniteCanvasPage() {
return; return;
} }
const userPrompt = payload.prompt.trim(); const userPrompt = payload.prompt.trim();
const prompt = `只修改蒙版透明区域,其他区域保持不变。${userPrompt}`; const prompt = t("canvas.projectPage.maskPrompt", { prompt: userPrompt });
const childId = nanoid(); const childId = nanoid();
const source = { id: node.id, name: `${node.title || node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content, storageKey: node.metadata.storageKey }; const source = { id: node.id, name: `${node.title || node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content, storageKey: node.metadata.storageKey };
const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [source]); const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [source]);
@@ -1731,7 +1726,7 @@ function InfiniteCanvasPage() {
{ {
id: childId, id: childId,
type: CanvasNodeType.Image, type: CanvasNodeType.Image,
title: userPrompt.slice(0, 32) || "局部编辑结果", title: userPrompt.slice(0, 32) || t("canvas.projectPage.maskResult"),
position: { x: node.position.x + node.width + 96, y: node.position.y }, position: { x: node.position.x + node.width + 96, y: node.position.y },
width: node.width, width: node.width,
height: node.height, height: node.height,
@@ -1750,7 +1745,7 @@ function InfiniteCanvasPage() {
setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt, ...generationMetadata } } : item))); setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt, ...generationMetadata } } : item)));
} catch (error) { } catch (error) {
if (isGenerationCanceled(error)) return; if (isGenerationCanceled(error)) return;
const errorDetails = error instanceof Error ? error.message : "局部修改失败"; const errorDetails = error instanceof Error ? error.message : t("canvas.projectPage.maskFailed");
message.error(errorDetails); message.error(errorDetails);
setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item))); setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item)));
} finally { } finally {
@@ -1758,7 +1753,7 @@ function InfiniteCanvasPage() {
setRunningNodeId(null); setRunningNodeId(null);
} }
}, },
[effectiveConfig, finishGenerationRequest, isAiConfigReady, message, openConfigDialog, startGenerationRequest], [effectiveConfig, finishGenerationRequest, isAiConfigReady, message, openConfigDialog, startGenerationRequest, t],
); );
const upscaleImageNode = useCallback(async (node: CanvasNodeData, params: CanvasImageUpscaleParams) => { const upscaleImageNode = useCallback(async (node: CanvasNodeData, params: CanvasImageUpscaleParams) => {
@@ -1832,14 +1827,14 @@ function InfiniteCanvasPage() {
setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt, ...generationMetadata } } : item))); setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt, ...generationMetadata } } : item)));
} catch (error) { } catch (error) {
if (isGenerationCanceled(error)) return; if (isGenerationCanceled(error)) return;
const errorDetails = error instanceof Error ? error.message : "生成失败"; const errorDetails = error instanceof Error ? error.message : t("canvas.projectPage.generationFailed");
setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item))); setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item)));
} finally { } finally {
finishGenerationRequest(childId, controller); finishGenerationRequest(childId, controller);
setRunningNodeId(null); setRunningNodeId(null);
} }
}, },
[effectiveConfig, finishGenerationRequest, openConfigDialog, startGenerationRequest], [effectiveConfig, finishGenerationRequest, openConfigDialog, startGenerationRequest, t],
); );
const handleFontSizeChange = useCallback((nodeId: string, fontSize: number) => { const handleFontSizeChange = useCallback((nodeId: string, fontSize: number) => {
@@ -2013,9 +2008,9 @@ function InfiniteCanvasPage() {
); );
const startTitleEditing = useCallback(() => { const startTitleEditing = useCallback(() => {
setTitleDraft(currentProject?.title || "未命名画布"); setTitleDraft(currentProject?.title || t("canvas.projectPage.untitledCanvas"));
setTitleEditing(true); setTitleEditing(true);
}, [currentProject?.title]); }, [currentProject?.title, t]);
const finishTitleEditing = useCallback(() => { const finishTitleEditing = useCallback(() => {
const nextTitle = titleDraft.trim(); const nextTitle = titleDraft.trim();
@@ -2069,7 +2064,7 @@ function InfiniteCanvasPage() {
setDialogNodeId(null); setDialogNodeId(null);
} catch (error) { } catch (error) {
if (!isGenerationCanceled(error)) { if (!isGenerationCanceled(error)) {
const errorDetails = error instanceof Error ? error.message : "生成失败"; const errorDetails = error instanceof Error ? error.message : t("canvas.projectPage.generationFailed");
message.error(errorDetails); message.error(errorDetails);
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } } : node))); setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } } : node)));
} }
@@ -2084,7 +2079,7 @@ function InfiniteCanvasPage() {
const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : ""; const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : "";
const editingTextNode = mode === "text" && Boolean(sourceTextContent); const editingTextNode = mode === "text" && Boolean(sourceTextContent);
const generationContext = await hydrateNodeGenerationContext( const generationContext = await hydrateNodeGenerationContext(
buildNodeGenerationContext(nodeId, nodesRef.current, connectionsRef.current, editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : prompt), buildNodeGenerationContext(nodeId, nodesRef.current, connectionsRef.current, editingTextNode ? t("canvas.projectPage.editTextPrompt", { source: sourceTextContent, prompt }) : prompt),
); );
const effectivePrompt = generationContext.prompt.trim(); const effectivePrompt = generationContext.prompt.trim();
if (runController.signal.aborted) { if (runController.signal.aborted) {
@@ -2240,7 +2235,7 @@ function InfiniteCanvasPage() {
return true; return true;
} catch (error) { } catch (error) {
if (isGenerationCanceled(error)) return false; if (isGenerationCanceled(error)) return false;
const errorDetails = error instanceof Error ? error.message : "生成失败"; const errorDetails = error instanceof Error ? error.message : t("canvas.projectPage.generationFailed");
if (!firstError) firstError = errorDetails; if (!firstError) firstError = errorDetails;
hasFailure = true; hasFailure = true;
setNodes((prev) => prev.map((node) => (node.id === targetId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } } : node))); setNodes((prev) => prev.map((node) => (node.id === targetId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } } : node)));
@@ -2256,16 +2251,16 @@ function InfiniteCanvasPage() {
return; return;
} }
if (hasFailure) { if (hasFailure) {
message.error(hasSuccess ? "部分图片生成失败" : firstError || "生成失败"); message.error(hasSuccess ? t("canvas.projectPage.partialFailed") : firstError || t("canvas.projectPage.generationFailed"));
} }
setNodes((prev) => setNodes((prev) =>
prev.map((node) => prev.map((node) =>
node.id === nodeId && isConfigNode node.id === nodeId && isConfigNode
? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : "生成失败" } } ? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : t("canvas.projectPage.generationFailed") } }
: node.id === nodeId && isEmptyImageNode : node.id === nodeId && isEmptyImageNode
? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : "生成失败" } } ? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : t("canvas.projectPage.generationFailed") } }
: node.id === rootId && !hasSuccess && !targetIds.includes(node.id) : node.id === rootId && !hasSuccess && !targetIds.includes(node.id)
? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails: "全部图片生成失败" } } ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails: t("canvas.projectPage.allFailed") } }
: node, : node,
), ),
); );
@@ -2436,7 +2431,7 @@ function InfiniteCanvasPage() {
); );
} catch (error) { } catch (error) {
if (isGenerationCanceled(error)) return; if (isGenerationCanceled(error)) return;
const errorDetails = error instanceof Error ? error.message : "生成失败"; const errorDetails = error instanceof Error ? error.message : t("canvas.projectPage.generationFailed");
message.error(errorDetails); message.error(errorDetails);
setNodes((prev) => setNodes((prev) =>
prev.map((node) => (node.id === nodeId || pendingChildIds.includes(node.id) ? (node.id === nodeId && !markSourceStatus ? node : { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } }) : node)), prev.map((node) => (node.id === nodeId || pendingChildIds.includes(node.id) ? (node.id === nodeId && !markSourceStatus ? node : { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } }) : node)),
@@ -2446,7 +2441,7 @@ function InfiniteCanvasPage() {
setRunningNodeId(null); setRunningNodeId(null);
} }
}, },
[effectiveConfig, finishGenerationRequest, isAiConfigReady, message, openConfigDialog, startGenerationRequest], [effectiveConfig, finishGenerationRequest, isAiConfigReady, message, openConfigDialog, startGenerationRequest, t],
); );
useEffect(() => { useEffect(() => {
generateNodeRef.current = handleGenerateNode; generateNodeRef.current = handleGenerateNode;
@@ -2477,7 +2472,7 @@ function InfiniteCanvasPage() {
const context = hasSavedImageMetadata ? null : await hydrateNodeGenerationContext(buildNodeGenerationContext(sourceNode.id, nodesRef.current, connectionsRef.current, sourceNode.metadata?.prompt || node.metadata?.prompt || "")); const context = hasSavedImageMetadata ? null : await hydrateNodeGenerationContext(buildNodeGenerationContext(sourceNode.id, nodesRef.current, connectionsRef.current, sourceNode.metadata?.prompt || node.metadata?.prompt || ""));
const prompt = (savedImageMetadata?.prompt || context?.prompt || "").trim(); const prompt = (savedImageMetadata?.prompt || context?.prompt || "").trim();
if (!prompt) { if (!prompt) {
message.warning("找不到提示词,无法重试"); message.warning(t("canvas.projectPage.retryPromptMissing"));
return; return;
} }
const generationType = savedImageMetadata?.generationType; const generationType = savedImageMetadata?.generationType;
@@ -2485,8 +2480,8 @@ function InfiniteCanvasPage() {
const retryReferenceImages = const retryReferenceImages =
hasSavedImageMetadata && savedImageMetadata ? await resolveMetadataReferences(savedImageMetadata) : useReferenceImages ? (context?.referenceImages.length ? context.referenceImages : sourceNodeReferenceImages(batchRoot || sourceNode)) : []; hasSavedImageMetadata && savedImageMetadata ? await resolveMetadataReferences(savedImageMetadata) : useReferenceImages ? (context?.referenceImages.length ? context.referenceImages : sourceNodeReferenceImages(batchRoot || sourceNode)) : [];
if (useReferenceImages && !retryReferenceImages) { if (useReferenceImages && !retryReferenceImages) {
message.error("参考图片已丢失,无法继续重试"); message.error(t("canvas.projectPage.referenceMissing"));
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: "参考图片已丢失,无法继续重试" } } : item))); setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: t("canvas.projectPage.referenceMissing") } } : item)));
return; return;
} }
const retryImages = retryReferenceImages || []; const retryImages = retryReferenceImages || [];
@@ -2577,7 +2572,7 @@ function InfiniteCanvasPage() {
); );
} catch (error) { } catch (error) {
if (isGenerationCanceled(error)) return; if (isGenerationCanceled(error)) return;
const errorDetails = error instanceof Error ? error.message : "生成失败"; const errorDetails = error instanceof Error ? error.message : t("canvas.projectPage.generationFailed");
message.error(errorDetails); message.error(errorDetails);
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item))); setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item)));
} finally { } finally {
@@ -2585,14 +2580,14 @@ function InfiniteCanvasPage() {
setRunningNodeId(null); setRunningNodeId(null);
} }
}, },
[effectiveConfig, finishGenerationRequest, isAiConfigReady, message, openConfigDialog, startGenerationRequest], [effectiveConfig, finishGenerationRequest, isAiConfigReady, message, openConfigDialog, startGenerationRequest, t],
); );
const generateImageFromTextNode = useCallback( const generateImageFromTextNode = useCallback(
(node: CanvasNodeData) => { (node: CanvasNodeData) => {
const prompt = (node.metadata?.content || node.metadata?.prompt || "").trim(); const prompt = (node.metadata?.content || node.metadata?.prompt || "").trim();
if (!prompt) { if (!prompt) {
message.warning("文本节点为空,无法生图"); message.warning(t("canvas.projectPage.emptyTextImage"));
return; return;
} }
const sourceNode = nodesRef.current.find((item) => item.id === node.id); const sourceNode = nodesRef.current.find((item) => item.id === node.id);
@@ -2622,7 +2617,7 @@ function InfiniteCanvasPage() {
setSelectedConnectionId(null); setSelectedConnectionId(null);
setDialogNodeId(configNode.id); setDialogNodeId(configNode.id);
}, },
[effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message], [effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message, t],
); );
const insertAssistantImage = useCallback( const insertAssistantImage = useCallback(
@@ -2769,7 +2764,7 @@ function InfiniteCanvasPage() {
<CanvasSidePanel nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={focusNode} onPreviewNode={setPreviewNodeId} onInsertAsset={handleAssetInsert} /> <CanvasSidePanel nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={focusNode} onPreviewNode={setPreviewNodeId} onInsertAsset={handleAssetInsert} />
<section className="relative min-w-0 flex-1 overflow-hidden"> <section className="relative min-w-0 flex-1 overflow-hidden">
<CanvasTopBar <CanvasTopBar
title={currentProject?.title || "未命名画布"} title={currentProject?.title || t("canvas.projectPage.untitledCanvas")}
titleDraft={titleDraft} titleDraft={titleDraft}
isTitleEditing={titleEditing} isTitleEditing={titleEditing}
onTitleDraftChange={setTitleDraft} onTitleDraftChange={setTitleDraft}
@@ -3007,14 +3002,14 @@ function InfiniteCanvasPage() {
<CanvasNodeUpscaleDialog dataUrl={upscaleNode.metadata.content} open={Boolean(upscaleNode)} onClose={() => setUpscaleNodeId(null)} onConfirm={(params) => void upscaleImageNode(upscaleNode!, params)} /> <CanvasNodeUpscaleDialog dataUrl={upscaleNode.metadata.content} open={Boolean(upscaleNode)} onClose={() => setUpscaleNodeId(null)} onConfirm={(params) => void upscaleImageNode(upscaleNode!, params)} />
) : null} ) : null}
<Modal title="AI 超分" open={Boolean(superResolveNode?.metadata?.content)} centered footer={null} onCancel={() => setSuperResolveNodeId(null)}> <Modal title={t("canvas.projectPage.superResolve")} open={Boolean(superResolveNode?.metadata?.content)} centered footer={null} onCancel={() => setSuperResolveNodeId(null)}>
<div className="py-8 text-center text-base font-medium"></div> <div className="py-8 text-center text-base font-medium">{t("canvas.projectPage.notImplemented")}</div>
</Modal> </Modal>
{angleNode?.metadata?.content ? <CanvasNodeAngleDialog dataUrl={angleNode.metadata.content} open={Boolean(angleNode)} onClose={() => setAngleNodeId(null)} onConfirm={(params) => void generateAngleNode(angleNode!, params)} /> : null} {angleNode?.metadata?.content ? <CanvasNodeAngleDialog dataUrl={angleNode.metadata.content} open={Boolean(angleNode)} onClose={() => setAngleNodeId(null)} onConfirm={(params) => void generateAngleNode(angleNode!, params)} /> : null}
<Modal <Modal
title="图片详情" title={t("canvas.projectPage.imageDetails")}
open={Boolean(previewNode?.metadata?.content)} open={Boolean(previewNode?.metadata?.content)}
centered centered
onCancel={() => setPreviewNodeId(null)} onCancel={() => setPreviewNodeId(null)}
@@ -3022,24 +3017,24 @@ function InfiniteCanvasPage() {
width="auto" width="auto"
styles={{ body: { padding: 0, display: "flex", justifyContent: "center", alignItems: "center", maxHeight: "80vh" } }} styles={{ body: { padding: 0, display: "flex", justifyContent: "center", alignItems: "center", maxHeight: "80vh" } }}
> >
{previewNode?.metadata?.content ? <img src={previewNode.metadata.content} alt={previewNode.title || "图片"} style={{ maxWidth: "100%", maxHeight: "80vh", objectFit: "contain" }} /> : null} {previewNode?.metadata?.content ? <img src={previewNode.metadata.content} alt={previewNode.title || t("assets.kinds.image")} style={{ maxWidth: "100%", maxHeight: "80vh", objectFit: "contain" }} /> : null}
</Modal> </Modal>
<Modal <Modal
title="清空画布?" title={t("canvas.projectPage.clearTitle")}
open={clearConfirmOpen} open={clearConfirmOpen}
centered centered
onCancel={() => setClearConfirmOpen(false)} onCancel={() => setClearConfirmOpen(false)}
footer={ footer={
<> <>
<Button onClick={() => setClearConfirmOpen(false)}></Button> <Button onClick={() => setClearConfirmOpen(false)}>{t("common.cancel")}</Button>
<Button danger type="primary" onClick={clearCanvas}> <Button danger type="primary" onClick={clearCanvas}>
{t("canvas.projectPage.clear")}
</Button> </Button>
</> </>
} }
> >
<p className="text-sm opacity-60">线</p> <p className="text-sm opacity-60">{t("canvas.projectPage.clearDescription")}</p>
</Modal> </Modal>
<AssetPickerModal open={assetPickerOpen} onInsert={handleAssetInsert} onClose={() => setAssetPickerOpen(false)} /> <AssetPickerModal open={assetPickerOpen} onInsert={handleAssetInsert} onClose={() => setAssetPickerOpen(false)} />
+6 -2
View File
@@ -1,12 +1,16 @@
import { useTranslation } from "react-i18next";
import { AppConfigPanel } from "@/components/layout/app-config-modal"; import { AppConfigPanel } from "@/components/layout/app-config-modal";
export default function ConfigPage() { export default function ConfigPage() {
const { t } = useTranslation();
return ( return (
<main className="h-full overflow-y-auto bg-background"> <main className="h-full overflow-y-auto bg-background">
<div className="mx-auto max-w-6xl px-6 py-6"> <div className="mx-auto max-w-6xl px-6 py-6">
<div className="mb-5"> <div className="mb-5">
<h1 className="text-xl font-semibold text-stone-950 dark:text-stone-100"></h1> <h1 className="text-xl font-semibold text-stone-950 dark:text-stone-100">{t("config.title")}</h1>
<p className="mt-1 text-sm text-stone-500"></p> <p className="mt-1 text-sm text-stone-500">{t("config.description")}</p>
</div> </div>
<AppConfigPanel /> <AppConfigPanel />
</div> </div>
+12 -17
View File
@@ -2,12 +2,14 @@ import { ArrowRight } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react"; import { type ReactNode, useEffect, useState } from "react";
import { App, Button, Image, Tag } from "antd"; import { App, Button, Image, Tag } from "antd";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import { fetchPrompts, type Prompt } from "@/services/api/prompts"; import { fetchPrompts, type Prompt } from "@/services/api/prompts";
import { navigationTools } from "@/constant/navigation-tools"; import { navigationTools } from "@/constant/navigation-tools";
import i18n from "@/i18n";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Highlighter({ action, color, children }: { action: "highlight" | "underline"; color: string; children: ReactNode }) { function Highlighter({ action, color, children }: { action: "highlight" | "underline"; color: string; children?: ReactNode }) {
return ( return (
<span className="relative inline-block px-1"> <span className="relative inline-block px-1">
{action === "highlight" ? ( {action === "highlight" ? (
@@ -22,6 +24,7 @@ function Highlighter({ action, color, children }: { action: "highlight" | "under
export default function IndexPage() { export default function IndexPage() {
const { message } = App.useApp(); const { message } = App.useApp();
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [primaryTool] = navigationTools; const [primaryTool] = navigationTools;
const [promptShowcase, setPromptShowcase] = useState<Prompt[]>([]); const [promptShowcase, setPromptShowcase] = useState<Prompt[]>([]);
@@ -31,7 +34,7 @@ export default function IndexPage() {
useEffect(() => { useEffect(() => {
void fetchPrompts({ pageSize: 12 }) void fetchPrompts({ pageSize: 12 })
.then((data) => setPromptShowcase(data.items)) .then((data) => setPromptShowcase(data.items))
.catch((error) => message.error(error instanceof Error ? error.message : "获取提示词失败")); .catch((error) => message.error(error instanceof Error ? error.message : i18n.t("home.promptError")));
}, [message]); }, [message]);
return ( return (
@@ -41,24 +44,16 @@ export default function IndexPage() {
<div className="pointer-events-none absolute right-[23%] top-[48%] size-20 rounded-full border border-dashed border-stone-200 dark:border-stone-800" /> <div className="pointer-events-none absolute right-[23%] top-[48%] size-20 rounded-full border border-dashed border-stone-200 dark:border-stone-800" />
<div className="relative flex min-h-[620px] flex-col items-center justify-center pt-10 text-center"> <div className="relative flex min-h-[620px] flex-col items-center justify-center pt-10 text-center">
<h1 className="ai-title-aurora max-w-5xl text-balance text-5xl font-semibold tracking-normal sm:text-7xl lg:text-8xl"></h1> <h1 className="ai-title-aurora max-w-5xl text-balance text-5xl font-semibold tracking-normal sm:text-7xl lg:text-8xl">{t("meta.title")}</h1>
<p className="mt-8 max-w-3xl text-balance text-lg leading-8 text-stone-500 dark:text-stone-400"> <p className="mt-8 max-w-3xl text-balance text-lg leading-8 text-stone-500 dark:text-stone-400">
<Trans i18nKey="home.description" components={{ canvas: <Highlighter action="underline" color="#FF9800" />, content: <Highlighter action="highlight" color="#87CEFA" /> }} />
<Highlighter action="underline" color="#FF9800">
</Highlighter>
<Highlighter action="highlight" color="#87CEFA">
</Highlighter>
</p> </p>
<div className="mt-10 flex flex-wrap items-center justify-center gap-3"> <div className="mt-10 flex flex-wrap items-center justify-center gap-3">
<Button type="primary" size="large" onClick={() => navigate(`/${primaryTool.slug}`)} icon={<ArrowRight className="size-4" />} iconPlacement="end"> <Button type="primary" size="large" onClick={() => navigate(`/${primaryTool.slug}`)} icon={<ArrowRight className="size-4" />} iconPlacement="end">
使 {t("home.start")}
</Button> </Button>
<Button size="large" onClick={() => navigate("/canvas")}> <Button size="large" onClick={() => navigate("/canvas")}>
{t("home.openCanvas")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -67,11 +62,11 @@ export default function IndexPage() {
<div className="mb-8 grid gap-4 md:grid-cols-[1fr_auto_1fr] md:items-start"> <div className="mb-8 grid gap-4 md:grid-cols-[1fr_auto_1fr] md:items-start">
<div /> <div />
<div className="max-w-2xl text-center"> <div className="max-w-2xl text-center">
<h2 className="text-3xl font-semibold text-stone-950 dark:text-stone-100"></h2> <h2 className="text-3xl font-semibold text-stone-950 dark:text-stone-100">{t("home.showcaseTitle")}</h2>
<p className="mt-3 text-base leading-7 text-stone-500 dark:text-stone-400"></p> <p className="mt-3 text-base leading-7 text-stone-500 dark:text-stone-400">{t("home.showcaseDescription")}</p>
</div> </div>
<Button type="link" onClick={() => navigate("/prompts")} className="justify-self-center md:justify-self-end" icon={<ArrowRight className="size-4" />} iconPlacement="end"> <Button type="link" onClick={() => navigate("/prompts")} className="justify-self-center md:justify-self-end" icon={<ArrowRight className="size-4" />} iconPlacement="end">
{t("home.viewPrompts")}
</Button> </Button>
</div> </div>
<div className="grid auto-rows-[210px] gap-4 md:grid-cols-4"> <div className="grid auto-rows-[210px] gap-4 md:grid-cols-4">
+79 -70
View File
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react";
import { App, Button, Checkbox, Drawer, Empty, Image, Input, Modal, Tag, Tooltip, Typography } from "antd"; import { App, Button, Checkbox, Drawer, Empty, Image, Input, Modal, Tag, Tooltip, Typography } from "antd";
import localforage from "localforage"; import localforage from "localforage";
import { saveAs } from "file-saver"; import { saveAs } from "file-saver";
import { useTranslation } from "react-i18next";
import { ImageSettingsPanel } from "@/components/image-settings-panel"; import { ImageSettingsPanel } from "@/components/image-settings-panel";
import { ModelPicker } from "@/components/model-picker"; import { ModelPicker } from "@/components/model-picker";
@@ -19,6 +20,7 @@ import { deleteStoredImages, resolveImageUrl, uploadImage } from "@/services/ima
import { useAssetStore } from "@/stores/use-asset-store"; import { useAssetStore } from "@/stores/use-asset-store";
import { useWorkbenchAgentStore } from "@/stores/use-workbench-agent-store"; import { useWorkbenchAgentStore } from "@/stores/use-workbench-agent-store";
import type { ReferenceImage } from "@/types/image"; import type { ReferenceImage } from "@/types/image";
import i18n from "@/i18n";
type GeneratedImage = { type GeneratedImage = {
id: string; id: string;
@@ -53,7 +55,7 @@ type GenerationLog = {
imageCount: number; imageCount: number;
size: string; size: string;
quality: string; quality: string;
status: "成功" | "失败"; status: "success" | "failed";
images: GeneratedImage[]; images: GeneratedImage[];
thumbnails: string[]; thumbnails: string[];
}; };
@@ -68,6 +70,7 @@ const logStore = localforage.createInstance({ name: "infinite-canvas", storeName
export default function ImagePage() { export default function ImagePage() {
const { message } = App.useApp(); const { message } = App.useApp();
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0); const dragDepthRef = useRef(0);
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
@@ -128,7 +131,7 @@ export default function ImagePage() {
const items = await navigator.clipboard.read(); const items = await navigator.clipboard.read();
const blobs = await Promise.all(items.flatMap((item) => item.types.filter((type) => type.startsWith("image/")).map((type) => item.getType(type)))); const blobs = await Promise.all(items.flatMap((item) => item.types.filter((type) => type.startsWith("image/")).map((type) => item.getType(type))));
if (!blobs.length) { if (!blobs.length) {
message.error("剪切板里没有可读取的图片"); message.error(t("imageWorkbench.clipboardEmpty"));
return; return;
} }
const nextReferences = await Promise.all( const nextReferences = await Promise.all(
@@ -138,9 +141,9 @@ export default function ImagePage() {
}), }),
); );
setReferences((value) => [...value, ...nextReferences]); setReferences((value) => [...value, ...nextReferences]);
message.success(`已读取 ${nextReferences.length} 张参考图`); message.success(t("imageWorkbench.clipboardAdded", { count: nextReferences.length }));
} catch { } catch {
message.error("剪切板里没有可读取的图片"); message.error(t("imageWorkbench.clipboardEmpty"));
} }
}; };
@@ -149,20 +152,20 @@ export default function ImagePage() {
agentTaskIdRef.current = undefined; agentTaskIdRef.current = undefined;
const text = prompt.trim(); const text = prompt.trim();
if (!text) { if (!text) {
message.error("请输入生图提示词"); message.error(t("imageWorkbench.promptRequired"));
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: "请输入生图提示词" }); if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: t("imageWorkbench.promptRequired") });
return; return;
} }
if (!isAiConfigReady(effectiveConfig, model)) { if (!isAiConfigReady(effectiveConfig, model)) {
message.warning("请先完成配置"); message.warning(t("workbench.configFirst"));
openConfigDialog(true); openConfigDialog(true);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: "生图配置不完整" }); if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: t("imageWorkbench.configIncomplete") });
return; return;
} }
const snapshot = buildRequestSnapshot(); const snapshot = buildRequestSnapshot();
if (!snapshot) { if (!snapshot) {
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: "生图参数无效" }); if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: t("imageWorkbench.invalidParams") });
return; return;
} }
@@ -181,7 +184,7 @@ export default function ImagePage() {
const successCount = successImages.length; const successCount = successImages.length;
const failCount = generationCount - successCount; const failCount = generationCount - successCount;
const failed = result.find((item): item is PromiseRejectedResult => item.status === "rejected"); const failed = result.find((item): item is PromiseRejectedResult => item.status === "rejected");
const error = failed?.reason instanceof Error ? failed.reason.message : failCount ? "生成失败" : undefined; const error = failed?.reason instanceof Error ? failed.reason.message : failCount ? t("workbench.generationFailed") : undefined;
if (agentTaskId) updateAgentTask(agentTaskId, { status: successCount ? "succeeded" : "failed", successCount, failCount, error: successCount ? undefined : error }); if (agentTaskId) updateAgentTask(agentTaskId, { status: successCount ? "succeeded" : "failed", successCount, failCount, error: successCount ? undefined : error });
try { try {
@@ -200,11 +203,11 @@ export default function ImagePage() {
durationMs: performance.now() - batchStartedAt, durationMs: performance.now() - batchStartedAt,
successCount, successCount,
failCount, failCount,
status: successCount ? "成功" : "失败", status: successCount ? "success" : "failed",
images: logImages, images: logImages,
}), }),
); );
successCount ? message.success("图片已生成") : message.error(failed?.reason instanceof Error ? failed.reason.message : "生成失败"); successCount ? message.success(t("imageWorkbench.generated")) : message.error(failed?.reason instanceof Error ? failed.reason.message : t("workbench.generationFailed"));
} finally { } finally {
setRunning(false); setRunning(false);
} }
@@ -217,7 +220,7 @@ export default function ImagePage() {
clearImageCommand(); clearImageCommand();
if (typeof imageCommand.prompt === "string") setPrompt(imageCommand.prompt); if (typeof imageCommand.prompt === "string") setPrompt(imageCommand.prompt);
if (imageCommand.run && running) { if (imageCommand.run && running) {
if (imageCommand.taskId) updateAgentTask(imageCommand.taskId, { status: "failed", error: "生图工作台已有任务正在运行" }); if (imageCommand.taskId) updateAgentTask(imageCommand.taskId, { status: "failed", error: t("imageWorkbench.busy") });
return; return;
} }
if (imageCommand.run) { if (imageCommand.run) {
@@ -239,21 +242,21 @@ export default function ImagePage() {
const addResultToReferences = async (image: GeneratedImage, index: number) => { const addResultToReferences = async (image: GeneratedImage, index: number) => {
const stored = await uploadImage(image.dataUrl); const stored = await uploadImage(image.dataUrl);
setReferences((value) => [...value, { id: nanoid(), name: `result-${index + 1}.png`, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]); setReferences((value) => [...value, { id: nanoid(), name: `result-${index + 1}.png`, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]);
message.success("已加入参考图"); message.success(t("imageWorkbench.addedReference"));
}; };
const saveResultToAssets = async (image: GeneratedImage, index: number) => { const saveResultToAssets = async (image: GeneratedImage, index: number) => {
const stored = await uploadImage(image.dataUrl); const stored = await uploadImage(image.dataUrl);
addAsset({ addAsset({
kind: "image", kind: "image",
title: `生成结果 ${index + 1}`, title: t("imageWorkbench.resultTitle", { count: index + 1 }),
coverUrl: stored.url, coverUrl: stored.url,
tags: [], tags: [],
source: "生图工作台", source: t("imageWorkbench.source"),
data: { dataUrl: stored.url, storageKey: stored.storageKey, width: stored.width, height: stored.height, bytes: stored.bytes, mimeType: stored.mimeType }, data: { dataUrl: stored.url, storageKey: stored.storageKey, width: stored.width, height: stored.height, bytes: stored.bytes, mimeType: stored.mimeType },
metadata: { source: "image-page", prompt }, metadata: { source: "image-page", prompt },
}); });
message.success("已加入我的资产"); message.success(t("common.addedToAssets"));
}; };
const insertPickedAsset = async (payload: InsertAssetPayload) => { const insertPickedAsset = async (payload: InsertAssetPayload) => {
@@ -263,7 +266,7 @@ export default function ImagePage() {
const stored = await uploadImage(payload.dataUrl); const stored = await uploadImage(payload.dataUrl);
setReferences((value) => [...value, { id: nanoid(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]); setReferences((value) => [...value, { id: nanoid(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]);
} else { } else {
message.warning("生图工作台只能使用文本或图片资产"); message.warning(t("imageWorkbench.unsupportedAsset"));
} }
setAssetPickerOpen(false); setAssetPickerOpen(false);
}; };
@@ -310,11 +313,11 @@ export default function ImagePage() {
const buildRequestSnapshot = () => { const buildRequestSnapshot = () => {
const text = prompt.trim(); const text = prompt.trim();
if (!text) { if (!text) {
message.error("请输入生图提示词"); message.error(t("imageWorkbench.promptRequired"));
return null; return null;
} }
if (!isAiConfigReady(effectiveConfig, model)) { if (!isAiConfigReady(effectiveConfig, model)) {
message.warning("请先完成配置"); message.warning(t("workbench.configFirst"));
openConfigDialog(true); openConfigDialog(true);
return null; return null;
} }
@@ -326,13 +329,13 @@ export default function ImagePage() {
try { try {
const result = snapshot.references.length ? await requestEdit(snapshot.config, snapshot.text, snapshot.references) : await requestGeneration(snapshot.config, snapshot.text); const result = snapshot.references.length ? await requestEdit(snapshot.config, snapshot.text, snapshot.references) : await requestGeneration(snapshot.config, snapshot.text);
const image = result[0]; const image = result[0];
if (!image) throw new Error("接口没有返回图片"); if (!image) throw new Error(t("imageWorkbench.missingResult"));
const meta = await readImageMeta(image.dataUrl); const meta = await readImageMeta(image.dataUrl);
const nextImage = { id: image.id, dataUrl: image.dataUrl, durationMs: performance.now() - itemStartedAt, width: meta.width, height: meta.height, bytes: getDataUrlByteSize(image.dataUrl) }; const nextImage = { id: image.id, dataUrl: image.dataUrl, durationMs: performance.now() - itemStartedAt, width: meta.width, height: meta.height, bytes: getDataUrlByteSize(image.dataUrl) };
setResults((value) => updateResultAt(value, index, { status: "success", image: nextImage })); setResults((value) => updateResultAt(value, index, { status: "success", image: nextImage }));
return nextImage; return nextImage;
} catch (error) { } catch (error) {
setResults((value) => updateResultAt(value, index, { status: "failed", error: error instanceof Error ? error.message : "生成失败" })); setResults((value) => updateResultAt(value, index, { status: "failed", error: error instanceof Error ? error.message : t("workbench.generationFailed") }));
throw error; throw error;
} }
}; };
@@ -357,11 +360,11 @@ export default function ImagePage() {
durationMs: performance.now() - retryStartedAt, durationMs: performance.now() - retryStartedAt,
successCount: 1, successCount: 1,
failCount: 0, failCount: 0,
status: "成功", status: "success",
images: [logImage], images: [logImage],
}), }),
); );
message.success("重试成功"); message.success(t("workbench.retrySuccess"));
} catch { } catch {
// runGenerationSlot 已经把结果状态更新为 failed // runGenerationSlot 已经把结果状态更新为 failed
} }
@@ -387,14 +390,14 @@ export default function ImagePage() {
<div> <div>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-stone-950 dark:text-stone-100"></h1> <h1 className="text-2xl font-semibold text-stone-950 dark:text-stone-100">{t("imageWorkbench.title")}</h1>
</div> </div>
<div className="flex shrink-0 gap-2 lg:hidden"> <div className="flex shrink-0 gap-2 lg:hidden">
<Button icon={<History className="size-4" />} onClick={() => setLogsOpen(true)}> <Button icon={<History className="size-4" />} onClick={() => setLogsOpen(true)}>
{t("workbench.logs")}
</Button> </Button>
<Button icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}> <Button icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>
{t("workbench.settings")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -403,28 +406,28 @@ export default function ImagePage() {
<div className="mt-6 space-y-5"> <div className="mt-6 space-y-5">
<div> <div>
<div className="mb-2 flex items-center justify-between gap-3"> <div className="mb-2 flex items-center justify-between gap-3">
<span className="text-base font-semibold"></span> <span className="text-base font-semibold">{t("workbench.prompt")}</span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="small" icon={<BookOpen className="size-3.5" />} onClick={() => setPromptDialogOpen(true)}> <Button size="small" icon={<BookOpen className="size-3.5" />} onClick={() => setPromptDialogOpen(true)}>
{t("workbench.viewPrompts")}
</Button> </Button>
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}> <Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}>
{t("workbench.viewAssets")}
</Button> </Button>
</div> </div>
</div> </div>
<Input.TextArea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={7} placeholder="描述画面主体、风格、构图、光线和用途" /> <Input.TextArea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={7} placeholder={t("imageWorkbench.promptPlaceholder")} />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-3"> <div className="mb-2 flex items-center justify-between gap-3">
<span className="text-base font-semibold"></span> <span className="text-base font-semibold">{t("imageWorkbench.references")}</span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="small" icon={<ClipboardPaste className="size-3.5" />} onClick={() => void addReferencesFromClipboard()}> <Button size="small" icon={<ClipboardPaste className="size-3.5" />} onClick={() => void addReferencesFromClipboard()}>
{t("workbench.clipboard")}
</Button> </Button>
<Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}> <Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}>
{t("workbench.upload")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -465,13 +468,13 @@ export default function ImagePage() {
type="button" type="button"
className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded bg-black/60 text-white group-hover:flex" className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded bg-black/60 text-white group-hover:flex"
onClick={() => setReferences((value) => value.filter((ref) => ref.id !== item.id))} onClick={() => setReferences((value) => value.filter((ref) => ref.id !== item.id))}
aria-label="移除参考图" aria-label={t("imageWorkbench.removeReference")}
> >
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</button> </button>
</div> </div>
))} ))}
{!references.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500">{isReferenceDragActive ? "松开即可添加参考图" : "暂无参考图,可将图片拖到这里"}</div> : null} {!references.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500">{isReferenceDragActive ? t("imageWorkbench.dropReferences") : t("imageWorkbench.noReferences")}</div> : null}
</div> </div>
</div> </div>
@@ -480,7 +483,7 @@ export default function ImagePage() {
{modelOptionLabel(effectiveConfig, model)} · {effectiveConfig.size} · {effectiveConfig.quality} {modelOptionLabel(effectiveConfig, model)} · {effectiveConfig.size} · {effectiveConfig.quality}
</span> </span>
<Button size="small" type="text" icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}> <Button size="small" type="text" icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>
{t("workbench.adjust")}
</Button> </Button>
</div> </div>
@@ -491,7 +494,7 @@ export default function ImagePage() {
<div className="mt-auto pt-6"> <div className="mt-auto pt-6">
<Button type="primary" size="large" block icon={<Sparkles className="size-4" />} loading={running} disabled={!canGenerate || running} onClick={() => void generate()}> <Button type="primary" size="large" block icon={<Sparkles className="size-4" />} loading={running} disabled={!canGenerate || running} onClick={() => void generate()}>
{t("workbench.generate")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -499,9 +502,9 @@ export default function ImagePage() {
<div className="thin-scrollbar rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:min-h-0 lg:overflow-y-auto lg:p-5"> <div className="thin-scrollbar rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:min-h-0 lg:overflow-y-auto lg:p-5">
<div className="mb-4 flex items-center justify-between gap-3"> <div className="mb-4 flex items-center justify-between gap-3">
<div> <div>
<h2 className="text-xl font-semibold"></h2> <h2 className="text-xl font-semibold">{t("workbench.results")}</h2>
</div> </div>
{running ? <Tag className="m-0 px-2 py-1"> {formatDuration(elapsedMs)}</Tag> : null} {running ? <Tag className="m-0 px-2 py-1">{t("workbench.waiting", { time: formatDuration(elapsedMs) })}</Tag> : null}
</div> </div>
{results.length ? ( {results.length ? (
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
@@ -509,7 +512,7 @@ export default function ImagePage() {
result.status === "success" && result.image ? ( result.status === "success" && result.image ? (
<ResultImageCard key={result.id} image={result.image} index={index} onEdit={addResultToReferences} onDownload={downloadImage} onSaveAsset={saveResultToAssets} /> <ResultImageCard key={result.id} image={result.image} index={index} onEdit={addResultToReferences} onDownload={downloadImage} onSaveAsset={saveResultToAssets} />
) : result.status === "failed" ? ( ) : result.status === "failed" ? (
<FailedImageCard key={result.id} error={result.error || "生成失败"} onRetry={() => retryResult(index)} /> <FailedImageCard key={result.id} error={result.error || t("workbench.generationFailed")} onRetry={() => retryResult(index)} />
) : ( ) : (
<PendingImageCard key={result.id} /> <PendingImageCard key={result.id} />
), ),
@@ -518,7 +521,7 @@ export default function ImagePage() {
) : ( ) : (
<div className="flex min-h-[320px] flex-col items-center justify-center rounded-lg border border-dashed border-stone-300 text-center dark:border-stone-700 lg:min-h-[560px]"> <div className="flex min-h-[320px] flex-col items-center justify-center rounded-lg border border-dashed border-stone-300 text-center dark:border-stone-700 lg:min-h-[560px]">
<ImagePlus className="mb-4 size-11 text-stone-400" /> <ImagePlus className="mb-4 size-11 text-stone-400" />
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有生成图片" /> <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("imageWorkbench.empty")} />
</div> </div>
)} )}
</div> </div>
@@ -535,7 +538,7 @@ export default function ImagePage() {
event.target.value = ""; event.target.value = "";
}} }}
/> />
<Drawer title="生成记录" placement="bottom" size="large" open={logsOpen} onClose={() => setLogsOpen(false)}> <Drawer title={t("workbench.logs")} placement="bottom" size="large" open={logsOpen} onClose={() => setLogsOpen(false)}>
<LogPanel <LogPanel
logs={logs} logs={logs}
selectedLogIds={selectedLogIds} selectedLogIds={selectedLogIds}
@@ -546,15 +549,15 @@ export default function ImagePage() {
onPreviewLog={(log) => void previewGenerationLog(log)} onPreviewLog={(log) => void previewGenerationLog(log)}
/> />
</Drawer> </Drawer>
<Drawer title="参数" placement="bottom" size="82vh" open={settingsOpen} onClose={() => setSettingsOpen(false)}> <Drawer title={t("workbench.settings")} placement="bottom" size="82vh" open={settingsOpen} onClose={() => setSettingsOpen(false)}>
<div className="grid grid-cols-2 gap-3 pb-4"> <div className="grid grid-cols-2 gap-3 pb-4">
<GenerationSettings config={effectiveConfig} model={model} updateConfig={updateConfig} openConfigDialog={openConfigDialog} /> <GenerationSettings config={effectiveConfig} model={model} updateConfig={updateConfig} openConfigDialog={openConfigDialog} />
</div> </div>
</Drawer> </Drawer>
<PromptSelectDialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen} onSelect={setPrompt} /> <PromptSelectDialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen} onSelect={setPrompt} />
<AssetPickerModal open={assetPickerOpen} defaultTab="my-assets" onInsert={(payload) => void insertPickedAsset(payload)} onClose={() => setAssetPickerOpen(false)} /> <AssetPickerModal open={assetPickerOpen} defaultTab="my-assets" onInsert={(payload) => void insertPickedAsset(payload)} onClose={() => setAssetPickerOpen(false)} />
<Modal title="删除生成记录" open={deleteConfirmOpen} onCancel={() => setDeleteConfirmOpen(false)} onOk={deleteSelectedLogs} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> <Modal title={t("workbench.deleteLogs")} open={deleteConfirmOpen} onCancel={() => setDeleteConfirmOpen(false)} onOk={deleteSelectedLogs} okText={t("common.delete")} okButtonProps={{ danger: true }} cancelText={t("common.cancel")}>
{selectedLogIds.length} {t("workbench.deleteLogsConfirm", { count: selectedLogIds.length })}
</Modal> </Modal>
</div> </div>
); );
@@ -562,11 +565,12 @@ export default function ImagePage() {
function GenerationSettings({ config, model, updateConfig, openConfigDialog }: { config: AiConfig; model: string; updateConfig: UpdateAiConfig; openConfigDialog: (shouldPromptContinue?: boolean) => void }) { function GenerationSettings({ config, model, updateConfig, openConfigDialog }: { config: AiConfig; model: string; updateConfig: UpdateAiConfig; openConfigDialog: (shouldPromptContinue?: boolean) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { t } = useTranslation();
return ( return (
<> <>
<label className="col-span-2 block min-w-0 sm:col-span-1"> <label className="col-span-2 block min-w-0 sm:col-span-1">
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base"></span> <span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base">{t("workbench.model")}</span>
<ModelPicker config={config} value={model} onChange={(value) => updateConfig("imageModel", value)} capability="image" fullWidth onMissingConfig={() => openConfigDialog(false)} /> <ModelPicker config={config} value={model} onChange={(value) => updateConfig("imageModel", value)} capability="image" fullWidth onMissingConfig={() => openConfigDialog(false)} />
</label> </label>
<div className="col-span-2"> <div className="col-span-2">
@@ -589,9 +593,10 @@ function ResultImageCard({
onDownload: (image: GeneratedImage, index: number) => void; onDownload: (image: GeneratedImage, index: number) => void;
onSaveAsset: (image: GeneratedImage, index: number) => void; onSaveAsset: (image: GeneratedImage, index: number) => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<div className="overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800"> <div className="overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
<Image src={image.dataUrl} alt={`生成结果 ${index + 1}`} className="aspect-square object-cover" /> <Image src={image.dataUrl} alt={t("imageWorkbench.resultAlt", { count: index + 1 })} className="aspect-square object-cover" />
<div className="space-y-2 border-t border-stone-200 px-3 py-2.5 dark:border-stone-800"> <div className="space-y-2 border-t border-stone-200 px-3 py-2.5 dark:border-stone-800">
<div className="flex min-w-0 gap-x-2 gap-y-1 text-xs text-stone-500 dark:text-stone-400"> <div className="flex min-w-0 gap-x-2 gap-y-1 text-xs text-stone-500 dark:text-stone-400">
<span> <span>
@@ -601,19 +606,19 @@ function ResultImageCard({
<span>{formatDuration(image.durationMs)}</span> <span>{formatDuration(image.durationMs)}</span>
</div> </div>
<div className="grid min-w-0 grid-cols-3 gap-2"> <div className="grid min-w-0 grid-cols-3 gap-2">
<Tooltip title="添加到资产"> <Tooltip title={t("common.addToAssets")}>
<Button className={RESULT_ACTION_BUTTON_CLASS} size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => void onSaveAsset(image, index)}> <Button className={RESULT_ACTION_BUTTON_CLASS} size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => void onSaveAsset(image, index)}>
{t("common.addToAssets")}
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip title="加入参考图"> <Tooltip title={t("imageWorkbench.addReference")}>
<Button className={RESULT_ACTION_BUTTON_CLASS} size="small" icon={<PenLine className="size-3.5" />} onClick={() => void onEdit(image, index)}> <Button className={RESULT_ACTION_BUTTON_CLASS} size="small" icon={<PenLine className="size-3.5" />} onClick={() => void onEdit(image, index)}>
{t("imageWorkbench.addReference")}
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip title="下载"> <Tooltip title={t("common.download")}>
<Button className={RESULT_ACTION_BUTTON_CLASS} size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(image, index)}> <Button className={RESULT_ACTION_BUTTON_CLASS} size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(image, index)}>
{t("common.download")}
</Button> </Button>
</Tooltip> </Tooltip>
</div> </div>
@@ -623,6 +628,7 @@ function ResultImageCard({
} }
function PendingImageCard() { function PendingImageCard() {
const { t } = useTranslation();
return ( return (
<div className="relative aspect-square overflow-hidden rounded-lg border border-dashed border-stone-300 bg-stone-50 dark:border-stone-700 dark:bg-stone-900"> <div className="relative aspect-square overflow-hidden rounded-lg border border-dashed border-stone-300 bg-stone-50 dark:border-stone-700 dark:bg-stone-900">
<div <div
@@ -634,24 +640,25 @@ function PendingImageCard() {
/> />
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-sm text-stone-500 dark:text-stone-400"> <div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-sm text-stone-500 dark:text-stone-400">
<LoaderCircle className="size-6 animate-spin" /> <LoaderCircle className="size-6 animate-spin" />
<span></span> <span>{t("workbench.generating")}</span>
</div> </div>
</div> </div>
); );
} }
function FailedImageCard({ error, onRetry }: { error: string; onRetry: () => void }) { function FailedImageCard({ error, onRetry }: { error: string; onRetry: () => void }) {
const { t } = useTranslation();
return ( return (
<div className="overflow-hidden rounded-lg border border-red-200 bg-red-50 dark:border-red-950 dark:bg-red-950/20"> <div className="overflow-hidden rounded-lg border border-red-200 bg-red-50 dark:border-red-950 dark:bg-red-950/20">
<div className="flex aspect-square flex-col items-center justify-center gap-3 p-5 text-center"> <div className="flex aspect-square flex-col items-center justify-center gap-3 p-5 text-center">
<div className="text-sm font-medium text-red-600 dark:text-red-300"></div> <div className="text-sm font-medium text-red-600 dark:text-red-300">{t("workbench.failed")}</div>
<Typography.Paragraph ellipsis={{ rows: 4 }} className="!mb-0 !text-xs !text-red-500 dark:!text-red-300"> <Typography.Paragraph ellipsis={{ rows: 4 }} className="!mb-0 !text-xs !text-red-500 dark:!text-red-300">
{error} {error}
</Typography.Paragraph> </Typography.Paragraph>
</div> </div>
<div className="flex justify-end border-t border-red-200 p-3 dark:border-red-950"> <div className="flex justify-end border-t border-red-200 p-3 dark:border-red-950">
<Button size="small" danger onClick={onRetry}> <Button size="small" danger onClick={onRetry}>
{t("workbench.retry")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -679,6 +686,7 @@ function LogPanel({
onDeleteSelected: () => void; onDeleteSelected: () => void;
onPreviewLog: (log: GenerationLog) => void; onPreviewLog: (log: GenerationLog) => void;
}) { }) {
const { t } = useTranslation();
const allSelected = Boolean(logs.length) && selectedLogIds.length === logs.length; const allSelected = Boolean(logs.length) && selectedLogIds.length === logs.length;
const toggleAll = () => onSelectedLogIdsChange(allSelected ? [] : logs.map((log) => log.id)); const toggleAll = () => onSelectedLogIdsChange(allSelected ? [] : logs.map((log) => log.id));
@@ -686,19 +694,19 @@ function LogPanel({
<> <>
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<div> <div>
<h2 className="text-base font-semibold"></h2> <h2 className="text-base font-semibold">{t("workbench.logs")}</h2>
</div> </div>
<Tag className="m-0">{logs.length}</Tag> <Tag className="m-0">{logs.length}</Tag>
</div> </div>
<div className="mb-4 flex flex-wrap gap-2"> <div className="mb-4 flex flex-wrap gap-2">
<Button size="small" icon={<Plus className="size-3.5" />} onClick={onCreateSession}> <Button size="small" icon={<Plus className="size-3.5" />} onClick={onCreateSession}>
{t("workbench.new")}
</Button> </Button>
<Button size="small" icon={<CheckSquare className="size-3.5" />} disabled={!logs.length} onClick={toggleAll}> <Button size="small" icon={<CheckSquare className="size-3.5" />} disabled={!logs.length} onClick={toggleAll}>
{allSelected ? "取消" : "全选"} {allSelected ? t("common.cancel") : t("workbench.selectAll")}
</Button> </Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} disabled={!selectedLogIds.length} onClick={onDeleteSelected}> <Button size="small" danger icon={<Trash2 className="size-3.5" />} disabled={!selectedLogIds.length} onClick={onDeleteSelected}>
{t("common.delete")}
</Button> </Button>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
@@ -712,13 +720,14 @@ function LogPanel({
onClick={() => onPreviewLog(log)} onClick={() => onPreviewLog(log)}
/> />
))} ))}
{!logs.length ? <div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed border-stone-300 text-center text-sm text-stone-500 dark:border-stone-700"></div> : null} {!logs.length ? <div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed border-stone-300 text-center text-sm text-stone-500 dark:border-stone-700">{t("workbench.noLogs")}</div> : null}
</div> </div>
</> </>
); );
} }
function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: GenerationLog; selected: boolean; active: boolean; onSelectedChange: (checked: boolean) => void; onClick: () => void }) { function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: GenerationLog; selected: boolean; active: boolean; onSelectedChange: (checked: boolean) => void; onClick: () => void }) {
const { t } = useTranslation();
const thumbnails = (log.thumbnails || []).filter(Boolean).slice(0, 4); const thumbnails = (log.thumbnails || []).filter(Boolean).slice(0, 4);
return ( return (
@@ -744,16 +753,16 @@ function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: Ge
<div className="grid justify-items-end gap-2"> <div className="grid justify-items-end gap-2">
<div className="flex gap-1"> <div className="flex gap-1">
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="blue"> <Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="blue">
{log.successCount ?? log.imageCount} {t("workbench.successCount", { count: log.successCount ?? log.imageCount })}
</Tag> </Tag>
{log.failCount ? ( {log.failCount ? (
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="red"> <Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="red">
{log.failCount} {t("workbench.failCount", { count: log.failCount })}
</Tag> </Tag>
) : null} ) : null}
</div> </div>
<div className="flex flex-wrap justify-end gap-1"> <div className="flex flex-wrap justify-end gap-1">
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.imageCount} </Tag> <Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{t("workbench.itemCount", { count: log.imageCount })}</Tag>
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="green"> <Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="green">
{formatDuration(log.durationMs)} {formatDuration(log.durationMs)}
</Tag> </Tag>
@@ -798,9 +807,9 @@ async function normalizeLog(log: Partial<GenerationLog>): Promise<GenerationLog>
return { return {
id: log.id || nanoid(), id: log.id || nanoid(),
createdAt: log.createdAt || Date.now(), createdAt: log.createdAt || Date.now(),
title: log.title || log.model || "未命名", title: log.title || log.model || i18n.t("workbench.untitled"),
prompt: log.prompt || log.title || "", prompt: log.prompt || log.title || "",
time: log.time || new Date().toLocaleString("zh-CN", { hour12: false }), time: log.time || new Date().toLocaleString(i18n.resolvedLanguage, { hour12: false }),
model: log.model || config.imageModel || "", model: log.model || config.imageModel || "",
config, config,
references, references,
@@ -810,7 +819,7 @@ async function normalizeLog(log: Partial<GenerationLog>): Promise<GenerationLog>
imageCount: log.imageCount || log.successCount || 0, imageCount: log.imageCount || log.successCount || 0,
size: log.size || config.size || "", size: log.size || config.size || "",
quality: log.quality || config.quality || "", quality: log.quality || config.quality || "",
status: log.status || "成功", status: log.status || "success",
images, images,
thumbnails: images.map((image) => image.dataUrl).filter(Boolean), thumbnails: images.map((image) => image.dataUrl).filter(Boolean),
}; };
@@ -884,9 +893,9 @@ function buildLog({
return { return {
id: nanoid(), id: nanoid(),
createdAt: Date.now(), createdAt: Date.now(),
title: prompt.slice(0, 12) || "未命名", title: prompt.slice(0, 12) || i18n.t("workbench.untitled"),
prompt, prompt,
time: new Date().toLocaleString("zh-CN", { hour12: false }), time: new Date().toLocaleString(i18n.resolvedLanguage, { hour12: false }),
model, model,
config: logConfig, config: logConfig,
references, references,
+5 -3
View File
@@ -1,18 +1,20 @@
import { Home } from "lucide-react"; import { Home } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
export default function NotFound() { export default function NotFound() {
const { t } = useTranslation();
return ( return (
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground"> <div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
<main className="flex h-full min-h-0 items-center justify-center overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-10 text-stone-900 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)] dark:text-stone-100"> <main className="flex h-full min-h-0 items-center justify-center overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-10 text-stone-900 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)] dark:text-stone-100">
<section className="w-full max-w-md text-center"> <section className="w-full max-w-md text-center">
<div className="mx-auto mb-6 flex size-16 items-center justify-center rounded-lg border border-stone-200 bg-white text-2xl font-semibold shadow-sm dark:border-stone-800 dark:bg-stone-900">404</div> <div className="mx-auto mb-6 flex size-16 items-center justify-center rounded-lg border border-stone-200 bg-white text-2xl font-semibold shadow-sm dark:border-stone-800 dark:bg-stone-900">404</div>
<h1 className="text-3xl font-semibold tracking-normal"></h1> <h1 className="text-3xl font-semibold tracking-normal">{t("notFound.title")}</h1>
<p className="mt-3 text-sm leading-6 text-stone-500 dark:text-stone-400"></p> <p className="mt-3 text-sm leading-6 text-stone-500 dark:text-stone-400">{t("notFound.description")}</p>
<div className="mt-8 flex flex-wrap justify-center gap-3"> <div className="mt-8 flex flex-wrap justify-center gap-3">
<Link to="/" className="inline-flex h-10 items-center gap-2 rounded-lg bg-stone-950 px-4 text-sm font-medium text-white transition hover:bg-stone-800 dark:bg-stone-100 dark:text-stone-950 dark:hover:bg-stone-200"> <Link to="/" className="inline-flex h-10 items-center gap-2 rounded-lg bg-stone-950 px-4 text-sm font-medium text-white transition hover:bg-stone-800 dark:bg-stone-100 dark:text-stone-950 dark:hover:bg-stone-200">
<Home className="size-4" /> <Home className="size-4" />
{t("notFound.home")}
</Link> </Link>
</div> </div>
</section> </section>
@@ -1,9 +1,12 @@
import { Copy, FileText, FolderPlus } from "lucide-react"; import { Copy, FileText, FolderPlus } from "lucide-react";
import { Button, Modal, Space, Tag } from "antd"; import { Button, Modal, Space, Tag } from "antd";
import { useTranslation } from "react-i18next";
import { formatPromptDate, type Prompt } from "@/services/api/prompts"; import { formatPromptDate, type Prompt } from "@/services/api/prompts";
export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { prompt: Prompt | null; onClose: () => void; onCopy: (prompt: string) => void; onSaveAsset?: (prompt: Prompt) => void }) { export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { prompt: Prompt | null; onClose: () => void; onCopy: (prompt: string) => void; onSaveAsset?: (prompt: Prompt) => void }) {
const { i18n, t } = useTranslation();
return ( return (
<Modal title={prompt?.title} open={Boolean(prompt)} onCancel={onClose} footer={null} width={720} centered styles={{ body: { height: "calc(85vh - 55px)", overflow: "hidden" } }}> <Modal title={prompt?.title} open={Boolean(prompt)} onCancel={onClose} footer={null} width={720} centered styles={{ body: { height: "calc(85vh - 55px)", overflow: "hidden" } }}>
{prompt ? ( {prompt ? (
@@ -23,16 +26,16 @@ export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { p
{prompt.description ? <p className="mt-4 text-sm leading-6 text-stone-500 dark:text-stone-400">{prompt.description}</p> : null} {prompt.description ? <p className="mt-4 text-sm leading-6 text-stone-500 dark:text-stone-400">{prompt.description}</p> : null}
{prompt.preview ? <pre className="mt-4 whitespace-pre-wrap rounded-lg bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{prompt.preview}</pre> : null} {prompt.preview ? <pre className="mt-4 whitespace-pre-wrap rounded-lg bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{prompt.preview}</pre> : null}
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-stone-800 dark:text-stone-300">{prompt.prompt}</p> <p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-stone-800 dark:text-stone-300">{prompt.prompt}</p>
{prompt.createdAt || prompt.updatedAt ? <div className="mt-4 text-xs text-stone-500 dark:text-stone-400">{prompt.createdAt ? `创建:${formatPromptDate(prompt.createdAt)}` : null}{prompt.createdAt && prompt.updatedAt ? " · " : null}{prompt.updatedAt ? `更新:${formatPromptDate(prompt.updatedAt)}` : null}</div> : null} {prompt.createdAt || prompt.updatedAt ? <div className="mt-4 text-xs text-stone-500 dark:text-stone-400">{prompt.createdAt ? t("common.created", { date: formatPromptDate(prompt.createdAt, i18n.resolvedLanguage) }) : null}{prompt.createdAt && prompt.updatedAt ? " · " : null}{prompt.updatedAt ? t("common.updated", { date: formatPromptDate(prompt.updatedAt, i18n.resolvedLanguage) }) : null}</div> : null}
</div> </div>
<div className="shrink-0 pt-4"> <div className="shrink-0 pt-4">
<Space wrap> <Space wrap>
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}> <Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}>
{t("common.copyPrompt")}
</Button> </Button>
{onSaveAsset ? ( {onSaveAsset ? (
<Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}> <Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}>
{t("common.addToAssets")}
</Button> </Button>
) : null} ) : null}
</Space> </Space>
+16 -14
View File
@@ -1,6 +1,7 @@
import { FolderPlus, Search } from "lucide-react"; import { FolderPlus, Search } from "lucide-react";
import { type ReactNode, type UIEvent, useEffect, useState } from "react"; import { type ReactNode, type UIEvent, useEffect, useState } from "react";
import { App, Button, Empty, Input, Spin, Tag } from "antd"; import { App, Button, Empty, Input, Spin, Tag } from "antd";
import { useTranslation } from "react-i18next";
import { PromptCard } from "@/components/prompts/prompt-card"; import { PromptCard } from "@/components/prompts/prompt-card";
import { usePromptList } from "@/components/prompts/use-prompt-list"; import { usePromptList } from "@/components/prompts/use-prompt-list";
@@ -12,6 +13,7 @@ import { ALL_PROMPTS_OPTION, type Prompt } from "@/services/api/prompts";
export default function PromptsPage() { export default function PromptsPage() {
const { message } = App.useApp(); const { message } = App.useApp();
const { t } = useTranslation();
const [titleKeyword, setTitleKeyword] = useState(""); const [titleKeyword, setTitleKeyword] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION); const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION);
@@ -21,8 +23,8 @@ export default function PromptsPage() {
const { query, items: promptItems, tags: promptTags, categories: promptCategoryOptions, total: totalPrompts } = usePromptList({ keyword: titleKeyword, tags: selectedTags, category: selectedCategory }); const { query, items: promptItems, tags: promptTags, categories: promptCategoryOptions, total: totalPrompts } = usePromptList({ keyword: titleKeyword, tags: selectedTags, category: selectedCategory });
useEffect(() => { useEffect(() => {
if (query.isError) message.error(query.error instanceof Error ? query.error.message : "获取提示词失败"); if (query.isError) message.error(query.error instanceof Error ? query.error.message : t("prompts.loadFailed"));
}, [message, query.error, query.isError]); }, [message, query.error, query.isError, t]);
const toggleTag = (tag: string) => { const toggleTag = (tag: string) => {
if (tag === ALL_PROMPTS_OPTION) return setSelectedTags([]); if (tag === ALL_PROMPTS_OPTION) return setSelectedTags([]);
@@ -31,7 +33,7 @@ export default function PromptsPage() {
const savePromptAsset = (item: Prompt) => { const savePromptAsset = (item: Prompt) => {
addAsset({ kind: "text", title: item.title, coverUrl: item.coverUrl, tags: item.tags, source: item.category, data: { content: item.prompt }, metadata: { source: "prompt-library", promptId: item.id, githubUrl: item.githubUrl } }); addAsset({ kind: "text", title: item.title, coverUrl: item.coverUrl, tags: item.tags, source: item.category, data: { content: item.prompt }, metadata: { source: "prompt-library", promptId: item.id, githubUrl: item.githubUrl } });
message.success("已加入我的资产"); message.success(t("common.addedToAssets"));
}; };
const handleListScroll = (event: UIEvent<HTMLDivElement>) => { const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
@@ -44,42 +46,42 @@ export default function PromptsPage() {
<main className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-4 py-6 [background-size:16px_16px] sm:px-6 lg:py-8 dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]" onScroll={handleListScroll}> <main className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-4 py-6 [background-size:16px_16px] sm:px-6 lg:py-8 dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]" onScroll={handleListScroll}>
<div className="mx-auto max-w-7xl"> <div className="mx-auto max-w-7xl">
<div className="text-center"> <div className="text-center">
<h1 className="text-2xl font-semibold text-stone-950 dark:text-stone-100"></h1> <h1 className="text-2xl font-semibold text-stone-950 dark:text-stone-100">{t("prompts.title")}</h1>
<p className="mt-1 text-sm text-stone-500 dark:text-stone-400"> {totalPrompts} </p> <p className="mt-1 text-sm text-stone-500 dark:text-stone-400">{t("prompts.total", { count: totalPrompts })}</p>
</div> </div>
<div className="mt-5 grid items-start gap-5 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-6"> <div className="mt-5 grid items-start gap-5 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-6">
<aside className="thin-scrollbar max-h-72 overflow-y-auto border-b border-stone-200 pb-5 lg:sticky lg:top-0 lg:max-h-[calc(100dvh-6rem)] lg:border-b-0 lg:border-r lg:pb-8 lg:pr-5 dark:border-stone-800"> <aside className="thin-scrollbar max-h-72 overflow-y-auto border-b border-stone-200 pb-5 lg:sticky lg:top-0 lg:max-h-[calc(100dvh-6rem)] lg:border-b-0 lg:border-r lg:pb-8 lg:pr-5 dark:border-stone-800">
<PromptFilter label="分类" options={promptCategoryOptions} selected={selectedCategory} onChange={setSelectedCategory} /> <PromptFilter label={t("prompts.category")} options={promptCategoryOptions} selected={selectedCategory} onChange={setSelectedCategory} />
<div className="mt-6"> <div className="mt-6">
<div className="mb-2 text-xs font-semibold uppercase tracking-widest text-stone-400 dark:text-stone-500"></div> <div className="mb-2 text-xs font-semibold uppercase tracking-widest text-stone-400 dark:text-stone-500">{t("prompts.tags")}</div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{promptTags.map((tag) => { {promptTags.map((tag) => {
const active = tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag); const active = tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag);
return <Tag.CheckableTag key={tag} checked={active} className={cn("prompt-filter-tag", active && "is-active")} onChange={() => toggleTag(tag)}>{tag}</Tag.CheckableTag>; return <Tag.CheckableTag key={tag} checked={active} className={cn("prompt-filter-tag", active && "is-active")} onChange={() => toggleTag(tag)}>{tag === ALL_PROMPTS_OPTION ? t("common.all") : tag}</Tag.CheckableTag>;
})} })}
</div> </div>
</div> </div>
</aside> </aside>
<section className="min-w-0"> <section className="min-w-0">
<Input size="large" prefix={<Search className="size-4 text-stone-400" />} value={titleKeyword} placeholder="搜索标题、内容或标签" onChange={(event) => setTitleKeyword(event.target.value)} /> <Input size="large" prefix={<Search className="size-4 text-stone-400" />} value={titleKeyword} placeholder={t("prompts.search")} onChange={(event) => setTitleKeyword(event.target.value)} />
{query.isLoading ? <div className="flex h-60 items-center justify-center"><Spin /></div> : null} {query.isLoading ? <div className="flex h-60 items-center justify-center"><Spin /></div> : null}
{!query.isLoading ? <div className="mt-5"><PromptGrid items={promptItems} onOpen={setSelectedPrompt} renderActions={(item) => <Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}></Button>} onCopy={(item) => copyText(item.prompt, "提示词已复制")} emptyText="没有找到匹配的提示词" /></div> : null} {!query.isLoading ? <div className="mt-5"><PromptGrid items={promptItems} onOpen={setSelectedPrompt} renderActions={(item) => <Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}>{t("common.addToAssets")}</Button>} onCopy={(item) => copyText(item.prompt, t("common.promptCopied"))} emptyText={t("prompts.empty")} /></div> : null}
<div className="mt-6 text-center text-xs text-stone-500 dark:text-stone-400">{query.isFetchingNextPage ? "加载中..." : query.hasNextPage ? "继续向下滚动加载更多" : promptItems.length > 0 ? "已经到底了" : null}</div> <div className="mt-6 text-center text-xs text-stone-500 dark:text-stone-400">{query.isFetchingNextPage ? t("prompts.loading") : query.hasNextPage ? t("prompts.loadMore") : promptItems.length > 0 ? t("prompts.end") : null}</div>
</section> </section>
</div> </div>
</div> </div>
</main> </main>
<PromptDetailDialog prompt={selectedPrompt} onClose={() => setSelectedPrompt(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={savePromptAsset} /> <PromptDetailDialog prompt={selectedPrompt} onClose={() => setSelectedPrompt(null)} onCopy={(prompt) => copyText(prompt, t("common.promptCopied"))} onSaveAsset={savePromptAsset} />
</div> </div>
); );
} }
function PromptFilter({ label, options, selected, onChange }: { label: string; options: string[]; selected: string; onChange: (value: string) => void }) { function PromptFilter({ label, options, selected, onChange }: { label: string; options: string[]; selected: string; onChange: (value: string) => void }) {
return <div><div className="mb-2 text-xs font-semibold uppercase tracking-widest text-stone-400 dark:text-stone-500">{label}</div><div className="flex flex-wrap gap-1.5">{options.map((option) => <Tag.CheckableTag key={option} checked={selected === option} className={cn("prompt-filter-tag", selected === option && "is-active")} onChange={() => onChange(option)}>{option}</Tag.CheckableTag>)}</div></div>; const { t } = useTranslation();
return <div><div className="mb-2 text-xs font-semibold uppercase tracking-widest text-stone-400 dark:text-stone-500">{label}</div><div className="flex flex-wrap gap-1.5">{options.map((option) => <Tag.CheckableTag key={option} checked={selected === option} className={cn("prompt-filter-tag", selected === option && "is-active")} onChange={() => onChange(option)}>{option === ALL_PROMPTS_OPTION ? t("common.all") : option}</Tag.CheckableTag>)}</div></div>;
} }
function PromptGrid({ items, onOpen, onCopy, renderActions, emptyText }: { items: Prompt[]; onOpen: (item: Prompt) => void; onCopy: (item: Prompt) => void; renderActions: (item: Prompt) => ReactNode; emptyText: string }) { function PromptGrid({ items, onOpen, onCopy, renderActions, emptyText }: { items: Prompt[]; onOpen: (item: Prompt) => void; onCopy: (item: Prompt) => void; renderActions: (item: Prompt) => ReactNode; emptyText: string }) {
return <div><div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">{items.map((item) => <PromptCard key={`${item.sourceId}:${item.id}`} item={item} onOpen={() => onOpen(item)} onCopy={() => onCopy(item)} extraAction={renderActions(item)} />)}</div>{items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyText} className="py-16" /> : null}</div>; return <div><div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">{items.map((item) => <PromptCard key={`${item.sourceId}:${item.id}`} item={item} onOpen={() => onOpen(item)} onCopy={() => onCopy(item)} extraAction={renderActions(item)} />)}</div>{items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyText} className="py-16" /> : null}</div>;
} }
+84 -75
View File
@@ -4,6 +4,7 @@ import { App, Button, Checkbox, Drawer, Empty, Input, Modal, Tag, Typography } f
import localforage from "localforage"; import localforage from "localforage";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { saveAs } from "file-saver"; import { saveAs } from "file-saver";
import { useTranslation } from "react-i18next";
import { AssetPickerModal, type InsertAssetPayload } from "@/components/canvas/asset-picker-modal"; import { AssetPickerModal, type InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
import { ModelPicker } from "@/components/model-picker"; import { ModelPicker } from "@/components/model-picker";
@@ -21,6 +22,7 @@ import { modelOptionLabel, useConfigStore, useEffectiveConfig, type AiConfig } f
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
import type { ReferenceImage } from "@/types/image"; import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media"; import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
import i18n from "@/i18n";
type GeneratedVideo = { type GeneratedVideo = {
id: string; id: string;
@@ -55,7 +57,7 @@ type GenerationLog = {
size: string; size: string;
resolution: string; resolution: string;
seconds: string; seconds: string;
status: "生成中" | "成功" | "失败"; status: "pending" | "success" | "failed";
task?: VideoGenerationTask; task?: VideoGenerationTask;
video?: GeneratedVideo; video?: GeneratedVideo;
error?: string; error?: string;
@@ -70,6 +72,7 @@ const logStore = localforage.createInstance({ name: "infinite-canvas", storeName
export default function VideoPage() { export default function VideoPage() {
const { message } = App.useApp(); const { message } = App.useApp();
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0); const dragDepthRef = useRef(0);
const activeLogIdsRef = useRef<Set<string>>(new Set()); const activeLogIdsRef = useRef<Set<string>>(new Set());
@@ -119,13 +122,13 @@ export default function VideoPage() {
const addReferences = async (files?: FileList | null) => { const addReferences = async (files?: FileList | null) => {
const selectedFiles = Array.from(files || []); const selectedFiles = Array.from(files || []);
const unsupported = selectedFiles.filter((file) => !file.type.startsWith("image/") && !SEEDANCE_VIDEO_MIME_TYPES.includes(file.type) && !isSupportedAudioFile(file)); const unsupported = selectedFiles.filter((file) => !file.type.startsWith("image/") && !SEEDANCE_VIDEO_MIME_TYPES.includes(file.type) && !isSupportedAudioFile(file));
if (unsupported.length) message.warning("已忽略不支持的参考资产,请使用图片、mp4/mov 视频或 mp3/wav 音频"); if (unsupported.length) message.warning(t("videoWorkbench.unsupportedFiles"));
const imageFiles = selectedFiles.filter((file) => file.type.startsWith("image/") && file.size <= SEEDANCE_REFERENCE_LIMITS.imageMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.images - references.length); const imageFiles = selectedFiles.filter((file) => file.type.startsWith("image/") && file.size <= SEEDANCE_REFERENCE_LIMITS.imageMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.images - references.length);
const videoFiles = selectedFiles.filter((file) => SEEDANCE_VIDEO_MIME_TYPES.includes(file.type) && file.size <= SEEDANCE_REFERENCE_LIMITS.videoMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.videos - videoReferences.length); const videoFiles = selectedFiles.filter((file) => SEEDANCE_VIDEO_MIME_TYPES.includes(file.type) && file.size <= SEEDANCE_REFERENCE_LIMITS.videoMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.videos - videoReferences.length);
const audioFiles = selectedFiles.filter((file) => isSupportedAudioFile(file) && file.size <= SEEDANCE_REFERENCE_LIMITS.audioMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.audios - audioReferences.length); const audioFiles = selectedFiles.filter((file) => isSupportedAudioFile(file) && file.size <= SEEDANCE_REFERENCE_LIMITS.audioMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.audios - audioReferences.length);
if (selectedFiles.some((file) => file.type.startsWith("image/") && file.size > SEEDANCE_REFERENCE_LIMITS.imageMaxBytes)) message.warning("已忽略超过 30MB 的参考图"); if (selectedFiles.some((file) => file.type.startsWith("image/") && file.size > SEEDANCE_REFERENCE_LIMITS.imageMaxBytes)) message.warning(t("videoWorkbench.imageTooLarge"));
if (selectedFiles.some((file) => SEEDANCE_VIDEO_MIME_TYPES.includes(file.type) && file.size > SEEDANCE_REFERENCE_LIMITS.videoMaxBytes)) message.warning("已忽略超过 200MB 的参考视频"); if (selectedFiles.some((file) => SEEDANCE_VIDEO_MIME_TYPES.includes(file.type) && file.size > SEEDANCE_REFERENCE_LIMITS.videoMaxBytes)) message.warning(t("videoWorkbench.videoTooLarge"));
if (selectedFiles.some((file) => isSupportedAudioFile(file) && file.size > SEEDANCE_REFERENCE_LIMITS.audioMaxBytes)) message.warning("已忽略超过 15MB 的参考音频"); if (selectedFiles.some((file) => isSupportedAudioFile(file) && file.size > SEEDANCE_REFERENCE_LIMITS.audioMaxBytes)) message.warning(t("videoWorkbench.audioTooLarge"));
const nextReferences = await Promise.all( const nextReferences = await Promise.all(
imageFiles.map(async (file) => { imageFiles.map(async (file) => {
const image = await uploadImage(file); const image = await uploadImage(file);
@@ -177,7 +180,7 @@ export default function VideoPage() {
const items = await navigator.clipboard.read(); const items = await navigator.clipboard.read();
const blobs = await Promise.all(items.flatMap((item) => item.types.filter((type) => type.startsWith("image/")).map((type) => item.getType(type)))); const blobs = await Promise.all(items.flatMap((item) => item.types.filter((type) => type.startsWith("image/")).map((type) => item.getType(type))));
if (!blobs.length) { if (!blobs.length) {
message.error("剪切板里没有可读取的图片"); message.error(t("videoWorkbench.clipboardEmpty"));
return; return;
} }
const nextReferences = await Promise.all( const nextReferences = await Promise.all(
@@ -187,9 +190,9 @@ export default function VideoPage() {
}), }),
); );
setReferences((value) => [...value, ...nextReferences].slice(0, SEEDANCE_REFERENCE_LIMITS.images)); setReferences((value) => [...value, ...nextReferences].slice(0, SEEDANCE_REFERENCE_LIMITS.images));
message.success(`已读取 ${nextReferences.length} 张参考图`); message.success(t("videoWorkbench.clipboardAdded", { count: nextReferences.length }));
} catch { } catch {
message.error("剪切板里没有可读取的图片"); message.error(t("videoWorkbench.clipboardEmpty"));
} }
}; };
const generate = async () => { const generate = async () => {
@@ -197,7 +200,7 @@ export default function VideoPage() {
agentTaskIdRef.current = undefined; agentTaskIdRef.current = undefined;
const snapshot = buildRequestSnapshot(); const snapshot = buildRequestSnapshot();
if (!snapshot) { if (!snapshot) {
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: "视频生成参数无效" }); if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: t("videoWorkbench.invalidParams") });
return; return;
} }
setElapsedMs(0); setElapsedMs(0);
@@ -209,14 +212,14 @@ export default function VideoPage() {
setStartedAt(batchStartedAt); setStartedAt(batchStartedAt);
try { try {
const task = await createVideoGenerationTask(snapshot.config, snapshot.text, snapshot.references, snapshot.videoReferences, snapshot.audioReferences); const task = await createVideoGenerationTask(snapshot.config, snapshot.text, snapshot.references, snapshot.videoReferences, snapshot.audioReferences);
const log = buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: 0, status: "生成中", task }); const log = buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: 0, status: "pending", task });
await saveLog(log, false); await saveLog(log, false);
void pollGenerationLog(log, snapshot.config, agentTaskId); void pollGenerationLog(log, snapshot.config, agentTaskId);
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : "生成失败"; const errorMessage = error instanceof Error ? error.message : t("workbench.generationFailed");
setResults([{ id: nanoid(), status: "failed", error: errorMessage }]); setResults([{ id: nanoid(), status: "failed", error: errorMessage }]);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", successCount: 0, failCount: 1, error: errorMessage }); if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", successCount: 0, failCount: 1, error: errorMessage });
await saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage })); await saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: performance.now() - batchStartedAt, status: "failed", error: errorMessage }));
message.error(errorMessage); message.error(errorMessage);
setRunning(false); setRunning(false);
} }
@@ -229,7 +232,7 @@ export default function VideoPage() {
clearVideoCommand(); clearVideoCommand();
if (typeof videoCommand.prompt === "string") setPrompt(videoCommand.prompt); if (typeof videoCommand.prompt === "string") setPrompt(videoCommand.prompt);
if (videoCommand.run && running) { if (videoCommand.run && running) {
if (videoCommand.taskId) updateAgentTask(videoCommand.taskId, { status: "failed", error: "视频工作台已有任务正在运行" }); if (videoCommand.taskId) updateAgentTask(videoCommand.taskId, { status: "failed", error: t("videoWorkbench.busy") });
return; return;
} }
if (videoCommand.run) { if (videoCommand.run) {
@@ -247,17 +250,17 @@ export default function VideoPage() {
const buildRequestSnapshot = () => { const buildRequestSnapshot = () => {
const text = prompt.trim(); const text = prompt.trim();
if (!text) { if (!text) {
message.error("请输入视频提示词"); message.error(t("videoWorkbench.promptRequired"));
return null; return null;
} }
if (!isAiConfigReady(effectiveConfig, model)) { if (!isAiConfigReady(effectiveConfig, model)) {
message.warning("请先完成配置"); message.warning(t("workbench.configFirst"));
openConfigDialog(true); openConfigDialog(true);
return null; return null;
} }
const videoReferenceError = seedanceVideoReferenceError(videoReferences); const videoReferenceError = seedanceVideoReferenceError(videoReferences);
if (videoReferenceError) { if (videoReferenceError) {
message.error(`${videoReferenceError}${seedanceVideoReferenceHint}`); message.error(t("videoWorkbench.referenceError", { error: videoReferenceError, hint: seedanceVideoReferenceHint() }));
return null; return null;
} }
return { text, config: buildVideoConfig(effectiveConfig, model), references: [...references], videoReferences: [...videoReferences], audioReferences: [...audioReferences] }; return { text, config: buildVideoConfig(effectiveConfig, model), references: [...references], videoReferences: [...videoReferences], audioReferences: [...audioReferences] };
@@ -274,14 +277,14 @@ export default function VideoPage() {
const saveResultToAssets = (video: GeneratedVideo) => { const saveResultToAssets = (video: GeneratedVideo) => {
addAsset({ addAsset({
kind: "video", kind: "video",
title: "生成视频", title: t("videoWorkbench.resultTitle"),
coverUrl: "", coverUrl: "",
tags: [], tags: [],
source: "视频创作台", source: t("videoWorkbench.source"),
data: { url: video.url, storageKey: video.storageKey, width: video.width, height: video.height, bytes: video.bytes, mimeType: video.mimeType }, data: { url: video.url, storageKey: video.storageKey, width: video.width, height: video.height, bytes: video.bytes, mimeType: video.mimeType },
metadata: { source: "video-page", prompt }, metadata: { source: "video-page", prompt },
}); });
message.success("已加入我的资产"); message.success(t("common.addedToAssets"));
}; };
const insertPickedAsset = async (payload: InsertAssetPayload) => { const insertPickedAsset = async (payload: InsertAssetPayload) => {
@@ -336,7 +339,7 @@ export default function VideoPage() {
const resumePendingLogs = (items: GenerationLog[]) => { const resumePendingLogs = (items: GenerationLog[]) => {
for (const log of items) { for (const log of items) {
if (log.status === "生成中" && log.task) void pollGenerationLog(log); if (log.status === "pending" && log.task) void pollGenerationLog(log);
} }
}; };
@@ -364,19 +367,19 @@ export default function VideoPage() {
}; };
setResults([{ id: nextVideo.id, status: "success", video: nextVideo }]); setResults([{ id: nextVideo.id, status: "success", video: nextVideo }]);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "succeeded", successCount: 1, failCount: 0, error: undefined }); if (agentTaskId) updateAgentTask(agentTaskId, { status: "succeeded", successCount: 1, failCount: 0, error: undefined });
await saveLog({ ...log, status: "成功", durationMs: nextVideo.durationMs, video: nextVideo, error: undefined }); await saveLog({ ...log, status: "success", durationMs: nextVideo.durationMs, video: nextVideo, error: undefined });
message.success("视频已生成"); message.success(t("videoWorkbench.generated"));
return; return;
} }
if (state.status === "failed") throw new Error(state.error); if (state.status === "failed") throw new Error(state.error);
if (attempt === 119) throw new Error("视频生成超时,请稍后重试"); if (attempt === 119) throw new Error(t("videoWorkbench.timeout"));
await delay(log.task.provider === "seedance" ? 5000 : 2500); await delay(log.task.provider === "seedance" ? 5000 : 2500);
} }
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : "生成失败"; const errorMessage = error instanceof Error ? error.message : t("workbench.generationFailed");
setResults([{ id: log.id, status: "failed", error: errorMessage }]); setResults([{ id: log.id, status: "failed", error: errorMessage }]);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", successCount: 0, failCount: 1, error: errorMessage }); if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", successCount: 0, failCount: 1, error: errorMessage });
await saveLog({ ...log, status: "失败", durationMs: Date.now() - log.createdAt, error: errorMessage }); await saveLog({ ...log, status: "failed", durationMs: Date.now() - log.createdAt, error: errorMessage });
message.error(errorMessage); message.error(errorMessage);
} finally { } finally {
activeLogIdsRef.current.delete(log.id); activeLogIdsRef.current.delete(log.id);
@@ -400,7 +403,7 @@ export default function VideoPage() {
if (log.config.videoSeconds) updateConfig("videoSeconds", log.config.videoSeconds); if (log.config.videoSeconds) updateConfig("videoSeconds", log.config.videoSeconds);
if (log.config.videoGenerateAudio) updateConfig("videoGenerateAudio", log.config.videoGenerateAudio); if (log.config.videoGenerateAudio) updateConfig("videoGenerateAudio", log.config.videoGenerateAudio);
if (log.config.videoWatermark) updateConfig("videoWatermark", log.config.videoWatermark); if (log.config.videoWatermark) updateConfig("videoWatermark", log.config.videoWatermark);
setResults(log.status === "生成中" ? [{ id: log.id, status: "pending" }] : log.video ? [{ id: log.video.id, status: "success", video: log.video }] : [{ id: log.id, status: "failed", error: log.error || "生成失败" }]); setResults(log.status === "pending" ? [{ id: log.id, status: "pending" }] : log.video ? [{ id: log.video.id, status: "success", video: log.video }] : [{ id: log.id, status: "failed", error: log.error || t("workbench.generationFailed") }]);
}; };
return ( return (
@@ -413,13 +416,13 @@ export default function VideoPage() {
<section className="grid gap-3 lg:min-h-0 lg:overflow-hidden xl:grid-cols-[420px_minmax(0,1fr)]"> <section className="grid gap-3 lg:min-h-0 lg:overflow-hidden xl:grid-cols-[420px_minmax(0,1fr)]">
<div className="thin-scrollbar flex flex-col rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:min-h-0 lg:overflow-y-auto"> <div className="thin-scrollbar flex flex-col rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:min-h-0 lg:overflow-y-auto">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<h1 className="text-2xl font-semibold text-stone-950 dark:text-stone-100"></h1> <h1 className="text-2xl font-semibold text-stone-950 dark:text-stone-100">{t("videoWorkbench.title")}</h1>
<div className="flex shrink-0 gap-2 lg:hidden"> <div className="flex shrink-0 gap-2 lg:hidden">
<Button icon={<History className="size-4" />} onClick={() => setLogsOpen(true)}> <Button icon={<History className="size-4" />} onClick={() => setLogsOpen(true)}>
{t("workbench.logs")}
</Button> </Button>
<Button icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}> <Button icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>
{t("workbench.settings")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -427,28 +430,28 @@ export default function VideoPage() {
<div className="mt-6 space-y-5"> <div className="mt-6 space-y-5">
<div> <div>
<div className="mb-2 flex items-center justify-between gap-3"> <div className="mb-2 flex items-center justify-between gap-3">
<span className="text-base font-semibold"></span> <span className="text-base font-semibold">{t("workbench.prompt")}</span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="small" icon={<BookOpen className="size-3.5" />} onClick={() => setPromptDialogOpen(true)}> <Button size="small" icon={<BookOpen className="size-3.5" />} onClick={() => setPromptDialogOpen(true)}>
{t("workbench.viewPrompts")}
</Button> </Button>
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}> <Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}>
{t("workbench.viewAssets")}
</Button> </Button>
</div> </div>
</div> </div>
<Input.TextArea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={7} placeholder="描述镜头运动、主体动作、场景氛围和画面风格" /> <Input.TextArea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={7} placeholder={t("videoWorkbench.promptPlaceholder")} />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-3"> <div className="mb-2 flex items-center justify-between gap-3">
<span className="text-base font-semibold"></span> <span className="text-base font-semibold">{t("videoWorkbench.references")}</span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="small" icon={<ClipboardPaste className="size-3.5" />} onClick={() => void addReferencesFromClipboard()}> <Button size="small" icon={<ClipboardPaste className="size-3.5" />} onClick={() => void addReferencesFromClipboard()}>
{t("workbench.clipboard")}
</Button> </Button>
<Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}> <Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}>
{t("workbench.upload")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -467,20 +470,20 @@ export default function VideoPage() {
<img src={item.dataUrl} alt={item.name} className="size-full object-cover" /> <img src={item.dataUrl} alt={item.name} className="size-full object-cover" />
<span className="absolute left-1 top-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white">{seedanceReferenceLabel("image", index)}</span> <span className="absolute left-1 top-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white">{seedanceReferenceLabel("image", index)}</span>
<ReferenceOrderButtons index={index} total={references.length} onMove={(offset) => setReferences((value) => moveListItem(value, index, offset))} /> <ReferenceOrderButtons index={index} total={references.length} onMove={(offset) => setReferences((value) => moveListItem(value, index, offset))} />
<button type="button" className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded bg-black/60 text-white group-hover:flex" onClick={() => setReferences((value) => value.filter((ref) => ref.id !== item.id))} aria-label="移除参考图"> <button type="button" className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded bg-black/60 text-white group-hover:flex" onClick={() => setReferences((value) => value.filter((ref) => ref.id !== item.id))} aria-label={t("videoWorkbench.removeImage")}>
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</button> </button>
</div> </div>
))} ))}
{!references.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500">{referenceDragTarget === "image" ? "松开即可上传参考资产" : "暂无参考图,可拖入文件,最多 9 张"}</div> : null} {!references.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500">{referenceDragTarget === "image" ? t("videoWorkbench.dropReferences") : t("videoWorkbench.noImages")}</div> : null}
</div> </div>
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-3"> <div className="mb-2 flex items-center justify-between gap-3">
<span className="text-base font-semibold"></span> <span className="text-base font-semibold">{t("videoWorkbench.videoReferences")}</span>
<Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}> <Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}>
{t("workbench.upload")}
</Button> </Button>
</div> </div>
<div <div
@@ -498,20 +501,20 @@ export default function VideoPage() {
<video src={item.url} className="size-full object-cover" muted preload="metadata" /> <video src={item.url} className="size-full object-cover" muted preload="metadata" />
<span className="absolute left-1 top-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white">{seedanceReferenceLabel("video", index)}</span> <span className="absolute left-1 top-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white">{seedanceReferenceLabel("video", index)}</span>
<ReferenceOrderButtons index={index} total={videoReferences.length} onMove={(offset) => setVideoReferences((value) => moveListItem(value, index, offset))} /> <ReferenceOrderButtons index={index} total={videoReferences.length} onMove={(offset) => setVideoReferences((value) => moveListItem(value, index, offset))} />
<button type="button" className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded bg-black/60 text-white group-hover:flex" onClick={() => setVideoReferences((value) => value.filter((ref) => ref.id !== item.id))} aria-label="移除参考视频"> <button type="button" className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded bg-black/60 text-white group-hover:flex" onClick={() => setVideoReferences((value) => value.filter((ref) => ref.id !== item.id))} aria-label={t("videoWorkbench.removeVideo")}>
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</button> </button>
</div> </div>
))} ))}
{!videoReferences.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500">{referenceDragTarget === "video" ? "松开即可上传参考资产" : "暂无参考视频,可拖入文件,最多 3 个"}</div> : null} {!videoReferences.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500">{referenceDragTarget === "video" ? t("videoWorkbench.dropReferences") : t("videoWorkbench.noVideos")}</div> : null}
</div> </div>
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-3"> <div className="mb-2 flex items-center justify-between gap-3">
<span className="text-base font-semibold"></span> <span className="text-base font-semibold">{t("videoWorkbench.audioReferences")}</span>
<Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}> <Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}>
{t("workbench.upload")}
</Button> </Button>
</div> </div>
<div <div
@@ -533,12 +536,12 @@ export default function VideoPage() {
</div> </div>
<audio src={item.url} controls className="h-8 w-full" preload="metadata" /> <audio src={item.url} controls className="h-8 w-full" preload="metadata" />
<ReferenceOrderButtons index={index} total={audioReferences.length} onMove={(offset) => setAudioReferences((value) => moveListItem(value, index, offset))} /> <ReferenceOrderButtons index={index} total={audioReferences.length} onMove={(offset) => setAudioReferences((value) => moveListItem(value, index, offset))} />
<button type="button" className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded bg-black/60 text-white group-hover:flex" onClick={() => setAudioReferences((value) => value.filter((ref) => ref.id !== item.id))} aria-label="移除参考音频"> <button type="button" className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded bg-black/60 text-white group-hover:flex" onClick={() => setAudioReferences((value) => value.filter((ref) => ref.id !== item.id))} aria-label={t("videoWorkbench.removeAudio")}>
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</button> </button>
</div> </div>
))} ))}
{!audioReferences.length ? <div className="flex min-w-full items-center justify-center text-center text-sm text-stone-500">{referenceDragTarget === "audio" ? "松开即可上传参考资产" : "暂无参考音频,可拖入文件,最多 3 个,mp3/wav,单个 15MB 内"}</div> : null} {!audioReferences.length ? <div className="flex min-w-full items-center justify-center text-center text-sm text-stone-500">{referenceDragTarget === "audio" ? t("videoWorkbench.dropReferences") : t("videoWorkbench.noAudio")}</div> : null}
</div> </div>
</div> </div>
@@ -547,7 +550,7 @@ export default function VideoPage() {
{modelOptionLabel(effectiveConfig, model)} · {normalizeResolution(effectiveConfig.vquality)}p · {videoSizeLabel(effectiveConfig.size)} · {normalizeVideoSeconds(effectiveConfig.videoSeconds)}s {modelOptionLabel(effectiveConfig, model)} · {normalizeResolution(effectiveConfig.vquality)}p · {videoSizeLabel(effectiveConfig.size)} · {normalizeVideoSeconds(effectiveConfig.videoSeconds)}s
</span> </span>
<Button size="small" type="text" icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}> <Button size="small" type="text" icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>
{t("workbench.adjust")}
</Button> </Button>
</div> </div>
@@ -558,24 +561,24 @@ export default function VideoPage() {
<div className="mt-auto pt-6"> <div className="mt-auto pt-6">
<Button type="primary" size="large" block icon={<Sparkles className="size-4" />} loading={running} disabled={!canGenerate || running} onClick={() => void generate()}> <Button type="primary" size="large" block icon={<Sparkles className="size-4" />} loading={running} disabled={!canGenerate || running} onClick={() => void generate()}>
{t("workbench.generate")}
</Button> </Button>
</div> </div>
</div> </div>
<div className="thin-scrollbar rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:min-h-0 lg:overflow-y-auto lg:p-5"> <div className="thin-scrollbar rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:min-h-0 lg:overflow-y-auto lg:p-5">
<div className="mb-4 flex items-center justify-between gap-3"> <div className="mb-4 flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold"></h2> <h2 className="text-xl font-semibold">{t("workbench.results")}</h2>
{running ? <Tag className="m-0 px-2 py-1"> {formatDuration(elapsedMs)}</Tag> : null} {running ? <Tag className="m-0 px-2 py-1">{t("workbench.waiting", { time: formatDuration(elapsedMs) })}</Tag> : null}
</div> </div>
{results.length ? ( {results.length ? (
<div className="grid gap-4"> <div className="grid gap-4">
{results.map((result) => (result.status === "success" && result.video ? <ResultVideoCard key={result.id} video={result.video} onDownload={downloadVideo} onSaveAsset={saveResultToAssets} /> : result.status === "failed" ? <FailedVideoCard key={result.id} error={result.error || "生成失败"} onRetry={retryResult} /> : <PendingVideoCard key={result.id} />))} {results.map((result) => (result.status === "success" && result.video ? <ResultVideoCard key={result.id} video={result.video} onDownload={downloadVideo} onSaveAsset={saveResultToAssets} /> : result.status === "failed" ? <FailedVideoCard key={result.id} error={result.error || t("workbench.generationFailed")} onRetry={retryResult} /> : <PendingVideoCard key={result.id} />))}
</div> </div>
) : ( ) : (
<div className="flex min-h-[320px] flex-col items-center justify-center rounded-lg border border-dashed border-stone-300 text-center dark:border-stone-700 lg:min-h-[560px]"> <div className="flex min-h-[320px] flex-col items-center justify-center rounded-lg border border-dashed border-stone-300 text-center dark:border-stone-700 lg:min-h-[560px]">
<VideoIcon className="mb-4 size-11 text-stone-400" /> <VideoIcon className="mb-4 size-11 text-stone-400" />
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有生成视频" /> <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("videoWorkbench.empty")} />
</div> </div>
)} )}
</div> </div>
@@ -592,18 +595,18 @@ export default function VideoPage() {
event.target.value = ""; event.target.value = "";
}} }}
/> />
<Drawer title="生成记录" placement="bottom" size="large" open={logsOpen} onClose={() => setLogsOpen(false)}> <Drawer title={t("workbench.logs")} placement="bottom" size="large" open={logsOpen} onClose={() => setLogsOpen(false)}>
<LogPanel logs={logs} selectedLogIds={selectedLogIds} activeLogId={previewLog?.id} onSelectedLogIdsChange={setSelectedLogIds} onCreateSession={createSession} onDeleteSelected={() => setDeleteConfirmOpen(true)} onPreviewLog={previewGenerationLog} /> <LogPanel logs={logs} selectedLogIds={selectedLogIds} activeLogId={previewLog?.id} onSelectedLogIdsChange={setSelectedLogIds} onCreateSession={createSession} onDeleteSelected={() => setDeleteConfirmOpen(true)} onPreviewLog={previewGenerationLog} />
</Drawer> </Drawer>
<Drawer title="参数" placement="bottom" height="82vh" open={settingsOpen} onClose={() => setSettingsOpen(false)}> <Drawer title={t("workbench.settings")} placement="bottom" height="82vh" open={settingsOpen} onClose={() => setSettingsOpen(false)}>
<div className="grid grid-cols-2 gap-3 pb-4"> <div className="grid grid-cols-2 gap-3 pb-4">
<GenerationSettings config={effectiveConfig} model={model} updateConfig={updateConfig} openConfigDialog={openConfigDialog} /> <GenerationSettings config={effectiveConfig} model={model} updateConfig={updateConfig} openConfigDialog={openConfigDialog} />
</div> </div>
</Drawer> </Drawer>
<PromptSelectDialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen} onSelect={setPrompt} /> <PromptSelectDialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen} onSelect={setPrompt} />
<AssetPickerModal open={assetPickerOpen} defaultTab="my-assets" onInsert={(payload) => void insertPickedAsset(payload)} onClose={() => setAssetPickerOpen(false)} /> <AssetPickerModal open={assetPickerOpen} defaultTab="my-assets" onInsert={(payload) => void insertPickedAsset(payload)} onClose={() => setAssetPickerOpen(false)} />
<Modal title="删除生成记录" open={deleteConfirmOpen} onCancel={() => setDeleteConfirmOpen(false)} onOk={deleteSelectedLogs} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> <Modal title={t("workbench.deleteLogs")} open={deleteConfirmOpen} onCancel={() => setDeleteConfirmOpen(false)} onOk={deleteSelectedLogs} okText={t("common.delete")} okButtonProps={{ danger: true }} cancelText={t("common.cancel")}>
{selectedLogIds.length} {t("workbench.deleteLogsConfirm", { count: selectedLogIds.length })}
</Modal> </Modal>
</div> </div>
); );
@@ -611,11 +614,12 @@ export default function VideoPage() {
function GenerationSettings({ config, model, updateConfig, openConfigDialog }: { config: AiConfig; model: string; updateConfig: UpdateAiConfig; openConfigDialog: (shouldPromptContinue?: boolean) => void }) { function GenerationSettings({ config, model, updateConfig, openConfigDialog }: { config: AiConfig; model: string; updateConfig: UpdateAiConfig; openConfigDialog: (shouldPromptContinue?: boolean) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { t } = useTranslation();
return ( return (
<> <>
<label className="col-span-2 block min-w-0 sm:col-span-1"> <label className="col-span-2 block min-w-0 sm:col-span-1">
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base"></span> <span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base">{t("workbench.model")}</span>
<ModelPicker config={config} value={model} onChange={(value) => updateConfig("videoModel", value)} capability="video" fullWidth onMissingConfig={() => openConfigDialog(false)} /> <ModelPicker config={config} value={model} onChange={(value) => updateConfig("videoModel", value)} capability="video" fullWidth onMissingConfig={() => openConfigDialog(false)} />
</label> </label>
<div className="col-span-2"> <div className="col-span-2">
@@ -626,6 +630,7 @@ function GenerationSettings({ config, model, updateConfig, openConfigDialog }: {
} }
function ResultVideoCard({ video, onDownload, onSaveAsset }: { video: GeneratedVideo; onDownload: (video: GeneratedVideo) => void; onSaveAsset: (video: GeneratedVideo) => void }) { function ResultVideoCard({ video, onDownload, onSaveAsset }: { video: GeneratedVideo; onDownload: (video: GeneratedVideo) => void; onSaveAsset: (video: GeneratedVideo) => void }) {
const { t } = useTranslation();
return ( return (
<div className="overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800"> <div className="overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
<video src={video.url} controls className="aspect-video w-full bg-black object-contain" /> <video src={video.url} controls className="aspect-video w-full bg-black object-contain" />
@@ -639,10 +644,10 @@ function ResultVideoCard({ video, onDownload, onSaveAsset }: { video: GeneratedV
</div> </div>
<div className="flex shrink-0 gap-1"> <div className="flex shrink-0 gap-1">
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => onSaveAsset(video)}> <Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => onSaveAsset(video)}>
{t("common.addToAssets")}
</Button> </Button>
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(video)}> <Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(video)}>
{t("common.download")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -651,28 +656,30 @@ function ResultVideoCard({ video, onDownload, onSaveAsset }: { video: GeneratedV
} }
function PendingVideoCard() { function PendingVideoCard() {
const { t } = useTranslation();
return ( return (
<div className="relative aspect-video overflow-hidden rounded-lg border border-dashed border-stone-300 bg-stone-50 dark:border-stone-700 dark:bg-stone-900"> <div className="relative aspect-video overflow-hidden rounded-lg border border-dashed border-stone-300 bg-stone-50 dark:border-stone-700 dark:bg-stone-900">
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-sm text-stone-500 dark:text-stone-400"> <div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-sm text-stone-500 dark:text-stone-400">
<LoaderCircle className="size-6 animate-spin" /> <LoaderCircle className="size-6 animate-spin" />
<span></span> <span>{t("workbench.generating")}</span>
</div> </div>
</div> </div>
); );
} }
function FailedVideoCard({ error, onRetry }: { error: string; onRetry: () => void }) { function FailedVideoCard({ error, onRetry }: { error: string; onRetry: () => void }) {
const { t } = useTranslation();
return ( return (
<div className="overflow-hidden rounded-lg border border-red-200 bg-red-50 dark:border-red-950 dark:bg-red-950/20"> <div className="overflow-hidden rounded-lg border border-red-200 bg-red-50 dark:border-red-950 dark:bg-red-950/20">
<div className="flex aspect-video flex-col items-center justify-center gap-3 p-5 text-center"> <div className="flex aspect-video flex-col items-center justify-center gap-3 p-5 text-center">
<div className="text-sm font-medium text-red-600 dark:text-red-300"></div> <div className="text-sm font-medium text-red-600 dark:text-red-300">{t("workbench.failed")}</div>
<Typography.Paragraph ellipsis={{ rows: 4 }} className="!mb-0 !text-xs !text-red-500 dark:!text-red-300"> <Typography.Paragraph ellipsis={{ rows: 4 }} className="!mb-0 !text-xs !text-red-500 dark:!text-red-300">
{error} {error}
</Typography.Paragraph> </Typography.Paragraph>
</div> </div>
<div className="flex justify-end border-t border-red-200 p-3 dark:border-red-950"> <div className="flex justify-end border-t border-red-200 p-3 dark:border-red-950">
<Button size="small" danger onClick={onRetry}> <Button size="small" danger onClick={onRetry}>
{t("workbench.retry")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -696,37 +703,39 @@ function LogPanel({
onDeleteSelected: () => void; onDeleteSelected: () => void;
onPreviewLog: (log: GenerationLog) => void; onPreviewLog: (log: GenerationLog) => void;
}) { }) {
const { t } = useTranslation();
const allSelected = Boolean(logs.length) && selectedLogIds.length === logs.length; const allSelected = Boolean(logs.length) && selectedLogIds.length === logs.length;
const toggleAll = () => onSelectedLogIdsChange(allSelected ? [] : logs.map((log) => log.id)); const toggleAll = () => onSelectedLogIdsChange(allSelected ? [] : logs.map((log) => log.id));
return ( return (
<> <>
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-base font-semibold"></h2> <h2 className="text-base font-semibold">{t("workbench.logs")}</h2>
<Tag className="m-0">{logs.length}</Tag> <Tag className="m-0">{logs.length}</Tag>
</div> </div>
<div className="mb-4 flex flex-wrap gap-2"> <div className="mb-4 flex flex-wrap gap-2">
<Button size="small" icon={<Plus className="size-3.5" />} onClick={onCreateSession}> <Button size="small" icon={<Plus className="size-3.5" />} onClick={onCreateSession}>
{t("workbench.new")}
</Button> </Button>
<Button size="small" icon={<CheckSquare className="size-3.5" />} disabled={!logs.length} onClick={toggleAll}> <Button size="small" icon={<CheckSquare className="size-3.5" />} disabled={!logs.length} onClick={toggleAll}>
{allSelected ? "取消" : "全选"} {allSelected ? t("common.cancel") : t("workbench.selectAll")}
</Button> </Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} disabled={!selectedLogIds.length} onClick={onDeleteSelected}> <Button size="small" danger icon={<Trash2 className="size-3.5" />} disabled={!selectedLogIds.length} onClick={onDeleteSelected}>
{t("common.delete")}
</Button> </Button>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{logs.map((log) => ( {logs.map((log) => (
<LogCard key={log.id} log={log} selected={selectedLogIds.includes(log.id)} active={activeLogId === log.id} onSelectedChange={(checked) => onSelectedLogIdsChange(checked ? [...selectedLogIds, log.id] : selectedLogIds.filter((id) => id !== log.id))} onClick={() => onPreviewLog(log)} /> <LogCard key={log.id} log={log} selected={selectedLogIds.includes(log.id)} active={activeLogId === log.id} onSelectedChange={(checked) => onSelectedLogIdsChange(checked ? [...selectedLogIds, log.id] : selectedLogIds.filter((id) => id !== log.id))} onClick={() => onPreviewLog(log)} />
))} ))}
{!logs.length ? <div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed border-stone-300 text-center text-sm text-stone-500 dark:border-stone-700"></div> : null} {!logs.length ? <div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed border-stone-300 text-center text-sm text-stone-500 dark:border-stone-700">{t("workbench.noLogs")}</div> : null}
</div> </div>
</> </>
); );
} }
function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: GenerationLog; selected: boolean; active: boolean; onSelectedChange: (checked: boolean) => void; onClick: () => void }) { function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: GenerationLog; selected: boolean; active: boolean; onSelectedChange: (checked: boolean) => void; onClick: () => void }) {
const { t } = useTranslation();
return ( return (
<button type="button" className={`block w-full rounded-lg border p-2 text-left transition ${active ? "border-stone-900 bg-blue-50 dark:border-stone-100 dark:bg-blue-950/20" : "border-stone-200 bg-background hover:bg-stone-50 dark:border-stone-800 dark:hover:bg-stone-900"}`} onClick={onClick}> <button type="button" className={`block w-full rounded-lg border p-2 text-left transition ${active ? "border-stone-900 bg-blue-50 dark:border-stone-100 dark:bg-blue-950/20" : "border-stone-200 bg-background hover:bg-stone-50 dark:border-stone-800 dark:hover:bg-stone-900"}`} onClick={onClick}>
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-2"> <div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-2">
@@ -740,8 +749,8 @@ function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: Ge
</div> </div>
</div> </div>
<div className="grid justify-items-end gap-2"> <div className="grid justify-items-end gap-2">
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color={log.status === "成功" ? "blue" : log.status === "生成中" ? "processing" : "red"}> <Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color={log.status === "success" ? "blue" : log.status === "pending" ? "processing" : "red"}>
{log.status} {t(`workbench.${log.status === "success" ? "success" : log.status === "pending" ? "generating" : "failed"}`)}
</Tag> </Tag>
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="green"> <Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="green">
{formatDuration(log.durationMs)} {formatDuration(log.durationMs)}
@@ -789,9 +798,9 @@ async function normalizeLog(log: Partial<GenerationLog>): Promise<GenerationLog>
return { return {
id: log.id || nanoid(), id: log.id || nanoid(),
createdAt: log.createdAt || Date.now(), createdAt: log.createdAt || Date.now(),
title: log.title || log.model || "未命名", title: log.title || log.model || i18n.t("workbench.untitled"),
prompt: log.prompt || "", prompt: log.prompt || "",
time: log.time || new Date().toLocaleString("zh-CN", { hour12: false }), time: log.time || new Date().toLocaleString(i18n.resolvedLanguage, { hour12: false }),
model: log.model || config.videoModel || "", model: log.model || config.videoModel || "",
config, config,
references, references,
@@ -801,7 +810,7 @@ async function normalizeLog(log: Partial<GenerationLog>): Promise<GenerationLog>
size: log.size || config.size || "", size: log.size || config.size || "",
resolution: normalizeResolution(log.resolution || config.vquality || ""), resolution: normalizeResolution(log.resolution || config.vquality || ""),
seconds: log.seconds || config.videoSeconds || "", seconds: log.seconds || config.videoSeconds || "",
status: log.status || "成功", status: log.status || "success",
task: log.task, task: log.task,
video, video,
error: log.error, error: log.error,
@@ -838,7 +847,7 @@ function filterAudioReferencesByDuration(existing: ReferenceAudio[], next: Refer
total += item.durationMs || 0; total += item.durationMs || 0;
accepted.push(item); accepted.push(item);
} }
if (skipped) warn("已忽略不符合时长要求的参考音频:单个 2-15 秒,总时长不超过 15 秒"); if (skipped) warn(i18n.t("videoWorkbench.audioDurationInvalid"));
return accepted; return accepted;
} }
@@ -885,9 +894,9 @@ function buildLog({ prompt, model, config, references, videoReferences, audioRef
return { return {
id: nanoid(), id: nanoid(),
createdAt: Date.now(), createdAt: Date.now(),
title: prompt.slice(0, 12) || "未命名", title: prompt.slice(0, 12) || i18n.t("workbench.untitled"),
prompt, prompt,
time: new Date().toLocaleString("zh-CN", { hour12: false }), time: new Date().toLocaleString(i18n.resolvedLanguage, { hour12: false }),
model, model,
config: logConfig, config: logConfig,
references, references,
+22 -20
View File
@@ -1,11 +1,13 @@
import axios from "axios"; import axios from "axios";
import i18n from "@/i18n";
import { audioMimeType, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation"; import { audioMimeType, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation";
import { uploadMediaFile, type UploadedFile } from "@/services/file-storage"; import { uploadMediaFile, type UploadedFile } from "@/services/file-storage";
import { buildApiUrl, resolveModelRequestConfig, resolveModelScript, type AiConfig } from "@/stores/use-config-store"; import { buildApiUrl, resolveModelRequestConfig, resolveModelScript, type AiConfig } from "@/stores/use-config-store";
import { runModelPlugin } from "./model-plugin"; import { runModelPlugin } from "./model-plugin";
type RequestOptions = { signal?: AbortSignal }; type RequestOptions = { signal?: AbortSignal };
const apiText = (key: string, options?: Record<string, unknown>) => i18n.t(`apiErrors.${key}`, options);
function aiApiUrl(config: AiConfig, path: string) { function aiApiUrl(config: AiConfig, path: string) {
return buildApiUrl(config.baseUrl, path); return buildApiUrl(config.baseUrl, path);
@@ -24,9 +26,9 @@ export async function requestAudioGeneration(config: AiConfig, prompt: string, o
const format = normalizeAudioFormatValue(config.audioFormat); const format = normalizeAudioFormatValue(config.audioFormat);
const script = resolveModelScript(config, config.model || config.audioModel); const script = resolveModelScript(config, config.model || config.audioModel);
if (script) { if (script) {
if (!model) throw new Error("请先配置音频模型"); if (!model) throw new Error(apiText("audioModelRequired"));
if (!requestConfig.baseUrl.trim()) throw new Error("请先配置 Base URL"); if (!requestConfig.baseUrl.trim()) throw new Error(apiText("baseUrlRequired"));
if (!requestConfig.apiKey.trim()) throw new Error("请先配置 API Key"); if (!requestConfig.apiKey.trim()) throw new Error(apiText("apiKeyRequired"));
try { try {
const result = await runModelPlugin({ const result = await runModelPlugin({
capability: "audio", capability: "audio",
@@ -38,7 +40,7 @@ export async function requestAudioGeneration(config: AiConfig, prompt: string, o
}); });
return await audioPluginBlob(result, format); return await audioPluginBlob(result, format);
} catch (error) { } catch (error) {
throw new Error(readAxiosError(error, "音频生成失败")); throw new Error(readAxiosError(error, apiText("audioGenerationFailed")));
} }
} }
assertAudioConfig(requestConfig, model); assertAudioConfig(requestConfig, model);
@@ -60,7 +62,7 @@ export async function requestAudioGeneration(config: AiConfig, prompt: string, o
await assertAudioBlob(response.data); await assertAudioBlob(response.data);
return response.data.type.startsWith("audio/") ? response.data : new Blob([response.data], { type: audioMimeType(format) }); return response.data.type.startsWith("audio/") ? response.data : new Blob([response.data], { type: audioMimeType(format) });
} catch (error) { } catch (error) {
throw new Error(readAxiosError(error, "音频生成失败")); throw new Error(readAxiosError(error, apiText("audioGenerationFailed")));
} }
} }
@@ -72,7 +74,7 @@ async function audioPluginBlob(result: unknown, format: string): Promise<Blob> {
const record = result as Record<string, unknown>; const record = result as Record<string, unknown>;
source = typeof record.b64_json === "string" ? record.b64_json : typeof record.data === "string" ? record.data : typeof record.url === "string" ? record.url : ""; source = typeof record.b64_json === "string" ? record.b64_json : typeof record.data === "string" ? record.data : typeof record.url === "string" ? record.url : "";
} }
if (!source) throw new Error("模型调用脚本没有返回音频"); if (!source) throw new Error(apiText("scriptNoAudio"));
const url = source.startsWith("data:") || /^https?:/i.test(source) ? source : `data:${audioMimeType(format)};base64,${source}`; const url = source.startsWith("data:") || /^https?:/i.test(source) ? source : `data:${audioMimeType(format)};base64,${source}`;
const blob = await (await fetch(url)).blob(); const blob = await (await fetch(url)).blob();
return blob.type.startsWith("audio/") ? blob : new Blob([blob], { type: audioMimeType(format) }); return blob.type.startsWith("audio/") ? blob : new Blob([blob], { type: audioMimeType(format) });
@@ -84,10 +86,10 @@ export async function storeGeneratedAudio(blob: Blob, format = "mp3"): Promise<U
} }
function assertAudioConfig(config: AiConfig, model: string) { function assertAudioConfig(config: AiConfig, model: string) {
if (!model) throw new Error("请先配置音频模型"); if (!model) throw new Error(apiText("audioModelRequired"));
if (!config.baseUrl.trim()) throw new Error("请先配置 Base URL"); if (!config.baseUrl.trim()) throw new Error(apiText("baseUrlRequired"));
if (!config.apiKey.trim()) throw new Error("请先配置 API Key"); if (!config.apiKey.trim()) throw new Error(apiText("apiKeyRequired"));
if (config.apiFormat === "gemini") throw new Error("Gemini 调用格式暂不支持音频生成,请使用 OpenAI 格式渠道"); if (config.apiFormat === "gemini") throw new Error(apiText("geminiAudioUnsupported"));
} }
async function assertAudioBlob(blob: Blob) { async function assertAudioBlob(blob: Blob) {
@@ -98,7 +100,7 @@ async function assertAudioBlob(blob: Blob) {
} catch { } catch {
return; return;
} }
if (typeof payload.code === "number" && payload.code !== 0) throw new Error(payload.msg || "音频生成失败"); if (typeof payload.code === "number" && payload.code !== 0) throw new Error(payload.msg || apiText("audioGenerationFailed"));
if (payload.error?.message) throw new Error(payload.error.message); if (payload.error?.message) throw new Error(payload.error.message);
} }
@@ -111,7 +113,7 @@ function readApiErrorMessage(value: unknown): string {
if (inner === value && typeof parsed === "object" && Object.keys(parsed).length === 0) return ""; if (inner === value && typeof parsed === "object" && Object.keys(parsed).length === 0) return "";
return inner; return inner;
} catch { } catch {
if (/<[a-z][\s\S]*>/i.test(value)) return `服务返回了 HTML 错误页面(${value.slice(0, 80)}...`; if (/<[a-z][\s\S]*>/i.test(value)) return apiText("htmlError", { preview: `${value.slice(0, 80)}...` });
return value; return value;
} }
} }
@@ -131,7 +133,7 @@ function readApiErrorMessage(value: unknown): string {
} }
function readAxiosError(error: unknown, fallback: string) { function readAxiosError(error: unknown, fallback: string) {
if (axios.isCancel(error)) return "请求已取消"; if (axios.isCancel(error)) return apiText("requestCanceled");
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const responseData = error.response?.data; const responseData = error.response?.data;
const apiMsg = readApiErrorMessage(responseData); const apiMsg = readApiErrorMessage(responseData);
@@ -140,15 +142,15 @@ function readAxiosError(error: unknown, fallback: string) {
if (statusMsg) return statusMsg; if (statusMsg) return statusMsg;
return error.message || fallback; return error.message || fallback;
} }
if (error instanceof DOMException && error.name === "AbortError") return "请求已取消"; if (error instanceof DOMException && error.name === "AbortError") return apiText("requestCanceled");
return error instanceof Error ? readApiErrorMessage(error.message) || error.message : fallback; return error instanceof Error ? readApiErrorMessage(error.message) || error.message : fallback;
} }
function statusMessage(status: number | undefined, fallback: string) { function statusMessage(status: number | undefined, fallback: string) {
if (status === 401 || status === 403) return "鉴权失败,请检查 API Key、套餐权限或模型权限"; if (status === 401 || status === 403) return apiText("authenticationFailed");
if (status === 429) return "请求被限流或额度不足,请稍后重试"; if (status === 429) return apiText("rateLimited");
if (status === 404) return "接口地址不存在(404),请检查 Base URL 和模型选择"; if (status === 404) return apiText("notFound");
if (status === 502) return "网关错误(502),接口服务暂时不可用,请稍后重试"; if (status === 502) return apiText("badGateway");
if (status === 503) return "服务繁忙(503),请稍后重试"; if (status === 503) return apiText("serviceBusy");
return status ? `请求失败(HTTP ${status}),请检查 Base URL 和 API Key 是否正确` : fallback; return status ? apiText("httpFailed", { status }) : fallback;
} }

Some files were not shown because too many files have changed in this diff Show More