feat(i18n): implement internationalization framework and update UI components for language support
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
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 { useTranslation } from "react-i18next";
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
@@ -55,6 +56,7 @@ export function AgentChatComposer({
|
||||
onReasoningEffortChange?: (effort: AgentReasoningEffort) => void;
|
||||
left?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const selectedSkill = useAgentSkillStore((state) => state.selectedSkill);
|
||||
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}>
|
||||
<img src={item.url} alt={item.name} className="size-full object-cover" />
|
||||
{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" />
|
||||
</button>
|
||||
) : null}
|
||||
@@ -80,7 +82,7 @@ export function AgentChatComposer({
|
||||
<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="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" />
|
||||
</button>
|
||||
</span>
|
||||
@@ -114,8 +116,8 @@ export function AgentChatComposer({
|
||||
void onAddFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
}} />
|
||||
<Tooltip title="上传图片">
|
||||
<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="上传图片" />
|
||||
<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={t("agent.composer.uploadImage")} />
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null}
|
||||
@@ -127,9 +129,9 @@ export function AgentChatComposer({
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{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>
|
||||
@@ -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 }) {
|
||||
const { t } = useTranslation();
|
||||
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 [reasoningOpen, setReasoningOpen] = useState(false);
|
||||
return (
|
||||
<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">
|
||||
<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" />
|
||||
<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" />
|
||||
@@ -158,16 +162,16 @@ function AgentModelControls({ models, model, reasoningEffort, onModelChange, onR
|
||||
</Select>
|
||||
</span>
|
||||
</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">
|
||||
<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" />
|
||||
<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" />
|
||||
</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">
|
||||
{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>
|
||||
</Select>
|
||||
</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 }) {
|
||||
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 [open, setOpen] = useState(false);
|
||||
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">
|
||||
<Dropdown
|
||||
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}
|
||||
<span className="hidden @min-[660px]:inline">{current.shortTitle}</span>
|
||||
<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 }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const mode = t(confirmTools ? "agent.composer.tools.manual" : "agent.composer.tools.automatic");
|
||||
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">
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
@@ -236,20 +232,20 @@ function ToolConfirmationMenu({ confirmTools, theme, onChange }: { confirmTools:
|
||||
items: [
|
||||
{
|
||||
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),
|
||||
},
|
||||
{
|
||||
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),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<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" />}
|
||||
<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" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
|
||||
@@ -2,30 +2,27 @@ import { useEffect, useId, useState, type ReactNode } from "react";
|
||||
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 { Streamdown, type LinkSafetyModalProps } from "streamdown";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import i18n from "@/i18n";
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useAgentStore, type AgentPendingApproval } from "@/stores/use-agent-store";
|
||||
import { revealAgentLocalFile } from "@/services/api/canvas-agent";
|
||||
|
||||
const streamdownProps = {
|
||||
const streamdownProps = () => ({
|
||||
className: "agent-streamdown",
|
||||
controls: { code: { copy: true, download: false }, table: { copy: true, download: false, fullscreen: false } },
|
||||
linkSafety: { enabled: true, renderModal: (props: LinkSafetyModalProps) => <AgentLinkModal {...props} /> },
|
||||
lineNumbers: false,
|
||||
translations: {
|
||||
close: "关闭",
|
||||
copied: "已复制",
|
||||
copyCode: "复制代码",
|
||||
copyLink: "复制链接",
|
||||
externalLinkWarning: "即将打开以下外部链接,请确认链接可信。",
|
||||
openExternalLink: "打开外部链接?",
|
||||
openLink: "继续打开",
|
||||
close: tr("close"), copied: tr("copied"), copyCode: tr("copyCode"), copyLink: tr("copyLink"), externalLinkWarning: tr("externalWarning"), openExternalLink: tr("openExternal"), openLink: tr("continueOpen"),
|
||||
},
|
||||
} as const;
|
||||
} as const);
|
||||
const streamdownAnimation = { duration: 20, stagger: 0, sep: "word" } as const;
|
||||
|
||||
function AgentLinkModal({ isOpen, onClose, onConfirm, url }: LinkSafetyModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { message } = App.useApp();
|
||||
const copyText = useCopyText();
|
||||
const localPath = localFilePath(url);
|
||||
@@ -36,26 +33,26 @@ function AgentLinkModal({ isOpen, onClose, onConfirm, url }: LinkSafetyModalProp
|
||||
setOpening(true);
|
||||
try {
|
||||
await revealAgentLocalFile(endpoint, token, localPath);
|
||||
message.success("已在文件管理器中定位");
|
||||
message.success(t("agent.message.revealed"));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "无法打开本地文件");
|
||||
message.error(error instanceof Error ? error.message : t("agent.message.openLocalFailed"));
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
};
|
||||
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">
|
||||
{localPath ? "将在本机文件管理器中定位该路径,不会通过浏览器打开。" : "即将打开以下外部链接,请确认链接可信。"}
|
||||
{t(localPath ? "agent.message.localDescription" : "agent.message.externalDescription")}
|
||||
</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">
|
||||
<Button type="text" icon={<Copy className="size-4" />} onClick={() => copyText(localPath || url, localPath ? "路径已复制" : "链接已复制")}>
|
||||
{localPath ? "复制路径" : "复制链接"}
|
||||
<Button type="text" icon={<Copy className="size-4" />} onClick={() => copyText(localPath || url, t(localPath ? "agent.message.pathCopied" : "agent.message.linkCopied"))}>
|
||||
{t(localPath ? "agent.message.copyPath" : "agent.message.copyLink")}
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -117,7 +114,7 @@ export function AgentChatMessage({ item, theme, onRejectTool, onApproveTool }: {
|
||||
}
|
||||
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)} />;
|
||||
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 (
|
||||
<div className={`flex ${isUser ? "justify-end" : "justify-start"}`}>
|
||||
@@ -128,7 +125,7 @@ export function AgentChatMessage({ item, theme, onRejectTool, onApproveTool }: {
|
||||
{isUser ? (
|
||||
<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.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 }) {
|
||||
const { t } = useTranslation();
|
||||
const view = userDetail(detail);
|
||||
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 }}>
|
||||
@@ -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(); }}>
|
||||
<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" />
|
||||
<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}
|
||||
</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 ? (
|
||||
<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?.()}>
|
||||
拒绝执行
|
||||
{t("agent.message.reject")}
|
||||
</Button>
|
||||
<Button type="text" className="!h-8" icon={<CheckCircle2 className="size-3.5" />} style={{ color: "#16a34a" }} onClick={() => onApprove?.()}>
|
||||
批准执行
|
||||
{t("agent.message.approve")}
|
||||
</Button>
|
||||
</div>
|
||||
) : 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 }) {
|
||||
const { t } = useTranslation();
|
||||
const isFile = approval.method === "item/fileChange/requestApproval";
|
||||
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;
|
||||
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 }}>
|
||||
@@ -182,9 +181,9 @@ export function AgentApprovalCard({ approval, theme, onDecision }: { approval: A
|
||||
</div>
|
||||
</div>
|
||||
<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 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 === "acceptForSession"} style={{ color: "#ea580c" }} onClick={() => onDecision("acceptForSession")}>本会话允许</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")}>{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")}>{t("agent.message.allowSession")}</Button>
|
||||
</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] }) {
|
||||
const { t } = useTranslation();
|
||||
const status = String(objectField(detail, "status") || "");
|
||||
const running = ["inProgress", "in_progress", "running", "started", "pending"].includes(status);
|
||||
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 }}>
|
||||
<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" />}
|
||||
<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" />
|
||||
</div>
|
||||
</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 }}>
|
||||
<Streamdown {...streamdownProps} animated={streamdownAnimation} isAnimating={running}>{text}</Streamdown>
|
||||
<Streamdown {...streamdownProps()} animated={streamdownAnimation} isAnimating={running}>{text}</Streamdown>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
@@ -247,14 +247,13 @@ function AgentReasoningSummary({ text, detail, theme }: { text: string; detail?:
|
||||
type AgentCommandItem = Pick<AgentChatMessageItem, "id" | "text" | "detail">;
|
||||
|
||||
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 running = states.some((state) => state.running);
|
||||
const failed = states.filter((state) => state.failed).length;
|
||||
const expandable = items.some((item) => Boolean(item.text.trim() || userDetail(item.detail)));
|
||||
const color = running ? "#d97706" : failed ? "#dc2626" : theme.node.muted;
|
||||
const label = running
|
||||
? items.length > 1 ? `正在执行 ${items.length} 条命令` : "正在执行命令"
|
||||
: `已执行 ${items.length} 条命令${failed ? ` · ${failed} 条失败` : ""}`;
|
||||
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 }) : "" });
|
||||
const header = (
|
||||
<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" />}
|
||||
@@ -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] }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const detailId = useId();
|
||||
const view = userDetail(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 content = (
|
||||
<>
|
||||
<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}>
|
||||
{state.running ? <LoaderCircle className="size-3.5 animate-spin" /> : state.failed ? <XCircle className="size-3.5" /> : <CheckCircle2 className="size-3.5" />}
|
||||
</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] }) {
|
||||
const { t } = useTranslation();
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
const startedAt = Date.now();
|
||||
@@ -378,15 +379,15 @@ export function AgentWorkingMessage({ text, detail, status = "running", mcpStatu
|
||||
))}
|
||||
</div>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
||||
function waitingTime(seconds: number) {
|
||||
if (seconds < 60) return `已等待 ${seconds} 秒`;
|
||||
if (seconds < 60) return tr("waitingSeconds", { seconds });
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `已等待 ${minutes} 分 ${seconds % 60} 秒`;
|
||||
return tr("waitingMinutes", { minutes, seconds: seconds % 60 });
|
||||
}
|
||||
|
||||
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 }> };
|
||||
|
||||
function AgentDetailBlock({ detail, theme }: { detail: UserDetail; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<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 ? (
|
||||
@@ -420,7 +422,7 @@ function AgentDetailBlock({ detail, theme }: { detail: UserDetail; theme: (typeo
|
||||
) : null}
|
||||
{detail.files?.length ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="opacity-60">涉及文件</div>
|
||||
<div className="opacity-60">{t("agent.message.files")}</div>
|
||||
{detail.files.map((file) => (
|
||||
<div key={`${file.action}-${file.path}`} className="flex items-start gap-2">
|
||||
<FileText className="mt-0.5 size-3.5 shrink-0" />
|
||||
@@ -432,7 +434,7 @@ function AgentDetailBlock({ detail, theme }: { detail: UserDetail; theme: (typeo
|
||||
) : null}
|
||||
{detail.output ? (
|
||||
<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>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -441,6 +443,7 @@ function AgentDetailBlock({ detail, theme }: { detail: UserDetail; theme: (typeo
|
||||
}
|
||||
|
||||
function AgentMessageAttachments({ attachments, alignRight }: { attachments: AgentChatAttachment[]; alignRight?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
return (
|
||||
<>
|
||||
@@ -450,7 +453,7 @@ function AgentMessageAttachments({ attachments, alignRight }: { attachments: Age
|
||||
key={item.id}
|
||||
src={item.url}
|
||||
alt={item.name}
|
||||
title="点击查看大图"
|
||||
title={t("agent.message.viewLarge")}
|
||||
className="size-10 cursor-zoom-in rounded-lg object-cover"
|
||||
draggable={false}
|
||||
onClick={() => setPreviewUrl(item.url)}
|
||||
@@ -459,7 +462,7 @@ function AgentMessageAttachments({ attachments, alignRight }: { attachments: Age
|
||||
</div>
|
||||
{previewUrl ? (
|
||||
<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>
|
||||
) : null}
|
||||
</>
|
||||
@@ -470,12 +473,12 @@ function toolCardState(title: string, text: string, detail?: unknown) {
|
||||
const raw = `${title} ${text} ${normalizeText(objectField(detail, "error"))}`;
|
||||
const lower = raw.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 (["declined", "rejected", "cancelled", "canceled"].includes(status) || /拒绝|取消/.test(raw)) 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: "执行失败", 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 (["completed", "succeeded", "success"].includes(status) || /完成|成功/.test(raw)) return { label: "已完成", color: "#16a34a", icon: <CheckCircle2 className="size-4" />, isError: false };
|
||||
return { label: "已记录", color: "#2563eb", icon: <Wrench 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: 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: tr("failed"), color: "#dc2626", icon: <XCircle className="size-4" />, isError: true };
|
||||
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: tr("completed"), color: "#16a34a", icon: <CheckCircle2 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) {
|
||||
@@ -486,17 +489,17 @@ function toolIcon(kind: string | undefined, fallback: ReactNode) {
|
||||
}
|
||||
|
||||
function planCardState(plan: PlanDetail, completed: number) {
|
||||
if (plan.status === "failed") return { label: "执行失败", color: "#dc2626" };
|
||||
if (["interrupted", "cancelled", "canceled"].includes(plan.status)) return { label: "已停止", color: "#d97706" };
|
||||
if (completed === plan.tasks.length) return { label: "已完成", color: "#16a34a" };
|
||||
if (plan.status === "finished") return { label: "已结束", color: "#2563eb" };
|
||||
return { label: "进行中", color: "#d97706" };
|
||||
if (plan.status === "failed") return { label: tr("failed"), color: "#dc2626" };
|
||||
if (["interrupted", "cancelled", "canceled"].includes(plan.status)) return { label: tr("stopped"), color: "#d97706" };
|
||||
if (completed === plan.tasks.length) return { label: tr("completed"), color: "#16a34a" };
|
||||
if (plan.status === "finished") return { label: tr("finished"), color: "#2563eb" };
|
||||
return { label: tr("running"), color: "#d97706" };
|
||||
}
|
||||
|
||||
function planTaskState(status: string, muted: string) {
|
||||
if (status === "completed") return { label: "已完成", color: "#16a34a", icon: <CheckCircle2 className="size-3.5" /> };
|
||||
if (status === "inProgress") return { label: "进行中", color: "#d97706", icon: <LoaderCircle className="size-3.5 animate-spin" /> };
|
||||
return { label: "待处理", color: muted, icon: <Circle className="size-3.5" /> };
|
||||
if (status === "completed") return { label: tr("completed"), color: "#16a34a", icon: <CheckCircle2 className="size-3.5" /> };
|
||||
if (status === "inProgress") return { label: tr("running"), color: "#d97706", icon: <LoaderCircle className="size-3.5 animate-spin" /> };
|
||||
return { label: tr("pending"), color: muted, icon: <Circle className="size-3.5" /> };
|
||||
}
|
||||
|
||||
function planDetail(value: unknown): PlanDetail | null {
|
||||
@@ -546,3 +549,7 @@ function normalizeText(value: unknown) {
|
||||
function objectField(value: unknown, key: string) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { motion, useSpring, useTransform } from "motion/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { summarizeCanvasAgentOps } from "@/lib/canvas/canvas-agent-ops";
|
||||
@@ -30,6 +31,7 @@ export function AgentChatTimeline({
|
||||
onApproveTool: () => void;
|
||||
onApprovalDecision: (approval: AgentPendingApproval, decision: "accept" | "acceptForSession" | "decline") => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const messages = useAgentStore((state) => state.messages);
|
||||
const bootstrapStatus = useAgentStore((state) => state.bootstrapStatus);
|
||||
const mcpStartupStatuses = useAgentStore((state) => state.mcpStartupStatuses);
|
||||
@@ -94,18 +96,19 @@ export function AgentChatTimeline({
|
||||
</div>
|
||||
</div>
|
||||
{showScrollToBottom ? (
|
||||
<AgentScrollToBottom theme={theme} title="查看最新消息" onClick={() => scrollToBottom()} />
|
||||
<AgentScrollToBottom theme={theme} title={t("agent.chat.latestMessages")} onClick={() => scrollToBottom()} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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));
|
||||
if (!plan) return null;
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -159,12 +162,13 @@ function isCommandMessage(item: AgentChatItem) {
|
||||
}
|
||||
|
||||
export function AgentUsageBar({ usage, theme }: { usage: AgentTokenUsage; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<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>
|
||||
<UsageNumber label="输入" value={usage.input} color={theme.node.text} />
|
||||
<UsageNumber label="缓存" value={usage.cached} color={theme.node.text} />
|
||||
<UsageNumber label="输出" value={usage.output} color={theme.node.text} />
|
||||
<span className="opacity-70">{t("agent.chat.latestCall")}</span>
|
||||
<UsageNumber label={t("agent.chat.input")} value={usage.input} color={theme.node.text} />
|
||||
<UsageNumber label={t("agent.chat.cached")} value={usage.cached} color={theme.node.text} />
|
||||
<UsageNumber label={t("agent.chat.output")} value={usage.output} color={theme.node.text} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,10 @@ import { Fragment } from "react";
|
||||
import { App, Button, Input, Tooltip } from "antd";
|
||||
import copyToClipboard from "copy-to-clipboard";
|
||||
import { Copy, KeyRound, Link2, PlugZap } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
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_MCP_REMOVE_COMMAND = "codex mcp remove infinite-canvas";
|
||||
|
||||
@@ -35,30 +32,32 @@ export function AgentConnectView({
|
||||
onTokenChange: (value: string) => void;
|
||||
onToggleEnabled: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
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 copyCommand = (command: string) => {
|
||||
copyToClipboard(command);
|
||||
message.success("命令已复制");
|
||||
message.success(t("agent.connect.commandCopied"));
|
||||
};
|
||||
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="font-medium" style={{ color: theme.node.text }}>
|
||||
Codex 插件提醒
|
||||
{t("agent.connect.pluginReminder")}
|
||||
</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">
|
||||
{[
|
||||
["移除插件", AGENT_PLUGIN_REMOVE_COMMAND],
|
||||
["移除手动 MCP", AGENT_MCP_REMOVE_COMMAND],
|
||||
[t("agent.connect.removePlugin"), AGENT_PLUGIN_REMOVE_COMMAND],
|
||||
[t("agent.connect.removeMcp"), AGENT_MCP_REMOVE_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 }}>
|
||||
<span className="shrink-0 text-[11px]" style={{ color: theme.node.muted }}>
|
||||
{label}
|
||||
</span>
|
||||
<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)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -70,13 +69,13 @@ export function AgentConnectView({
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
<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 }}>
|
||||
按使用场景选择一种连接方式。
|
||||
{t("agent.connect.description")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{AGENT_CONNECT_STEPS.map((step, index) => {
|
||||
{steps.map((step, index) => {
|
||||
const command = "command" in step ? step.command : "";
|
||||
return (
|
||||
<Fragment key={step.title}>
|
||||
@@ -88,7 +87,7 @@ export function AgentConnectView({
|
||||
{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 }}>
|
||||
<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)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -103,7 +102,7 @@ export function AgentConnectView({
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<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
|
||||
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 }}
|
||||
@@ -113,26 +112,26 @@ export function AgentConnectView({
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||
默认自动读取 Local URL 和 Connect token,失败时再手动填写。
|
||||
{t("agent.connect.autoDiscover")}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2.5">
|
||||
<label className="grid gap-1.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium" style={{ color: theme.node.muted }}>
|
||||
<Link2 className="size-3.5" />
|
||||
本地地址
|
||||
{t("agent.connect.localAddress")}
|
||||
<span className="font-normal opacity-70">Local URL</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 className="grid gap-1.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium" style={{ color: theme.node.muted }}>
|
||||
<KeyRound className="size-3.5" />
|
||||
连接 Token
|
||||
{t("agent.connect.token")}
|
||||
<span className="font-normal opacity-70">Connect token</span>
|
||||
</span>
|
||||
<Input.Password
|
||||
@@ -140,7 +139,7 @@ export function AgentConnectView({
|
||||
prefix={<KeyRound className="mr-1 size-4" style={{ color: theme.node.faint }} />}
|
||||
value={token}
|
||||
onChange={(event) => onTokenChange(event.target.value)}
|
||||
placeholder="自动发现,或手动填入 Connect token"
|
||||
placeholder={t("agent.connect.tokenPlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
{connectError ? (
|
||||
@@ -154,4 +153,3 @@ export function AgentConnectView({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 { randomId } from "@/lib/utils";
|
||||
import { useAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentTokenUsage } from "@/stores/use-agent-store";
|
||||
import type { AgentChatAttachment } from "./agent-chat-message";
|
||||
export const REASONING_PLACEHOLDER = "正在分析任务…";
|
||||
export const REASONING_PLACEHOLDER = i18n.t("agent.events.analyzing");
|
||||
|
||||
export type AgentEventPayload = {
|
||||
agent?: string;
|
||||
@@ -78,33 +79,33 @@ export function formatAgentActivity(event: AgentEventPayload): Omit<AgentChatIte
|
||||
if (item.type === "reasoning") {
|
||||
const text = readableText(item.summary);
|
||||
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") {
|
||||
const text = stringText(item.text);
|
||||
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") {
|
||||
const command = stringText(item.command);
|
||||
const text = command || (completed ? failed ? "命令执行失败" : "命令已完成" : activityPlaceholder(item.type));
|
||||
return { role: "tool", title: "执行命令", text, detail: commandActivityDetail(item, itemStatus) };
|
||||
const text = command || (completed ? tr(failed ? "commandFailed" : "commandCompleted") : activityPlaceholder(item.type));
|
||||
return { role: "tool", title: tr("executeCommand"), text, detail: commandActivityDetail(item, itemStatus) };
|
||||
}
|
||||
if (item.type === "file_change") {
|
||||
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") {
|
||||
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") {
|
||||
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)) {
|
||||
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") {
|
||||
const name = String(item.tool || "");
|
||||
@@ -112,11 +113,11 @@ export function formatAgentActivity(event: AgentEventPayload): Omit<AgentChatIte
|
||||
return {
|
||||
role: "tool",
|
||||
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),
|
||||
};
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -126,8 +127,8 @@ export function formatAgentPlan(event: AgentEventPayload): Omit<AgentChatItem, "
|
||||
const completed = tasks.filter((item) => item.status === "completed").length;
|
||||
return {
|
||||
role: "tool",
|
||||
title: "任务进度",
|
||||
text: `已完成 ${completed}/${tasks.length} 项`,
|
||||
title: tr("progress"),
|
||||
text: tr("progressCount", { completed, total: tasks.length }),
|
||||
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 {
|
||||
if (item.type === "command_execution") return { id: item.id || randomId(), role: "tool", title: "执行命令", 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" } };
|
||||
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" ? tr("plan") : tr("reasoning"), text: delta, detail: { kind: activityKind(item.type), status: "inProgress" } };
|
||||
}
|
||||
|
||||
export function activityPlaceholder(type?: string) {
|
||||
if (type === "plan") return "正在整理执行步骤…";
|
||||
if (type === "command_execution") return "正在执行命令…";
|
||||
return REASONING_PLACEHOLDER;
|
||||
if (type === "plan") return tr("planning");
|
||||
if (type === "command_execution") return tr("executingCommand");
|
||||
return tr("analyzing");
|
||||
}
|
||||
|
||||
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 {
|
||||
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;
|
||||
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) {
|
||||
if (!files.length) return completed ? "已完成文件修改" : "正在准备文件修改…";
|
||||
if (files.length === 1) return `${completed ? "已" : "正在"}${files[0].action || "修改"} ${files[0].path}`;
|
||||
const names = files.slice(0, 3).map((file) => file.path).join("、");
|
||||
return `${completed ? "已修改" : "正在修改"} ${files.length} 个文件:${names}${files.length > 3 ? " 等" : ""}`;
|
||||
if (!files.length) return tr(completed ? "filesCompleted" : "preparingFiles");
|
||||
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(", ");
|
||||
return tr(completed ? "filesEdited" : "editingFiles", { count: files.length, names, more: files.length > 3 ? tr("andMore") : "" });
|
||||
}
|
||||
|
||||
function webSearchSummary(item: AgentEventItem) {
|
||||
const action = item.action;
|
||||
const type = stringText(objectField(action, "type"));
|
||||
if (type === "openPage") return `打开网页:${stringText(objectField(action, "url"))}`;
|
||||
if (type === "findInPage") return `在网页中查找“${stringText(objectField(action, "pattern")) || "内容"}”`;
|
||||
return `搜索:${stringText(item.query) || stringText(objectField(action, "query")) || "相关资料"}`;
|
||||
if (type === "openPage") return tr("openPage", { url: stringText(objectField(action, "url")) });
|
||||
if (type === "findInPage") return tr("findInPage", { pattern: stringText(objectField(action, "pattern")) || tr("content") });
|
||||
return tr("search", { query: stringText(item.query) || stringText(objectField(action, "query")) || tr("relatedInfo") });
|
||||
}
|
||||
|
||||
function webSearchDetailRows(item: AgentEventItem) {
|
||||
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 {
|
||||
@@ -215,13 +216,13 @@ function detailRow(label: string, value: unknown) {
|
||||
|
||||
function durationDetailRow(value: unknown) {
|
||||
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) {
|
||||
if (value === "add") return "新增";
|
||||
if (value === "delete") return "删除";
|
||||
return "修改";
|
||||
if (value === "add") return tr("add");
|
||||
if (value === "delete") return tr("delete");
|
||||
return tr("edit");
|
||||
}
|
||||
|
||||
export function parseEventData<T>(event: Event) {
|
||||
@@ -252,13 +253,13 @@ export function registerLiveAgentTurn(
|
||||
|
||||
export function formatLogText(logs: AgentEventLog[], context: AgentLogContext) {
|
||||
const head = [
|
||||
"Infinite Canvas Agent 诊断",
|
||||
`地址:${context.endpoint}`,
|
||||
`连接:${context.connected ? "在线" : context.enabled ? "连接中" : "未启用"} · 状态:${context.activity}`,
|
||||
`消息:${context.messages} · 工具:${context.pendingTool ? toolName(context.pendingTool) : "无"}`,
|
||||
tr("diagnostics"),
|
||||
tr("address", { endpoint: context.endpoint }),
|
||||
tr("connection", { connection: tr(context.connected ? "online" : context.enabled ? "connecting" : "disabled"), status: context.activity }),
|
||||
tr("messageTool", { messages: context.messages, tool: context.pendingTool ? toolName(context.pendingTool) : tr("none") }),
|
||||
].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) {
|
||||
@@ -267,29 +268,29 @@ export function formatLogJson(logs: AgentEventLog[], context: AgentLogContext) {
|
||||
|
||||
export function formatAgentEventLog(event: AgentEventPayload) {
|
||||
const item = event.item;
|
||||
if (event.type === "thread.started") return { title: "创建会话", text: shortId(event.thread_id) };
|
||||
if (event.type === "turn.started") return { title: "开始处理", text: shortId(event.turn_id) };
|
||||
if (event.type === "thread.started") return { title: tr("threadCreated"), text: shortId(event.thread_id) };
|
||||
if (event.type === "turn.started") return { title: tr("turnStarted"), text: shortId(event.turn_id) };
|
||||
if (event.type === "plan.updated") {
|
||||
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") return { title: event.status === "interrupted" ? "处理已停止" : "处理完成", text: turnSummary(event) };
|
||||
if (event.type === "turn.failed" || event.type === "error") return { title: "处理失败", 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.completed" && isMcpToolItem(item)) return { title: item.error ? "工具失败" : "工具完成", 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 === "turn.completed" && event.status === "failed") return { title: tr("turnFailed"), text: agentErrorView(event.error?.message).text };
|
||||
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: tr("turnFailed"), text: agentErrorView(event.message || event.error?.message).text };
|
||||
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: 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: tr("replyReceived"), text: compactText(stringText(item.text)) };
|
||||
return null;
|
||||
}
|
||||
|
||||
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) {
|
||||
const text = normalizeText(value);
|
||||
if (/selected model is at capacity/i.test(text)) return { title: "模型暂时繁忙", text: "当前选择的模型请求量过大,暂时无法处理。请稍后重试,或切换其他模型后再试。" };
|
||||
return { title: "任务失败", text: text || "Codex 未能完成本次任务,请稍后重试。" };
|
||||
if (/selected model is at capacity/i.test(text)) return { title: tr("modelBusy"), text: tr("modelBusyDescription") };
|
||||
return { title: tr("taskFailed"), text: text || tr("taskFailedDescription") };
|
||||
}
|
||||
|
||||
export function eventUsage(event: AgentEventPayload): AgentTokenUsage {
|
||||
@@ -314,53 +315,33 @@ export function isConnectionErrorMessage(item: AgentChatItem) {
|
||||
}
|
||||
|
||||
export function toolName(name: string) {
|
||||
if (name === "imagegen" || name.endsWith("__imagegen")) return "生成图片";
|
||||
if (name === "view_image" || name.endsWith("__view_image")) return "查看图片";
|
||||
if (name === "exec" || name === "exec_command" || name.endsWith("__exec_command")) return "执行命令";
|
||||
if (name === "apply_patch" || name.endsWith("__apply_patch")) return "修改文件";
|
||||
if (name === "web__run" || name.endsWith("__web__run")) return "搜索资料";
|
||||
if (name === "canvas_apply_ops") return "画布操作";
|
||||
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 (name === "imagegen" || name.endsWith("__imagegen")) return tr("tools.generateImage");
|
||||
if (name === "view_image" || name.endsWith("__view_image")) return tr("tools.viewImage");
|
||||
if (name === "exec" || name === "exec_command" || name.endsWith("__exec_command")) return tr("tools.executeCommand");
|
||||
if (name === "apply_patch" || name.endsWith("__apply_patch")) return tr("tools.editFiles");
|
||||
if (name === "web__run" || name.endsWith("__web__run")) return tr("tools.searchWeb");
|
||||
if (toolTranslationKeys[name]) return tr(`tools.${toolTranslationKeys[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) {
|
||||
const data = result && typeof result === "object" ? (result as Record<string, unknown>) : {};
|
||||
if (name === "site_navigate") return `已打开${routeName(stringText(objectField(input, "path")) || "/")}`;
|
||||
if (name === "canvas_list_projects") return `共 ${numberField(data, "total")} 个画布`;
|
||||
if (name === "prompts_search") return `找到 ${numberField(data, "total")} 条提示词`;
|
||||
if (name === "assets_list") return `共 ${numberField(data, "total")} 个资产`;
|
||||
if (name === "assets_add") return "已加入我的素材";
|
||||
if (name === "site_navigate") return tr("openedRoute", { route: routeName(stringText(objectField(input, "path")) || "/") });
|
||||
if (name === "canvas_list_projects") return tr("canvasCount", { count: numberField(data, "total") });
|
||||
if (name === "prompts_search") return tr("promptCount", { count: numberField(data, "total") });
|
||||
if (name === "assets_list") return tr("assetCount", { count: numberField(data, "total") });
|
||||
if (name === "assets_add") return tr("assetAdded");
|
||||
if (name === "generation_get_status") {
|
||||
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_get_config" || name === "workbench_video_get_config") return "已读取工作台配置";
|
||||
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 tr("workbenchConfigRead");
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -379,11 +360,11 @@ export function toolCallDetail(name: string, input: unknown, status: string, err
|
||||
|
||||
function toolInputRows(name: string, input: unknown) {
|
||||
input = parseToolArguments(input);
|
||||
if (name === "site_navigate") return [detailRow("目标页面", routeName(stringText(objectField(input, "path")) || "/"))].flatMap((row) => (row ? [row] : []));
|
||||
if (name === "prompts_search") return [detailRow("搜索内容", 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_apply_ops") return [detailRow("操作内容", 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 === "site_navigate") return [detailRow(tr("targetPage"), routeName(stringText(objectField(input, "path")) || "/"))].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(tr("textContent"), objectField(input, "text"))].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(tr("imageCount"), Array.isArray(objectField(input, "attachmentIds")) ? (objectField(input, "attachmentIds") as unknown[]).length : 0)].flatMap((row) => (row ? [row] : []));
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -395,8 +376,8 @@ export function toolSummary(item?: AgentEventItem) {
|
||||
const connectionField = objectField(result, "connections");
|
||||
const nodes = Array.isArray(nodeField) ? nodeField : [];
|
||||
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_selection") return "已读取当前选中内容";
|
||||
if (name === "canvas_get_state") return Array.isArray(nodeField) || Array.isArray(connectionField) ? canvasContentSummary(nodes, connections.length) : tr("canvasRead");
|
||||
if (name === "canvas_get_selection") return tr("selectionRead");
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -409,33 +390,32 @@ function canvasContentSummary(nodes: unknown[], connections: number) {
|
||||
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 parts = [
|
||||
counts.text ? `${counts.text} 个文本` : "",
|
||||
counts.image ? `${counts.image} 张图片` : "",
|
||||
counts.config ? `${counts.config} 个配置` : "",
|
||||
counts.video ? `${counts.video} 个视频` : "",
|
||||
counts.audio ? `${counts.audio} 个音频` : "",
|
||||
counts.group ? `${counts.group} 个分组` : "",
|
||||
other ? `${other} 个其他节点` : "",
|
||||
connections ? `${connections} 条连线` : "",
|
||||
counts.text ? tr("textNodes", { count: counts.text }) : "",
|
||||
counts.image ? tr("imageNodes", { count: counts.image }) : "",
|
||||
counts.config ? tr("configNodes", { count: counts.config }) : "",
|
||||
counts.video ? tr("videoNodes", { count: counts.video }) : "",
|
||||
counts.audio ? tr("audioNodes", { count: counts.audio }) : "",
|
||||
counts.group ? tr("groupNodes", { count: counts.group }) : "",
|
||||
other ? tr("otherNodes", { count: other }) : "",
|
||||
connections ? tr("connections", { count: connections }) : "",
|
||||
].filter(Boolean);
|
||||
return parts.length ? parts.join("、") : "当前画布为空";
|
||||
return parts.length ? parts.join(i18n.language === "zh-CN" ? "、" : ", ") : tr("emptyCanvas");
|
||||
}
|
||||
|
||||
export function toolAction(name: string) {
|
||||
const label = toolName(name);
|
||||
if (label.startsWith("读取") || label.startsWith("查看") || label.startsWith("搜索") || label.startsWith("打开")) return label;
|
||||
return `执行${label}`;
|
||||
return tr("executeTool", { tool: label });
|
||||
}
|
||||
|
||||
export function routeName(path: string) {
|
||||
if (path === "/") return "首页";
|
||||
if (path === "/canvas") return "画布页面";
|
||||
if (path.startsWith("/canvas/")) return "指定画布";
|
||||
if (path.startsWith("/image")) return "生图工作台";
|
||||
if (path.startsWith("/video")) return "视频工作台";
|
||||
if (path.startsWith("/prompts")) return "提示词中心";
|
||||
if (path.startsWith("/assets")) return "我的素材";
|
||||
if (path.startsWith("/config")) return "配置页面";
|
||||
if (path === "/") return tr("routes.home");
|
||||
if (path === "/canvas") return tr("routes.canvas");
|
||||
if (path.startsWith("/canvas/")) return tr("routes.canvasProject");
|
||||
if (path.startsWith("/image")) return tr("routes.image");
|
||||
if (path.startsWith("/video")) return tr("routes.video");
|
||||
if (path.startsWith("/prompts")) return tr("routes.prompts");
|
||||
if (path.startsWith("/assets")) return tr("routes.assets");
|
||||
if (path.startsWith("/config")) return tr("routes.config");
|
||||
return path;
|
||||
}
|
||||
|
||||
@@ -443,10 +423,10 @@ export function workingActivity(item?: AgentChatItem) {
|
||||
const status = String(objectField(item?.detail, "status") || "");
|
||||
const output = stringText(objectField(item?.detail, "output"));
|
||||
const key = `${item?.id || "waiting"}-${status}-${item?.text || ""}-${output.length}`;
|
||||
if (item?.role !== "tool") return { key, text: "正在思考..." };
|
||||
if (["inProgress", "in_progress", "running", "pending"].includes(status)) return { key, text: `${item.title || "工具操作"}正在进行...` };
|
||||
if (item.title === "读取画布") return { key, text: "画布已读取,Codex 正在整理结果..." };
|
||||
return { key, text: `${item.title || "工具操作"}已完成,Codex 正在继续处理...` };
|
||||
if (item?.role !== "tool") return { key, text: tr("thinking") };
|
||||
if (["inProgress", "in_progress", "running", "pending"].includes(status)) return { key, text: tr("operationRunning", { operation: item.title || tr("toolOperation") }) };
|
||||
if (item.title === toolName("canvas_get_state")) return { key, text: tr("organizingCanvas") };
|
||||
return { key, text: tr("operationCompleted", { operation: item.title || tr("toolOperation") }) };
|
||||
}
|
||||
|
||||
export function currentPlanMessage(messages: AgentChatItem[]) {
|
||||
@@ -503,7 +483,7 @@ function numberField(value: unknown, key: string) {
|
||||
}
|
||||
|
||||
export function promptWithAttachments(text: string, attachments: AgentAttachment[]) {
|
||||
return text || (attachments.length ? "请处理上传的图片附件。" : "");
|
||||
return text || (attachments.length ? tr("attachmentPrompt") : "");
|
||||
}
|
||||
|
||||
export function attachmentPayloadBytes(attachments: AgentAttachment[]) {
|
||||
@@ -615,3 +595,9 @@ export function mergeStreamText(prefix: string, incoming: string) {
|
||||
if (!incoming || prefix.startsWith(incoming)) return 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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Checkbox } from "antd";
|
||||
import { FolderOpen, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import type { AgentThreadSummary } from "@/stores/use-agent-store";
|
||||
@@ -30,6 +31,7 @@ export function AgentHistoryView({
|
||||
onResumeThread: (threadId: string) => void;
|
||||
onDeleteThreads: (threadIds: string[]) => void;
|
||||
}) {
|
||||
const { i18n, t } = useTranslation();
|
||||
const [selectedIds, setSelectedIds] = useState(() => new Set<string>());
|
||||
const selectedThreads = threads.filter((thread) => selectedIds.has(thread.id));
|
||||
const allSelected = Boolean(threads.length) && selectedThreads.length === threads.length;
|
||||
@@ -46,27 +48,27 @@ export function AgentHistoryView({
|
||||
<div className="space-y-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs" style={{ color: theme.node.muted }}>
|
||||
<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}>
|
||||
{workspacePath || "默认画布目录"}
|
||||
{workspacePath || t("agent.history.defaultWorkspace")}
|
||||
</span>
|
||||
</div>
|
||||
<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 }}>
|
||||
{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 className="flex items-center gap-2">
|
||||
{selectedThreads.length ? (
|
||||
<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>
|
||||
) : null}
|
||||
<Button size="small" icon={<RefreshCw className={`size-3.5 ${loading ? "animate-spin" : ""}`} />} disabled={!connected || loading} onClick={onRefresh}>
|
||||
刷新
|
||||
{t("agent.history.refresh")}
|
||||
</Button>
|
||||
<Button size="small" type="primary" icon={<Plus className="size-3.5" />} disabled={!connected || loading || busy} onClick={onNewThread}>
|
||||
新对话
|
||||
{t("agent.history.newThread")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,7 +96,7 @@ export function AgentHistoryView({
|
||||
<Checkbox
|
||||
checked={selectedIds.has(thread.id)}
|
||||
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()}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
onChange={() => toggleThread(thread.id)}
|
||||
@@ -103,21 +105,21 @@ export function AgentHistoryView({
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{active ? (
|
||||
<span className="shrink-0 text-[10px] font-medium" style={{ color: theme.node.text }}>
|
||||
当前
|
||||
{t("agent.history.current")}
|
||||
</span>
|
||||
) : 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 className="truncate text-[11px] leading-4 opacity-65">{thread.preview || thread.id}</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>
|
||||
);
|
||||
})}
|
||||
{!threads.length ? (
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -126,8 +128,7 @@ export function AgentHistoryView({
|
||||
);
|
||||
}
|
||||
|
||||
function formatThreadTime(value?: number) {
|
||||
function formatThreadTime(value: number | undefined, locale: string) {
|
||||
if (!value) return "";
|
||||
return new Date(value * 1000).toLocaleString();
|
||||
return new Date(value * 1000).toLocaleString(locale);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Segmented, Tooltip } from "antd";
|
||||
import copyToClipboard from "copy-to-clipboard";
|
||||
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 type { AgentEventLog } from "@/stores/use-agent-store";
|
||||
import { formatLogJson, formatLogText, type AgentLogContext } from "./agent-event-formatters";
|
||||
@@ -27,6 +29,7 @@ export function AgentLogView({
|
||||
onCopied: (text: string) => void;
|
||||
onCopyBlocked: (text: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setMode] = useState<"text" | "json">("text");
|
||||
const [filter, setFilter] = useState<LogFilter>("all");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -93,28 +96,28 @@ export function AgentLogView({
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [mode, scrollToBottom, updateScrollState, visibleLogCount]);
|
||||
const copy = async (value = content, tip = "日志已复制") => {
|
||||
const copy = async (value = content, tip = t("agent.logs.copied")) => {
|
||||
if (await copyToClipboard(value)) {
|
||||
onCopied(tip);
|
||||
return;
|
||||
}
|
||||
textareaRef.current?.focus();
|
||||
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 (
|
||||
<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 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
|
||||
size="small"
|
||||
value={mode}
|
||||
onChange={(value) => setMode(value as "text" | "json")}
|
||||
options={[
|
||||
{ label: "排查日志", value: "text" },
|
||||
{ label: "原始 JSON", value: "json" },
|
||||
{ label: t("agent.logs.diagnostics"), value: "text" },
|
||||
{ label: t("agent.logs.rawJson"), value: "json" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -134,8 +137,8 @@ export function AgentLogView({
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-[11px] leading-4" style={{ color: theme.node.muted }}>
|
||||
<div>{context.messages} 条消息</div>
|
||||
<div>{context.pendingTool ? `工具:${context.pendingTool}` : "无待处理工具"}</div>
|
||||
<div>{t("agent.logs.messages", { count: context.messages })}</div>
|
||||
<div>{context.pendingTool ? t("agent.logs.tool", { tool: context.pendingTool }) : t("agent.logs.noPendingTool")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,30 +151,30 @@ export function AgentLogView({
|
||||
value={filter}
|
||||
onChange={(value) => setFilter(value as LogFilter)}
|
||||
options={[
|
||||
{ label: `全部 ${counts.all}`, value: "all" },
|
||||
{ label: `错误 ${counts.error}`, value: "error" },
|
||||
{ label: `警告 ${counts.warning}`, value: "warning" },
|
||||
{ label: `信息 ${counts.info}`, value: "info" },
|
||||
{ label: t("agent.logs.all", { count: counts.all }), value: "all" },
|
||||
{ label: t("agent.logs.errors", { count: counts.error }), value: "error" },
|
||||
{ label: t("agent.logs.warnings", { count: counts.warning }), value: "warning" },
|
||||
{ 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} />
|
||||
</div>
|
||||
<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) => (
|
||||
<LogRow key={item.id} item={item} theme={theme} onToggle={index === visibleLogs.length - 1 ? handleLastLogToggle : undefined} />
|
||||
))}
|
||||
{!visibleLogs.length ? (
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
{showScrollToBottom ? (
|
||||
<AgentScrollToBottom
|
||||
theme={theme}
|
||||
title={newLogCount ? `${newLogCount} 条新日志` : "查看最新日志"}
|
||||
ariaLabel={newLogCount ? `${newLogCount} 条新日志,查看最新日志` : "查看最新日志"}
|
||||
title={newLogCount ? t("agent.logs.new", { count: newLogCount }) : t("agent.logs.latest")}
|
||||
ariaLabel={newLogCount ? t("agent.logs.newLabel", { count: newLogCount }) : t("agent.logs.latest")}
|
||||
className="!bottom-52"
|
||||
onClick={() => scrollToBottom()}
|
||||
/>
|
||||
@@ -182,7 +185,7 @@ export function AgentLogView({
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs" style={{ color: theme.node.muted }}>
|
||||
完整诊断数据 · {logs.length} 条
|
||||
{t("agent.logs.fullData", { count: logs.length })}
|
||||
</span>
|
||||
<LogActions logs={logs} lastError={lastError} onClear={onClear} onCopy={(value, tip) => void copy(value, tip)} context={context} />
|
||||
</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 }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Tooltip title="复制全部日志">
|
||||
<Button type="text" size="small" shape="circle" aria-label="复制全部日志" icon={<Copy className="size-3.5" />} onClick={() => onCopy()} />
|
||||
<Tooltip title={t("agent.logs.copyAll")}>
|
||||
<Button type="text" size="small" shape="circle" aria-label={t("agent.logs.copyAll")} icon={<Copy className="size-3.5" />} onClick={() => onCopy()} />
|
||||
</Tooltip>
|
||||
<Tooltip title="复制最近错误">
|
||||
<Button type="text" size="small" shape="circle" aria-label="复制最近错误" disabled={!lastError} icon={<CircleAlert className="size-3.5" />} onClick={() => lastError && onCopy(formatLogText([lastError], context), "最近错误已复制")} />
|
||||
<Tooltip title={t("agent.logs.copyLastError")}>
|
||||
<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 title="清空日志">
|
||||
<Button danger type="text" size="small" shape="circle" aria-label="清空日志" disabled={!logs.length} icon={<Trash2 className="size-3.5" />} onClick={onClear} />
|
||||
<Tooltip title={t("agent.logs.clear")}>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 Icon = item.level === "error" ? CircleAlert : item.level === "warning" ? TriangleAlert : item.success ? CheckCircle2 : CircleDot;
|
||||
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>
|
||||
{item.count > 1 ? (
|
||||
<span className="shrink-0 text-[10px] leading-4" style={{ color: theme.node.muted }}>
|
||||
重复 {item.count} 次
|
||||
{t("agent.logs.repeated", { count: item.count })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -248,7 +253,7 @@ function LogRow({ item, theme, onToggle }: { item: DisplayLog; theme: (typeof ca
|
||||
</summary>
|
||||
<div className="pb-3 pl-[34px] pr-2">
|
||||
<div className="mb-1 text-[10px] font-medium" style={{ color: theme.node.faint }}>
|
||||
详细信息
|
||||
{t("agent.logs.details")}
|
||||
</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 }}>
|
||||
{item.detail}
|
||||
@@ -323,13 +328,13 @@ function declaredLogLevel(value: unknown): DisplayLog["level"] | "" {
|
||||
}
|
||||
|
||||
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();
|
||||
if (target.includes("skill")) return "技能加载";
|
||||
if (target.includes("plugin")) return "插件";
|
||||
if (target.includes("skill")) return i18n.t("agent.logs.skillLoading");
|
||||
if (target.includes("plugin")) return i18n.t("agent.logs.plugin");
|
||||
if (target.includes("mcp") || target.includes("rmcp")) return "MCP";
|
||||
if (target.includes("shell")) return "终端";
|
||||
if (target.includes("state_db")) return "会话存储";
|
||||
if (target.includes("shell")) return i18n.t("agent.logs.terminal");
|
||||
if (target.includes("state_db")) return i18n.t("agent.logs.conversationStorage");
|
||||
return "Codex";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Tooltip } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
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 }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="@container border-b px-2" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="flex h-12 items-center gap-1">
|
||||
{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) => (
|
||||
<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)}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { LocalAgentPanel } from "./local-agent-panel";
|
||||
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;
|
||||
|
||||
export function AgentPanel() {
|
||||
const { t } = useTranslation();
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const width = useAgentStore((state) => state.width);
|
||||
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] }}
|
||||
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 />
|
||||
</motion.aside>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Input, Popover, Tooltip } from "antd";
|
||||
import { Check, Search, Sparkles } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
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";
|
||||
|
||||
export function AgentSkillPicker() {
|
||||
const { t } = useTranslation();
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const connected = useAgentStore((state) => state.connected);
|
||||
const url = useAgentStore((state) => state.url);
|
||||
@@ -26,8 +28,8 @@ export function AgentSkillPicker() {
|
||||
}, [query, skills]);
|
||||
const content = (
|
||||
<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>
|
||||
<Input allowClear size="small" value={query} onChange={(event) => setQuery(event.target.value)} prefix={<Search className="size-3.5" />} placeholder="搜索 Skill" />
|
||||
<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={t("agent.skills.search")} />
|
||||
<div className="thin-scrollbar mt-2 max-h-64 overflow-y-auto">
|
||||
{enabledSkills.length ? enabledSkills.map((skill) => {
|
||||
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 }}>
|
||||
{loading ? "正在读取 Skill…" : skills.length ? "没有匹配的已启用 Skill" : "还没有可用的 Skill"}
|
||||
{t(loading ? "agent.skills.loading" : skills.length ? "agent.skills.noMatch" : "agent.skills.none")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
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
|
||||
arrow={false}
|
||||
trigger="click"
|
||||
@@ -77,7 +79,7 @@ export function AgentSkillPicker() {
|
||||
disabled={!connected}
|
||||
style={{ color: selectedSkill ? theme.node.text : theme.node.muted, background: selectedSkill ? theme.toolbar.activeBg : undefined }}
|
||||
icon={<Sparkles className="size-4" />}
|
||||
aria-label="选择 Skill"
|
||||
aria-label={t("agent.skills.select")}
|
||||
/>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
|
||||
@@ -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 type { MenuProps } from "antd";
|
||||
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 { 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 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]+)*$/;
|
||||
|
||||
export function AgentSkillsView({ clientId }: { clientId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const { message, modal } = App.useApp();
|
||||
const connected = useAgentStore((state) => state.connected);
|
||||
@@ -89,16 +90,16 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
|
||||
};
|
||||
const generateDraft = async (source: SkillDraftSource) => {
|
||||
const agent = useAgentStore.getState();
|
||||
if (agent.sending || agent.waiting) return message.warning("Codex 正在运行,请完成当前任务后再提炼 Skill");
|
||||
if (source === "conversation" && !hasSettledConversation(agent.messages, agent.activeThreadId)) return message.warning("当前对话还没有可提炼的已完成内容");
|
||||
if (source === "canvas" && !agent.canvasContext) return message.warning("当前页面没有可提炼的画布");
|
||||
if (!clientId) return message.warning("当前页面仍在连接 Agent,请稍后再试");
|
||||
if (agent.sending || agent.waiting) return message.warning(t("agent.skillManager.codexBusy"));
|
||||
if (source === "conversation" && !hasSettledConversation(agent.messages, agent.activeThreadId)) return message.warning(t("agent.skillManager.noConversation"));
|
||||
if (source === "canvas" && !agent.canvasContext) return message.warning(t("agent.skillManager.noCanvas"));
|
||||
if (!clientId) return message.warning(t("agent.skillManager.connecting"));
|
||||
const connectionRevision = useAgentSkillStore.getState().connectionRevision;
|
||||
setGeneratingSource(source);
|
||||
try {
|
||||
if (source === "canvas") {
|
||||
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;
|
||||
const response = await createCodexSkillDraft(endpoint, token, {
|
||||
@@ -109,11 +110,11 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
|
||||
...(agent.reasoningEffort ? { effort: agent.reasoningEffort } : {}),
|
||||
});
|
||||
if (!connectionIsCurrent(connectionRevision)) return;
|
||||
if (!response.data) throw new Error("未生成 Skill 草稿");
|
||||
if (!response.data) throw new Error(t("agent.skillManager.noDraft"));
|
||||
setDraft(response.data);
|
||||
message.success("草稿已生成,可在技能页确认后创建");
|
||||
message.success(t("agent.skillManager.draftCreated"));
|
||||
} 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 {
|
||||
if (connectionIsCurrent(connectionRevision)) setGeneratingSource(null);
|
||||
}
|
||||
@@ -125,10 +126,10 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
|
||||
try {
|
||||
const response = await fetchCodexSkill(endpoint, token, skill.name);
|
||||
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 });
|
||||
} 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 {
|
||||
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 skillInterface = compactInterface(values);
|
||||
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);
|
||||
requestAnimationFrame(() => form.scrollToField("defaultPrompt", { block: "center" }));
|
||||
return;
|
||||
@@ -165,9 +166,9 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
|
||||
setAdvancedOpen(false);
|
||||
await refresh();
|
||||
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) {
|
||||
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 {
|
||||
if (connectionIsCurrent(connectionRevision)) setSaving(false);
|
||||
}
|
||||
@@ -175,27 +176,27 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
|
||||
const confirmDelete = (skill: AgentSkillSummary) => {
|
||||
const connectionRevision = useAgentSkillStore.getState().connectionRevision;
|
||||
confirmRef.current = modal.confirm({
|
||||
title: `删除 ${skill.interface?.displayName || skill.name}`,
|
||||
content: "删除后本地文件无法恢复,确定继续吗?",
|
||||
okText: "删除",
|
||||
title: t("agent.skillManager.deleteTitle", { name: skill.interface?.displayName || skill.name }),
|
||||
content: t("agent.skillManager.deleteDescription"),
|
||||
okText: t("agent.skillManager.delete"),
|
||||
okType: "danger",
|
||||
cancelText: "取消",
|
||||
cancelText: t("common.cancel"),
|
||||
onOk: async () => {
|
||||
if (!connectionIsCurrent(connectionRevision)) return;
|
||||
setBusySkill(skill.path);
|
||||
try {
|
||||
const response = await fetchCodexSkill(endpoint, token, skill.name);
|
||||
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);
|
||||
if (!connectionIsCurrent(connectionRevision)) return;
|
||||
if (selectedSkill?.name === skill.name && selectedSkill.path === skill.path) clearSelection();
|
||||
await refresh();
|
||||
if (!connectionIsCurrent(connectionRevision)) return;
|
||||
message.success("Skill 已删除");
|
||||
message.success(t("agent.skillManager.deleted"));
|
||||
} catch (error) {
|
||||
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;
|
||||
} finally {
|
||||
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();
|
||||
await refresh();
|
||||
} 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 {
|
||||
if (connectionIsCurrent(connectionRevision)) setBusySkill("");
|
||||
}
|
||||
@@ -230,8 +231,8 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
|
||||
disabled: codexBusy || !hasConversation,
|
||||
label: (
|
||||
<div className="py-0.5">
|
||||
<div className="text-sm">从当前对话生成草稿</div>
|
||||
<div className="mt-0.5 text-xs" style={{ color: theme.node.muted }}>{codexBusy ? "Codex 运行结束后可用" : hasConversation ? "整理当前对话中的可复用流程" : activeThreadId ? "当前对话还没有已完成内容" : "请先开始一段对话"}</div>
|
||||
<div className="text-sm">{t("agent.skillManager.fromConversation")}</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>
|
||||
),
|
||||
},
|
||||
@@ -241,8 +242,8 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
|
||||
disabled: codexBusy || !hasCanvas,
|
||||
label: (
|
||||
<div className="py-0.5">
|
||||
<div className="text-sm">从当前画布生成草稿</div>
|
||||
<div className="mt-0.5 text-xs" style={{ color: theme.node.muted }}>{codexBusy ? "Codex 运行结束后可用" : hasCanvas ? "整理当前页面的节点与生成流程" : "当前页面没有可用画布"}</div>
|
||||
<div className="text-sm">{t("agent.skillManager.fromCanvas")}</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>
|
||||
),
|
||||
},
|
||||
@@ -252,12 +253,11 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
|
||||
icon: <FilePenLine className="size-4" />,
|
||||
label: (
|
||||
<div className="py-0.5">
|
||||
<div className="text-sm">空白创建</div>
|
||||
<div className="mt-0.5 text-xs" style={{ color: theme.node.muted }}>从空白表单开始编写</div>
|
||||
<div className="text-sm">{t("agent.skillManager.blankCreate")}</div>
|
||||
<div className="mt-0.5 text-xs" style={{ color: theme.node.muted }}>{t("agent.skillManager.blankDescription")}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === "manual") {
|
||||
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="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">本地 Skill</div>
|
||||
<div className="mt-0.5 text-xs" style={{ color: theme.node.muted }}>安装在本机,由 Codex 直接执行</div>
|
||||
<div className="text-sm font-semibold">{t("agent.skillManager.localSkills")}</div>
|
||||
<div className="mt-0.5 text-xs" style={{ color: theme.node.muted }}>{t("agent.skillManager.localDescription")}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip title="重新读取">
|
||||
<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()} />
|
||||
<Tooltip title={t("agent.skillManager.reload")}>
|
||||
<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>
|
||||
<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" />}>
|
||||
创建 Skill <ChevronDown className="size-3.5 opacity-60" />
|
||||
{t("agent.skillManager.createSkill")} <ChevronDown className="size-3.5 opacity-60" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
size="small"
|
||||
variant="borderless"
|
||||
aria-label="按来源筛选 Skill"
|
||||
aria-label={t("agent.skillManager.filterBySource")}
|
||||
className="w-28 shrink-0"
|
||||
disabled={!connected}
|
||||
value={scope}
|
||||
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>
|
||||
{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}
|
||||
</div>
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto px-4">
|
||||
{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 ? (
|
||||
<div className="divide-y" style={{ borderColor: theme.node.stroke }}>
|
||||
{filteredSkills.map((skill) => {
|
||||
@@ -318,25 +318,25 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<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 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}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<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 }}>
|
||||
<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>
|
||||
<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 ? (
|
||||
<>
|
||||
<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="删除"><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.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={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}
|
||||
</div>
|
||||
@@ -348,23 +348,23 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
|
||||
) : (
|
||||
<div className="flex h-48 flex-col items-center justify-center text-center">
|
||||
<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-1 text-xs" style={{ color: theme.node.muted }}>{!connected ? "连接成功后会读取本机已安装的 Skill" : skills.length ? "换个关键词或来源试试" : "创建一个,或在本机安装后刷新"}</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 }}>{t(!connected ? "agent.skillManager.connectDescription" : skills.length ? "agent.skillManager.tryAnotherFilter" : "agent.skillManager.createOrInstall")}</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 }}>
|
||||
{errors.map((error, index) => <div key={`${index}:${error}`} className="break-all py-1" style={{ color: theme.node.muted }}>{error}</div>)}
|
||||
</div>
|
||||
</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)}
|
||||
okText={editor?.mode === "edit" ? "保存更改" : "创建 Skill"}
|
||||
cancelText="取消"
|
||||
okText={t(editor?.mode === "edit" ? "agent.skillManager.saveChanges" : "agent.skillManager.createSkill")}
|
||||
cancelText={t("common.cancel")}
|
||||
confirmLoading={saving}
|
||||
width={680}
|
||||
centered
|
||||
@@ -378,17 +378,17 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
|
||||
}}
|
||||
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}>
|
||||
<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">
|
||||
<Form.Item name="name" label="Skill 标识" extra="用于文件夹名和 $skill-name 调用。" rules={[{ required: true, message: "请输入 Skill 标识" }, { max: 64, message: "Skill 标识不能超过 64 个字符" }, { pattern: skillNamePattern, message: "仅支持小写字母、数字和连字符,连字符不能连续或位于首尾" }]}>
|
||||
<Input maxLength={64} disabled={editor?.mode === "edit"} placeholder="例如 product-grid" />
|
||||
<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={t("agent.skillManager.identifierPlaceholder")} />
|
||||
</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>
|
||||
<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="instructions" label="执行说明" extra="按实际执行顺序写清步骤、约束和输出要求。" rules={[{ required: true, message: "请输入执行说明" }]}><Input.TextArea className="!leading-6" autoSize={{ minRows: 6, maxRows: 10 }} 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={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
|
||||
ghost
|
||||
size="small"
|
||||
@@ -398,11 +398,11 @@ export function AgentSkillsView({ clientId }: { clientId: string }) {
|
||||
items={[{
|
||||
key: "advanced",
|
||||
forceRender: true,
|
||||
label: <span className="text-sm font-medium">高级设置</span>,
|
||||
label: <span className="text-sm font-medium">{t("agent.skillManager.advanced")}</span>,
|
||||
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="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="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={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>
|
||||
</>
|
||||
),
|
||||
}]}
|
||||
|
||||
@@ -3,7 +3,9 @@ import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { App, Button, Tooltip } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
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 { imageMetadata } from "@/lib/canvas/canvas-node-factory";
|
||||
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 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_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 AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; conversation?: AgentConversationState; data?: AgentThreadSummary[] };
|
||||
@@ -95,29 +97,30 @@ function agentErrorState(error: unknown) {
|
||||
function conversationBootstrapView(conversation: AgentConversationState) {
|
||||
const mcpStartupStatuses: Record<string, AgentBootstrapStatus> = Object.fromEntries(Object.entries(conversation.mcpStatuses).map(([name, item]) => {
|
||||
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"
|
||||
? { key: `mcp:${name}:ready`, text: `MCP 已就绪:${name}`, detail: "工具列表加载完成,可以开始对话", status: "ready" }
|
||||
: { key: `mcp:${name}:${item.status}`, text: item.status === "failed" ? `MCP 启动失败:${name}` : `MCP 启动已取消:${name}`, detail: item.error || "工具服务未能完成初始化", status: "error" };
|
||||
? { key: `mcp:${name}:ready`, text: rt("mcpReadyNamed", { name }), detail: rt("toolsReady"), status: "ready" }
|
||||
: { key: `mcp:${name}:${item.status}`, text: rt(item.status === "failed" ? "mcpFailedNamed" : "mcpCanceledNamed", { name }), detail: item.error || rt("toolInitFailed"), status: "error" };
|
||||
return [name, view];
|
||||
}));
|
||||
const services = Object.values(mcpStartupStatuses);
|
||||
const pending = services.filter((item) => item.status === "running").length;
|
||||
const bootstrapStatus: AgentBootstrapStatus | null = conversation.status === "idle" || conversation.status === "preparing"
|
||||
? services.length
|
||||
? { key: "mcp:starting", text: "正在启动 MCP 服务", detail: pending ? `还有 ${pending} 个工具服务正在初始化` : "正在确认工具服务状态", status: "running" }
|
||||
: { key: "codex:preparing", text: "正在初始化 Codex 对话", detail: "正在创建会话并启动画布工具服务", status: "running" }
|
||||
? { key: "mcp:starting", text: rt("mcpServicesStarting"), detail: pending ? rt("toolServicesPending", { count: pending }) : rt("checkingToolServices"), status: "running" }
|
||||
: { key: "codex:preparing", text: rt("conversationInitializing"), detail: rt("conversationCreating"), status: "running" }
|
||||
: 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"
|
||||
? { 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"
|
||||
? { 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;
|
||||
return { bootstrapStatus, mcpStartupStatuses };
|
||||
}
|
||||
|
||||
export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?: boolean; headless?: boolean; autoConnect?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const { message, modal } = App.useApp();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -307,7 +310,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
liveTurnKeysRef.current.clear();
|
||||
}
|
||||
} catch (error) {
|
||||
addEventLog("读取历史失败", error);
|
||||
addEventLog(rt("historyReadFailed"), error);
|
||||
} finally {
|
||||
if (sequence === loadThreadsSequenceRef.current && !threadOperationRef.current) setAgentState({ loadingThreads: false });
|
||||
}
|
||||
@@ -348,7 +351,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
eventQueue = eventQueue.then(async () => {
|
||||
if (isCurrentConnection()) await task();
|
||||
}).catch((error) => {
|
||||
if (isCurrentConnection()) addEventLog("同步会话失败", error);
|
||||
if (isCurrentConnection()) addEventLog(rt("conversationSyncFailed"), error);
|
||||
});
|
||||
};
|
||||
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;
|
||||
const hello = parseEventData<AgentHelloEvent>(event);
|
||||
if (hello?.protocolVersion !== AGENT_PROTOCOL_VERSION) {
|
||||
const text = "本地 Agent 版本过旧,请重启 Canvas Agent 后重新连接";
|
||||
const text = rt("agentOutdated");
|
||||
protocolRejected = true;
|
||||
source.close();
|
||||
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();
|
||||
addEventLog("Agent 版本不匹配", text, hello);
|
||||
addEventLog(rt("versionMismatch"), text, hello);
|
||||
if (!headless) message.error(text);
|
||||
return;
|
||||
}
|
||||
@@ -383,7 +386,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
connectedRef.current = true;
|
||||
setAgentState({
|
||||
connected: true,
|
||||
activity: pendingApprovals.length ? "等待权限确认" : busy ? "Codex 正在运行" : "已连接",
|
||||
activity: pendingApprovals.length ? rt("awaitingApproval") : busy ? rt("codexRunning") : rt("connected"),
|
||||
waiting: busy,
|
||||
sending: false,
|
||||
connectError: "",
|
||||
@@ -393,7 +396,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
messages,
|
||||
pendingApprovals,
|
||||
});
|
||||
if (!headless) message.success("本地 Agent 已连接");
|
||||
if (!headless) message.success(rt("localAgentConnected"));
|
||||
void postState(endpoint, token, clientId, canvasContextRef.current?.snapshot || null);
|
||||
if (document.visibilityState === "visible" && document.hasFocus()) void activateAgentClient(endpoint, token, clientId);
|
||||
if (!busy && !nextThreadId && (!hello?.conversation || hello.conversation.status === "idle")) {
|
||||
@@ -402,7 +405,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
.catch((error) => {
|
||||
const state = agentErrorState(error);
|
||||
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 messages = activeTurnId ? bindPendingTurnMessages(current.messages, current.activeThreadId, activeTurnId) : current.messages;
|
||||
setAgentState({
|
||||
activity: busy ? "Codex 正在运行" : current.activity === "处理失败" ? "处理失败" : "完成",
|
||||
activity: busy ? rt("codexRunning") : current.activity === rt("processingFailed") ? rt("processingFailed") : rt("completed"),
|
||||
waiting: busy,
|
||||
sending: false,
|
||||
activeTurnId,
|
||||
@@ -437,8 +440,8 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
if (!isCurrentConnection()) return;
|
||||
const data = parseEventData<AgentPendingApproval>(event);
|
||||
if (!data || !isCurrentThreadEvent(data)) return;
|
||||
setAgentState({ pendingApprovals: [...useAgentStore.getState().pendingApprovals.filter((item) => item.requestId !== data.requestId), data], activity: "等待权限确认" });
|
||||
addEventLog("等待权限确认", data.reason || data.method, data);
|
||||
setAgentState({ pendingApprovals: [...useAgentStore.getState().pendingApprovals.filter((item) => item.requestId !== data.requestId), data], activity: rt("awaitingApproval") });
|
||||
addEventLog(rt("awaitingApproval"), data.reason || data.method, data);
|
||||
});
|
||||
source.addEventListener("codex_approval_resolved", (event) => {
|
||||
if (!isCurrentConnection()) return;
|
||||
@@ -449,7 +452,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
const pendingApprovals = current.pendingApprovals.filter((item) => item.requestId !== data.requestId);
|
||||
setAgentState({ pendingApprovals, activity: approvalActivity(pendingApprovals, current.waiting, current.activity) });
|
||||
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) => {
|
||||
const data = parseEventData<AgentEventPayload>(event);
|
||||
@@ -464,26 +467,26 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
const data = parseEventData<AgentBootstrapEvent>(event);
|
||||
if (!data?.type) return;
|
||||
if (data.type === "codex.preparing") {
|
||||
addEventLog("正在初始化 Codex 对话", "正在创建会话并启动画布工具服务", data);
|
||||
addEventLog(rt("conversationInitializing"), rt("conversationCreating"), data);
|
||||
return;
|
||||
}
|
||||
if (data.type === "codex.prepare_failed") {
|
||||
addEventLog("Codex 对话初始化失败", data.error, data);
|
||||
addEventLog(rt("conversationInitFailed"), data.error, data);
|
||||
return;
|
||||
}
|
||||
if (data.type === "mcp.complete") {
|
||||
addEventLog("MCP 状态确认完成", "已读取 Codex 返回的完整工具服务清单", data);
|
||||
addEventLog(rt("mcpStatusComplete"), rt("mcpListRead"), data);
|
||||
return;
|
||||
}
|
||||
if (!data.name || !data.status) return;
|
||||
const label = data.name;
|
||||
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"
|
||||
? { text: `MCP 已就绪:${label}`, detail: "工具列表加载完成,可以开始对话", status: "ready" as const }
|
||||
? { text: rt("mcpReadyNamed", { name: label }), detail: rt("toolsReady"), status: "ready" as const }
|
||||
: data.status === "failed"
|
||||
? { text: `MCP 启动失败:${label}`, detail: data.error || "工具服务未能完成初始化", status: "error" as const }
|
||||
: { text: `MCP 启动已取消:${label}`, detail: "工具服务初始化已取消", status: "error" as const };
|
||||
? { text: rt("mcpFailedNamed", { name: label }), detail: data.error || rt("toolInitFailed"), status: "error" as const }
|
||||
: { text: rt("mcpCanceledNamed", { name: label }), detail: rt("toolInitCanceled"), status: "error" as const };
|
||||
addEventLog(status.text, status.detail, data);
|
||||
});
|
||||
source.addEventListener("conversation_changed", (event) => {
|
||||
@@ -542,7 +545,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
source.addEventListener("agent_log", (event) => {
|
||||
if (!isCurrentConnection()) return;
|
||||
const text = parseEventData<{ text?: unknown }>(event)?.text;
|
||||
addEventLog("日志", text, text);
|
||||
addEventLog(rt("log"), text, text);
|
||||
});
|
||||
source.addEventListener("skills_changed", (event) => {
|
||||
if (!isCurrentConnection()) return;
|
||||
@@ -562,16 +565,16 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
if (disposed || protocolRejected) return;
|
||||
const wasConnected = connectedRef.current;
|
||||
const silent = useAgentStore.getState().silentConnect && !wasConnected;
|
||||
const text = wasConnected ? "本地 Agent 连接失败或已断开" : "连接失败,请检查地址和 token";
|
||||
const text = rt(wasConnected ? "connectionLostDescription" : "connectionFailedDescription");
|
||||
if (!errorLoggedRef.current || wasConnected) {
|
||||
addEventLog(wasConnected ? "连接断开" : "连接失败", text);
|
||||
addEventLog(rt(wasConnected ? "connectionLost" : "connectionFailed"), text);
|
||||
if (!headless && !silent) message.error(text);
|
||||
}
|
||||
errorLoggedRef.current = true;
|
||||
connectedRef.current = false;
|
||||
pendingToolRef.current = null;
|
||||
setAgentState({
|
||||
activity: wasConnected ? "连接断开" : "连接失败",
|
||||
activity: rt(wasConnected ? "connectionLost" : "connectionFailed"),
|
||||
connected: false,
|
||||
waiting: 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-reasoning-effort", nextEffort);
|
||||
setAgentState({ models, model: current.model, reasoningEffort: nextEffort });
|
||||
}).catch((error) => addEventLog("读取模型列表失败", error));
|
||||
}).catch((error) => addEventLog(rt("modelListFailed"), error));
|
||||
}, [connected, endpoint, setAgentState, token]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -650,22 +653,22 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
const currentState = useAgentStore.getState();
|
||||
if (!currentState.connected || !requestPrompt || currentState.sending || currentState.waiting || currentState.loadingThreads || !["ready", "warning"].includes(currentState.conversation.status)) return;
|
||||
if (attachmentPayloadBytes(files) > MAX_ATTACHMENT_PAYLOAD_BYTES) {
|
||||
addMessage({ role: "error", title: "图片过大", text: "图片附件超过 30MB,请删减后再发送。" });
|
||||
addMessage({ role: "error", title: rt("imageTooLarge"), text: rt("imagePayloadTooLarge") });
|
||||
return;
|
||||
}
|
||||
const messageId = createId();
|
||||
const userText = text || `发送了 ${files.length} 张图片`;
|
||||
const userText = text || rt("imagesSent", { count: files.length });
|
||||
loadThreadsSequenceRef.current += 1;
|
||||
const currentBeforeSend = useAgentStore.getState();
|
||||
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 });
|
||||
let threadId = requestThreadId;
|
||||
try {
|
||||
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 effortName = reasoningEffort ? AGENT_REASONING_LABELS[reasoningEffort] : "默认强度";
|
||||
addEventLog("发送任务", `${modelName} · ${effortName}${selectedSkill ? ` · Skill ${selectedSkill.name}` : ""}${files.length ? ` · 附件 ${files.length}` : ""} · ${compactText(text) || "仅附件"}`);
|
||||
const modelName = models.find((item) => item.model === model)?.displayName || model || rt("defaultModel");
|
||||
const effortName = reasoningEffort ? i18n.t(`agent.composer.effort.${reasoningEffort}`) : rt("defaultEffort");
|
||||
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", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -685,12 +688,12 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
}),
|
||||
});
|
||||
threadId = accepted.threadId || threadId;
|
||||
if (!threadId) throw new Error("启动对话失败");
|
||||
if (!threadId) throw new Error(rt("startConversationFailed"));
|
||||
if (selectedSkill) clearSkillSelection(selectedSkillRevision);
|
||||
if (files.length) {
|
||||
const latestMessage = useAgentStore.getState().messages.find((item) => item.clientMessageId === messageId);
|
||||
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) => {
|
||||
URL.revokeObjectURL(item.url);
|
||||
@@ -698,7 +701,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
});
|
||||
} catch (error) {
|
||||
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;
|
||||
if (response?.state) applyConversationState(response.state);
|
||||
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 };
|
||||
if (ownsCurrentThread) {
|
||||
setAgentState({
|
||||
activity: stale ? "会话已同步" : busy ? "Codex 正在运行" : "发送失败",
|
||||
activity: rt(stale ? "conversationSynced" : busy ? "codexRunning" : "sendFailed"),
|
||||
sending: false,
|
||||
messages: removeFailedPending(state.messages),
|
||||
...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 {
|
||||
setAgentState({ sending: false, messages: removeFailedPending(state.messages), ...restoreDraft });
|
||||
}
|
||||
addEventLog("发送失败", error);
|
||||
addEventLog(rt("sendFailed"), error);
|
||||
}
|
||||
};
|
||||
|
||||
const stopTurn = async () => {
|
||||
if (!connected || (!sending && !waiting)) return;
|
||||
setAgentState({ activity: "停止中" });
|
||||
setAgentState({ activity: rt("stopping") });
|
||||
try {
|
||||
await interruptCodexTurn(endpoint, token, useAgentStore.getState().activeThreadId || undefined);
|
||||
addEventLog("停止任务", "任务已停止");
|
||||
addEventLog(rt("stopTask"), rt("taskStopped"));
|
||||
} catch (error) {
|
||||
setAgentState({ activity: "停止失败" });
|
||||
addEventLog("停止失败", error);
|
||||
setAgentState({ activity: rt("stopFailed") });
|
||||
addEventLog(rt("stopFailed"), error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -758,12 +761,12 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
URL.revokeObjectURL(item.url);
|
||||
attachmentUrlsRef.current.delete(item.url);
|
||||
});
|
||||
addMessage({ role: "error", title: "图片过大", text: "图片附件最多约 30MB。" });
|
||||
addMessage({ role: "error", title: rt("imageTooLarge"), text: rt("imageLimit") });
|
||||
return;
|
||||
}
|
||||
if (next.length) setAgentState({ attachments: merged });
|
||||
} 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) => {
|
||||
if (confirmToolsRef.current && isCanvasWriteTool(payload.name)) {
|
||||
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;
|
||||
}
|
||||
pendingToolRef.current = payload;
|
||||
setAgentState({ pendingTool: payload });
|
||||
addEventLog("等待确认", payload, payload);
|
||||
addEventLog(rt("awaitingConfirmation"), payload, payload);
|
||||
return;
|
||||
}
|
||||
await runToolCall(endpoint, token, payload);
|
||||
@@ -796,9 +799,9 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
addEventLog(toolName(payload.name), payload, payload);
|
||||
const result = await runSiteTool(payload.name, payload.input || {}, navigate, { canvasSnapshot: canvasContextRef.current?.snapshot || null });
|
||||
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) {
|
||||
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 });
|
||||
}
|
||||
return;
|
||||
@@ -814,31 +817,31 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
result = { ok: true, path };
|
||||
} else if (payload.name === "canvas_apply_ops") {
|
||||
const context = canvasContextRef.current;
|
||||
if (!context) throw new Error("当前不在画布页,请先用 site_navigate 打开画布");
|
||||
if (!context) throw new Error(rt("openCanvasFirst"));
|
||||
result = context.applyOps(appliedOps);
|
||||
void postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot);
|
||||
} else if (payload.name === "canvas_create_attachment_nodes") {
|
||||
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);
|
||||
result = context.applyOps(appliedOps);
|
||||
await postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot);
|
||||
} else {
|
||||
const snapshot = canvasContextRef.current?.snapshot;
|
||||
if (!snapshot) throw new Error("当前不在画布页,请先用 site_navigate 打开画布");
|
||||
if (!snapshot) throw new Error(rt("openCanvasFirst"));
|
||||
result = snapshot;
|
||||
}
|
||||
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) {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
const rejectPendingTool = async () => {
|
||||
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;
|
||||
setAgentState({ pendingTool: null });
|
||||
};
|
||||
@@ -855,11 +858,11 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
const current = useAgentStore.getState();
|
||||
const pending = current.pendingApprovals.find((item) => item.requestId === approval.requestId);
|
||||
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 {
|
||||
await postCodexApproval(endpoint, token, approval.requestId, decision);
|
||||
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) {
|
||||
const latest = useAgentStore.getState();
|
||||
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);
|
||||
setAgentState({ pendingApprovals, activity: approvalActivity(pendingApprovals, latest.waiting, latest.activity) });
|
||||
if (resolved) return;
|
||||
addEventLog("权限审批失败", error);
|
||||
message.error(error instanceof Error ? error.message : "权限审批失败");
|
||||
addEventLog(rt("approvalFailed"), error);
|
||||
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();
|
||||
modal.confirm({
|
||||
title: "启用完全访问权限",
|
||||
content: "Codex 将不受沙箱限制,可访问互联网及本机任意文件。请仅在信任当前任务时使用。",
|
||||
okText: "启用完全访问",
|
||||
title: rt("enableFullAccess"),
|
||||
content: rt("fullAccessDescription"),
|
||||
okText: rt("enableFullAccessAction"),
|
||||
okType: "danger",
|
||||
cancelText: "取消",
|
||||
cancelText: t("common.cancel"),
|
||||
onOk: apply,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAgentConnection = async ({ silent = false }: { silent?: boolean } = {}) => {
|
||||
if (enabled) {
|
||||
clearAgentSession({ enabled: false, connected: false, activity: "离线", connectError: "" });
|
||||
clearAgentSession({ enabled: false, connected: false, activity: rt("offline"), connectError: "" });
|
||||
return;
|
||||
}
|
||||
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 nextToken = (urlToken || token.trim() || discovered?.token || "").trim();
|
||||
if (!nextEndpoint) {
|
||||
const text = "请填写本地 Agent 地址";
|
||||
const text = rt("addressRequired");
|
||||
if (!silent) {
|
||||
setAgentState({ connectError: text });
|
||||
if (!headless) message.warning(text);
|
||||
@@ -909,7 +912,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
return;
|
||||
}
|
||||
if (!nextToken) {
|
||||
const text = "没有发现本地 Agent,请先在 Codex 使用插件或手动启动 Canvas Agent";
|
||||
const text = rt("agentNotFound");
|
||||
if (!silent) {
|
||||
setAgentState({ connectError: text });
|
||||
if (!headless) message.warning(text);
|
||||
@@ -920,7 +923,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
const parsed = new URL(nextEndpoint);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("invalid protocol");
|
||||
} catch {
|
||||
const text = "本地 Agent 地址格式不正确";
|
||||
const text = rt("invalidAddress");
|
||||
if (!silent) {
|
||||
setAgentState({ connectError: text });
|
||||
if (!headless) message.warning(text);
|
||||
@@ -928,7 +931,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
return;
|
||||
}
|
||||
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(() => {
|
||||
@@ -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;
|
||||
const operation = beginThreadOperation();
|
||||
clearSkillSelection();
|
||||
setAgentState({ activeTab: "chat", activity: "正在新建对话" });
|
||||
setAgentState({ activeTab: "chat", activity: rt("creatingConversation") });
|
||||
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 }) });
|
||||
if (threadOperationRef.current !== operation) return;
|
||||
if (result.conversation) applyConversationState(result.conversation);
|
||||
setAgentState({ activeTab: "chat", activity: "新对话" });
|
||||
setAgentState({ activeTab: "chat", activity: rt("newConversation") });
|
||||
} catch (error) {
|
||||
const state = agentErrorState(error);
|
||||
if (state) applyConversationState(state);
|
||||
addEventLog("新建对话失败", error);
|
||||
message.error(error instanceof Error ? error.message : "新建对话失败");
|
||||
addEventLog(rt("newConversationFailed"), error);
|
||||
message.error(error instanceof Error ? error.message : rt("newConversationFailed"));
|
||||
await loadThreads();
|
||||
} finally {
|
||||
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 }) });
|
||||
if (result.conversation) applyConversationState(result.conversation);
|
||||
await loadThreads();
|
||||
if (useAgentStore.getState().activeThreadId === threadId) setAgentState({ activeTab: "chat", activity: "已恢复会话" });
|
||||
if (useAgentStore.getState().activeThreadId === threadId) setAgentState({ activeTab: "chat", activity: rt("conversationResumed") });
|
||||
} catch (error) {
|
||||
const state = agentErrorState(error);
|
||||
if (state) applyConversationState(state);
|
||||
addEventLog("恢复对话失败", error);
|
||||
message.error(error instanceof Error ? error.message : "恢复对话失败");
|
||||
addEventLog(rt("resumeConversationFailed"), error);
|
||||
message.error(error instanceof Error ? error.message : rt("resumeConversationFailed"));
|
||||
await loadThreads();
|
||||
} finally {
|
||||
finishThreadOperation(operation);
|
||||
@@ -1035,14 +1038,14 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
}
|
||||
void deleteAgentThreadMessages(deletedThreadIds).catch(() => undefined);
|
||||
await loadThreads();
|
||||
message.success(`已删除 ${deletedThreadIds.length} 条记录`);
|
||||
message.success(rt("recordsDeleted", { count: deletedThreadIds.length }));
|
||||
} catch (error) {
|
||||
if (deletedThreadIds.length) {
|
||||
void deleteAgentThreadMessages(deletedThreadIds).catch(() => undefined);
|
||||
}
|
||||
await loadThreads();
|
||||
addEventLog("删除对话失败", error);
|
||||
message.error(error instanceof Error ? error.message : "删除对话失败");
|
||||
addEventLog(rt("deleteConversationFailed"), error);
|
||||
message.error(error instanceof Error ? error.message : rt("deleteConversationFailed"));
|
||||
} finally {
|
||||
finishThreadOperation(operation);
|
||||
}
|
||||
@@ -1050,11 +1053,11 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
|
||||
const confirmDeleteThreads = (threadIds: string[]) => {
|
||||
modal.confirm({
|
||||
title: `删除 ${threadIds.length} 条对话记录`,
|
||||
content: "删除后无法恢复,确定继续吗?",
|
||||
okText: "删除",
|
||||
title: rt("deleteConversations", { count: threadIds.length }),
|
||||
content: rt("deleteConversationsDescription"),
|
||||
okText: t("common.delete"),
|
||||
okType: "danger",
|
||||
cancelText: "取消",
|
||||
cancelText: t("common.cancel"),
|
||||
onOk: () => deleteThreads(threadIds),
|
||||
});
|
||||
};
|
||||
@@ -1091,7 +1094,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
const activityItems = { ...(current?.activityItems || {}) };
|
||||
const previous = activityItems[item.id] || "";
|
||||
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;
|
||||
}
|
||||
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 incoming = normalizeText(item.text);
|
||||
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;
|
||||
}
|
||||
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);
|
||||
if (current && !normalizeText(value)) return;
|
||||
upsertActivityMessage(item);
|
||||
setAgentState({ activity: "处理失败", pendingApprovals: [] });
|
||||
if (log) addEventLog("处理失败", error.text, value);
|
||||
setAgentState({ activity: rt("processingFailed"), pendingApprovals: [] });
|
||||
if (log) addEventLog(rt("processingFailed"), error.text, value);
|
||||
};
|
||||
|
||||
const handleAgentEvent = async (event: AgentEventPayload) => {
|
||||
@@ -1243,7 +1246,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
const result = context.applyOps(ops);
|
||||
void postState(endpoint, token, clientIdRef.current, result);
|
||||
}
|
||||
addEventLog("导入生成图片", context ? "已添加到发起任务的画布" : "图片已生成");
|
||||
addEventLog(rt("importGeneratedImages"), rt(context ? "addedToSourceCanvas" : "imageGenerated"));
|
||||
}
|
||||
}
|
||||
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) });
|
||||
};
|
||||
|
||||
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 content = (
|
||||
<>
|
||||
@@ -1294,18 +1297,18 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
<Bot className="size-4" />
|
||||
</span>
|
||||
<div className="hidden text-base font-semibold leading-5 @min-[560px]:block">Agent</div>
|
||||
<Tooltip title={`连接设置 · ${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" })}>
|
||||
<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={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>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
items={[
|
||||
{ value: "chat", label: "对话", icon: <MessageSquare className="size-3.5" /> },
|
||||
{ value: "history", label: "历史", icon: <History className="size-3.5" />, count: threads.length },
|
||||
{ value: "skills", label: "技能", icon: <Sparkles className="size-3.5" />, count: skillCount },
|
||||
{ value: "log", label: "日志", icon: <Terminal className="size-3.5" />, count: eventLogs.length },
|
||||
{ value: "chat", label: t("agent.panel.chat"), icon: <MessageSquare className="size-3.5" /> },
|
||||
{ value: "history", label: t("agent.panel.history"), icon: <History className="size-3.5" />, count: threads.length },
|
||||
{ value: "skills", label: t("agent.panel.skills"), icon: <Sparkles className="size-3.5" />, count: skillCount },
|
||||
{ value: "log", label: t("agent.panel.logs"), icon: <Terminal className="size-3.5" />, count: eventLogs.length },
|
||||
]}
|
||||
onChange={(activeTab) => {
|
||||
setAgentState({ activeTab });
|
||||
@@ -1313,13 +1316,13 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
}}
|
||||
right={
|
||||
<>
|
||||
<Tooltip title="新对话" 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}>
|
||||
<span className="hidden @min-[560px]:inline">新对话</span>
|
||||
<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={t("agent.history.newThread")} disabled={!connected || loadingThreads || sending || waiting || conversationBusy} icon={<Plus className="size-3.5" />} onClick={startNewThread}>
|
||||
<span className="hidden @min-[560px]:inline">{t("agent.history.newThread")}</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="收起对话">
|
||||
<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} />
|
||||
<Tooltip title={t("agent.panel.collapse")}>
|
||||
<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>
|
||||
</>
|
||||
}
|
||||
@@ -1374,10 +1377,10 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
disabled={!connected || !conversationReady || loadingThreads}
|
||||
sending={sending || waiting}
|
||||
placeholder={conversation.status === "idle" || conversation.status === "preparing"
|
||||
? "MCP 初始化中,完成后即可发送"
|
||||
? t("agent.panel.mcpInitializing")
|
||||
: conversation.status === "failed"
|
||||
? "Codex 对话初始化失败,请新建或恢复对话"
|
||||
: "询问 Codex,或让它操作网站/画布"}
|
||||
? t("agent.panel.initFailed")
|
||||
: t("agent.panel.placeholder")}
|
||||
theme={theme}
|
||||
onPromptChange={(prompt) => setAgentState({ prompt })}
|
||||
onSubmit={sendPrompt}
|
||||
@@ -1480,23 +1483,23 @@ function scopeEventChatItem(event: AgentEventPayload, item: AgentChatItem, itemI
|
||||
}
|
||||
|
||||
function approvalActivity(pendingApprovals: AgentPendingApproval[], waiting: boolean, fallback: string) {
|
||||
if (pendingApprovals.length) return "等待权限确认";
|
||||
return waiting ? "Codex 正在运行" : fallback;
|
||||
if (pendingApprovals.length) return rt("awaitingApproval");
|
||||
return waiting ? rt("codexRunning") : fallback;
|
||||
}
|
||||
|
||||
async function attachmentNodeOps(endpoint: string, token: string, clientId: string, value: unknown): Promise<CanvasAgentOp[]> {
|
||||
const nodes = Array.isArray(value) ? value : [];
|
||||
if (!nodes.length) throw new Error("没有可添加的图片附件");
|
||||
if (!nodes.length) throw new Error(rt("noImageAttachments"));
|
||||
return await Promise.all(
|
||||
nodes.map(async (value) => {
|
||||
const item = value as { id?: unknown; attachmentId?: unknown; title?: unknown; position?: unknown };
|
||||
const id = String(item.id || "");
|
||||
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)}`);
|
||||
if (!res.ok) {
|
||||
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 size = fitNodeSize(image.width, image.height);
|
||||
@@ -1505,7 +1508,7 @@ async function attachmentNodeOps(endpoint: string, token: string, clientId: stri
|
||||
type: "add_node" as const,
|
||||
id,
|
||||
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 },
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
@@ -1530,11 +1533,11 @@ async function importGeneratedImages(endpoint: string, token: string, item: Agen
|
||||
const response = source.startsWith("data:image/")
|
||||
? await fetch(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 upload = await uploadImage(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 } };
|
||||
}),
|
||||
);
|
||||
@@ -1554,7 +1557,7 @@ function readDataUrl(file: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ImageSettingsTheme } from "@/components/image-settings-panel";
|
||||
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) {
|
||||
const { t } = useTranslation();
|
||||
const voice = normalizeAudioVoiceValue(config.audioVoice);
|
||||
const format = normalizeAudioFormatValue(config.audioFormat);
|
||||
const speed = normalizeAudioSpeedValue(config.audioSpeed);
|
||||
@@ -25,8 +27,8 @@ export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
return (
|
||||
<ImageSettingsTheme theme={theme}>
|
||||
<div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
|
||||
{showTitle ? <div className="text-lg font-semibold">音频设置</div> : null}
|
||||
<SettingGroup title="声音" color={theme.node.muted}>
|
||||
{showTitle ? <div className="text-lg font-semibold">{t("settingsPanels.audio.title")}</div> : null}
|
||||
<SettingGroup title={t("settingsPanels.audio.voice")} color={theme.node.muted}>
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
{audioVoiceOptions.map((item) => (
|
||||
<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>
|
||||
</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">
|
||||
{audioFormatOptions.map((item) => (
|
||||
<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>
|
||||
</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">
|
||||
{speedOptions.map((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()}
|
||||
/>
|
||||
</SettingGroup>
|
||||
<SettingGroup title="声音指令" color={theme.node.muted}>
|
||||
<SettingGroup title={t("settingsPanels.audio.instructions")} color={theme.node.muted}>
|
||||
<textarea
|
||||
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"
|
||||
style={{ borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
onChange={(event) => onConfigChange("audioInstructions", event.target.value)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Empty, Input, Modal, Pagination, Tag } from "antd";
|
||||
import { Search } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
@@ -15,8 +16,9 @@ type Props = {
|
||||
};
|
||||
|
||||
export function AssetPickerModal({ open, onInsert, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
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} />
|
||||
</Modal>
|
||||
);
|
||||
@@ -24,14 +26,10 @@ export function AssetPickerModal({ open, onInsert, onClose }: Props) {
|
||||
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
const kindOptions = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "文本", value: "text" },
|
||||
{ label: "图片", value: "image" },
|
||||
{ label: "视频", value: "video" },
|
||||
];
|
||||
const kindOptions = ["all", "text", "image", "video"];
|
||||
|
||||
function PickerCard({ title, kind, cover, onClick }: { title: string; kind: string; cover: string; onClick: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -46,15 +44,16 @@ function PickerCard({ title, kind, cover, onClick }: { title: string; kind: stri
|
||||
<div className="p-2.5">
|
||||
<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>
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
|
||||
function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [kindFilter, setKindFilter] = useState("all");
|
||||
@@ -90,7 +89,7 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
|
||||
className="w-56"
|
||||
size="small"
|
||||
prefix={<Search className="size-3.5 text-stone-400" />}
|
||||
placeholder="搜索资产"
|
||||
placeholder={t("canvas.assetPicker.search")}
|
||||
value={keyword}
|
||||
allowClear
|
||||
onChange={(e) => {
|
||||
@@ -99,17 +98,17 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
{kindOptions.map((opt) => (
|
||||
{kindOptions.map((option) => (
|
||||
<Tag.CheckableTag
|
||||
key={opt.value}
|
||||
checked={kindFilter === opt.value}
|
||||
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
|
||||
key={option}
|
||||
checked={kindFilter === option}
|
||||
className={cn("prompt-filter-tag", kindFilter === option && "is-active")}
|
||||
onChange={() => {
|
||||
setPage(1);
|
||||
setKindFilter(opt.value);
|
||||
setKindFilter(option);
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
{option === "all" ? t("common.all") : t(`assets.kinds.${option}`)}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
@@ -122,7 +121,7 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
|
||||
))}
|
||||
</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 && (
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, KeyboardEvent, MouseEvent, PointerEvent } from "react";
|
||||
import { Button, Image } from "antd";
|
||||
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 { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { NodeGenerationInput } from "./canvas-node-generation";
|
||||
@@ -25,6 +27,7 @@ type MentionState = {
|
||||
export const CONFIG_REFERENCE_PATTERN = /@\[node:([^\]]+)\]/g;
|
||||
|
||||
export function CanvasConfigComposer({ value, inputs, onChange, onClose }: CanvasConfigComposerProps) {
|
||||
const { t } = useTranslation();
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
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="flex min-w-0 items-baseline gap-2">
|
||||
<div className="shrink-0 text-xs font-semibold">组装提示词</div>
|
||||
<div className="truncate text-[11px] opacity-55">@ 引用已连接资产,发送前按当前连接重新编号</div>
|
||||
<div className="shrink-0 text-xs font-semibold">{t("canvas.composer.title")}</div>
|
||||
<div className="truncate text-[11px] opacity-55">{t("canvas.composer.description")}</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} />
|
||||
</div>
|
||||
<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
|
||||
ref={editorRef}
|
||||
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}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -360,10 +363,7 @@ function parseComposerTokens(value: string): Token[] {
|
||||
function resourceLabel(input: NodeGenerationInput, inputs: NodeGenerationInput[]) {
|
||||
const sameTypeInputs = inputs.filter((item) => item.type === input.type);
|
||||
const index = Math.max(0, sameTypeInputs.findIndex((item) => item.nodeId === input.nodeId));
|
||||
if (input.type === "image") return `图片${index + 1}`;
|
||||
if (input.type === "video") return `视频${index + 1}`;
|
||||
if (input.type === "audio") return `音频${index + 1}`;
|
||||
return `文本${index + 1}`;
|
||||
return i18n.t(`canvas.composer.resources.${input.type}`, { index: index + 1 });
|
||||
}
|
||||
|
||||
function chipStyle(theme: (typeof canvasThemes)[keyof typeof canvasThemes]): CSSProperties {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Square, Video } from "lucide-react";
|
||||
import { Button, Segmented } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
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) {
|
||||
const { t } = useTranslation();
|
||||
const globalConfig = useEffectiveConfig();
|
||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
@@ -36,7 +38,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
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="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()}>
|
||||
<Segmented
|
||||
size="small"
|
||||
@@ -49,7 +51,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ImageIcon className="size-3.5" />
|
||||
生图
|
||||
{t("canvas.configNode.image")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -58,7 +60,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MessageSquare className="size-3.5" />
|
||||
文本
|
||||
{t("canvas.configNode.text")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -67,7 +69,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Video className="size-3.5" />
|
||||
视频
|
||||
{t("canvas.configNode.video")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -76,7 +78,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Music2 className="size-3.5" />
|
||||
音频
|
||||
{t("canvas.configNode.audio")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -86,13 +88,13 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
<InputChip label="提示词" value={`${inputSummary.textCount} 个`} style={chipStyle} />
|
||||
<InputChip label="参考图" value={`${inputSummary.imageCount} 张`} style={chipStyle} />
|
||||
<InputChip label="参考视频" value={`${inputSummary.videoCount} 个`} style={chipStyle} />
|
||||
<InputChip label="参考音频" value={`${inputSummary.audioCount} 个`} style={chipStyle} />
|
||||
<InputChip label={t("canvas.configNode.prompt")} value={t("canvas.configNode.items", { count: inputSummary.textCount })} style={chipStyle} />
|
||||
<InputChip label={t("canvas.configNode.references")} value={t("canvas.configNode.images", { count: inputSummary.imageCount })} style={chipStyle} />
|
||||
<InputChip label={t("canvas.configNode.videoReferences")} value={t("canvas.configNode.items", { count: inputSummary.videoCount })} 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}>
|
||||
<Settings2 className="size-3.5" />
|
||||
组装提示词
|
||||
{t("canvas.configNode.compose")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -122,12 +124,12 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
<>
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
<Square className="size-3.5 fill-current" />
|
||||
<span>停止</span>
|
||||
<span>{t("canvas.configNode.stop")}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="size-4" />
|
||||
<span>开始生成</span>
|
||||
<span>{t("canvas.configNode.generate")}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { ContextMenuState } from "@/types/canvas";
|
||||
|
||||
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)];
|
||||
|
||||
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 }}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{menu.type === "node" ? <MenuButton icon={<Plus className="size-4" />} label="复制" onClick={onDuplicate} /> : null}
|
||||
<MenuButton icon={<Trash2 className="size-4" />} label="删除" onClick={onDelete} danger />
|
||||
{menu.type === "node" ? <MenuButton icon={<Plus className="size-4" />} label={t("canvas.controls.duplicate")} onClick={onDuplicate} /> : null}
|
||||
<MenuButton icon={<Trash2 className="size-4" />} label={t("canvas.controls.delete")} onClick={onDelete} danger />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ImageIcon, List, Music2, Settings2, Video, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
@@ -21,6 +22,7 @@ export function ConnectionCreateMenu({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
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">
|
||||
<span className="text-sm font-medium" style={{ color: theme.node.muted }}>
|
||||
引用该节点生成
|
||||
{t("canvas.createMenu.fromNode")}
|
||||
</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>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<ConnectionCreateOption theme={theme} icon={<List className="size-5" />} title="文本生成" description="脚本、广告词、品牌文案" onClick={() => onCreate(CanvasNodeType.Text)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<ImageIcon className="size-5" />} title="图片生成" onClick={() => onCreate(CanvasNodeType.Image)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<Video className="size-5" />} title="视频生成" onClick={() => onCreate(CanvasNodeType.Video)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<Music2 className="size-5" />} title="音频参考" onClick={() => onCreate(CanvasNodeType.Audio)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<Settings2 className="size-5" />} title="配置节点" description="模型、尺寸、数量和输入顺序" onClick={() => onCreate(CanvasNodeType.Config)} />
|
||||
<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={t("canvas.createMenu.image")} onClick={() => onCreate(CanvasNodeType.Image)} />
|
||||
<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={t("canvas.createMenu.audio")} onClick={() => onCreate(CanvasNodeType.Audio)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<Settings2 className="size-5" />} title={t("canvas.createMenu.config")} description={t("canvas.createMenu.configDescription")} onClick={() => onCreate(CanvasNodeType.Config)} />
|
||||
</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 }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const { t } = useTranslation();
|
||||
useNodeRegistryVersion();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
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">
|
||||
<span className="text-sm font-medium" style={{ color: theme.node.muted }}>
|
||||
选择节点
|
||||
{t("canvas.createMenu.select")}
|
||||
</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" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Button, Modal } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
|
||||
|
||||
export function CanvasDeleteProjectsDialog() {
|
||||
const { t } = useTranslation();
|
||||
const ids = useCanvasUiStore((state) => state.deleteProjectIds);
|
||||
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
|
||||
const removeSelectedIds = useCanvasUiStore((state) => state.removeSelectedProjectIds);
|
||||
@@ -19,20 +21,20 @@ export function CanvasDeleteProjectsDialog() {
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="删除画布?"
|
||||
title={t("canvas.project.deleteTitle")}
|
||||
open={ids.length > 0}
|
||||
centered
|
||||
onCancel={() => setDeleteIds([])}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setDeleteIds([])}>取消</Button>
|
||||
<Button onClick={() => setDeleteIds([])}>{t("common.cancel")}</Button>
|
||||
<Button danger type="primary" onClick={confirm}>
|
||||
删除
|
||||
{t("common.delete")}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Settings2 } from "lucide-react";
|
||||
import { Button } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ImageSettingsPanel, imageQualityLabel, imageSizeLabel } from "@/components/image-settings-panel";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
@@ -20,6 +21,7 @@ type CanvasImageSettingsPopoverProps = {
|
||||
};
|
||||
|
||||
export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, placement = "topLeft" }: CanvasImageSettingsPopoverProps) {
|
||||
const { t } = useTranslation();
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const buttonRef = useRef<HTMLSpanElement>(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">
|
||||
<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">
|
||||
{imageQualityLabel(quality)} · {imageSizeLabel(activeSize)} · {count} 张
|
||||
{imageQualityLabel(quality)} · {imageSizeLabel(activeSize)} · {t("canvas.controls.images", { count })}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { Ellipsis, Image as ImageIcon, Settings2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { ImageQuickToolId } from "./canvas-image-toolbar-tools";
|
||||
|
||||
@@ -48,6 +49,7 @@ export function ImageToolSettingsModal({
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = antdTheme.useToken();
|
||||
const previewToolbarRef = useRef<HTMLDivElement>(null);
|
||||
const scrollbarTrackRef = useRef<HTMLInputElement>(null);
|
||||
@@ -56,7 +58,7 @@ export function ImageToolSettingsModal({
|
||||
const selectedTools = tools.filter((tool) => selected.has(tool.id));
|
||||
const previewTools: PreviewTool[] = [
|
||||
...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(() => {
|
||||
@@ -121,7 +123,7 @@ export function ImageToolSettingsModal({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="自定义工具栏"
|
||||
title={t("canvas.imageTools.customize")}
|
||||
open={open}
|
||||
centered
|
||||
width={760}
|
||||
@@ -130,20 +132,20 @@ export function ImageToolSettingsModal({
|
||||
footer={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>显示按钮文字</span>
|
||||
<span>{t("canvas.imageTools.showLabels")}</span>
|
||||
<Switch checked={showLabels} onChange={onShowLabelsChange} />
|
||||
</div>
|
||||
<Space>
|
||||
<Button onClick={onCancel}>取消</Button>
|
||||
<Button onClick={onCancel}>{t("common.cancel")}</Button>
|
||||
<Button type="primary" onClick={onSave}>
|
||||
保存
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Typography.Paragraph type="secondary" className="!mb-4">
|
||||
选择你想在图片节点编辑栏中使用的快捷工具。
|
||||
{t("canvas.imageTools.description")}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Card
|
||||
@@ -151,7 +153,7 @@ export function ImageToolSettingsModal({
|
||||
title={
|
||||
<Space size={6}>
|
||||
<Settings2 className="size-4" />
|
||||
节点预览
|
||||
{t("canvas.imageTools.preview")}
|
||||
</Space>
|
||||
}
|
||||
className="mb-4"
|
||||
@@ -172,7 +174,7 @@ export function ImageToolSettingsModal({
|
||||
style={{ background: token.colorFillAlter, borderColor: token.colorBorderSecondary, color: token.colorTextSecondary }}
|
||||
>
|
||||
<ImageIcon className="mb-2 size-8" />
|
||||
<Typography.Text type="secondary">图片节点</Typography.Text>
|
||||
<Typography.Text type="secondary">{t("canvas.imageTools.imageNode")}</Typography.Text>
|
||||
</div>
|
||||
<input
|
||||
ref={scrollbarTrackRef}
|
||||
@@ -194,7 +196,7 @@ export function ImageToolSettingsModal({
|
||||
className="!mb-4"
|
||||
label={
|
||||
<Space size={8}>
|
||||
<span>快捷工具</span>
|
||||
<span>{t("canvas.imageTools.quickTools")}</span>
|
||||
<Tag className="m-0">
|
||||
{selectedTools.length}/{tools.length}
|
||||
</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 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 ImageQuickToolId = "info" | "delete" | "saveAsset" | "download" | "edit" | ImageNodeActionToolId;
|
||||
@@ -23,7 +24,6 @@ export type ImageToolHandlers = {
|
||||
export type ImageToolDefinition = {
|
||||
id: ImageNodeActionToolId;
|
||||
defaultVisible: boolean;
|
||||
panelLabel: string;
|
||||
label: string | ((node: CanvasNodeData) => string);
|
||||
title: string | ((node: CanvasNodeData) => string);
|
||||
icon: (node: CanvasNodeData) => ReactNode;
|
||||
@@ -44,36 +44,32 @@ export const imageToolDefinitions: ImageToolDefinition[] = [
|
||||
{
|
||||
id: "copyPrompt",
|
||||
defaultVisible: true,
|
||||
panelLabel: "复制提示词",
|
||||
label: "复制提示词",
|
||||
title: "复制生成该图片的提示词",
|
||||
label: () => i18n.t("canvas.imageTools.copyPrompt"),
|
||||
title: () => i18n.t("canvas.imageTools.copyPromptTitle"),
|
||||
icon: () => <Copy className="size-4" />,
|
||||
run: (node, handlers) => handlers.onCopyPrompt(node),
|
||||
},
|
||||
{
|
||||
id: "reversePrompt",
|
||||
defaultVisible: true,
|
||||
panelLabel: "反推提示词",
|
||||
label: "反推提示词",
|
||||
title: "创建反推提示词的文本和配置节点",
|
||||
label: () => i18n.t("canvas.imageTools.reversePrompt"),
|
||||
title: () => i18n.t("canvas.imageTools.reversePromptTitle"),
|
||||
icon: () => <FileText className="size-4" />,
|
||||
run: (node, handlers) => handlers.onReversePrompt(node),
|
||||
},
|
||||
{
|
||||
id: "replace",
|
||||
defaultVisible: true,
|
||||
panelLabel: "替换图片",
|
||||
label: "替换图片",
|
||||
title: "替换图片",
|
||||
label: () => i18n.t("canvas.imageTools.replace"),
|
||||
title: () => i18n.t("canvas.imageTools.replace"),
|
||||
icon: () => <Upload className="size-4" />,
|
||||
run: (node, handlers) => handlers.onUpload(node),
|
||||
},
|
||||
{
|
||||
id: "resize",
|
||||
defaultVisible: false,
|
||||
panelLabel: "锁比例",
|
||||
label: (node) => (node.metadata?.freeResize ? "自由比例" : "锁比例"),
|
||||
title: (node) => (node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"),
|
||||
label: (node) => i18n.t(node.metadata?.freeResize ? "canvas.imageTools.free" : "canvas.imageTools.locked"),
|
||||
title: (node) => i18n.t(node.metadata?.freeResize ? "canvas.imageTools.lockTitle" : "canvas.imageTools.freeTitle"),
|
||||
icon: (node) => (node.metadata?.freeResize ? <LockOpen className="size-4" /> : <Lock className="size-4" />),
|
||||
active: (node) => Boolean(node.metadata?.freeResize),
|
||||
run: (node, handlers) => handlers.onToggleFreeResize(node),
|
||||
@@ -81,63 +77,56 @@ export const imageToolDefinitions: ImageToolDefinition[] = [
|
||||
{
|
||||
id: "maskEdit",
|
||||
defaultVisible: true,
|
||||
panelLabel: "局部编辑",
|
||||
label: "局部编辑",
|
||||
title: "添加蒙版遮罩后局部修改",
|
||||
label: () => i18n.t("canvas.imageTools.mask"),
|
||||
title: () => i18n.t("canvas.imageTools.maskTitle"),
|
||||
icon: () => <Brush className="size-4" />,
|
||||
run: (node, handlers) => handlers.onMaskEdit(node),
|
||||
},
|
||||
{
|
||||
id: "crop",
|
||||
defaultVisible: true,
|
||||
panelLabel: "裁剪",
|
||||
label: "裁剪",
|
||||
title: "裁剪并生成新节点",
|
||||
label: () => i18n.t("canvas.imageTools.crop"),
|
||||
title: () => i18n.t("canvas.imageTools.cropTitle"),
|
||||
icon: () => <Scissors className="size-4" />,
|
||||
run: (node, handlers) => handlers.onCrop(node),
|
||||
},
|
||||
{
|
||||
id: "split",
|
||||
defaultVisible: true,
|
||||
panelLabel: "切图",
|
||||
label: "切图",
|
||||
title: "按行列切分图片",
|
||||
label: () => i18n.t("canvas.imageTools.split"),
|
||||
title: () => i18n.t("canvas.imageTools.splitTitle"),
|
||||
icon: () => <Grid2x2 className="size-4" />,
|
||||
run: (node, handlers) => handlers.onSplit(node),
|
||||
},
|
||||
{
|
||||
id: "upscale",
|
||||
defaultVisible: true,
|
||||
panelLabel: "放大",
|
||||
label: "放大",
|
||||
title: "放大图片分辨率",
|
||||
label: () => i18n.t("canvas.imageTools.upscale"),
|
||||
title: () => i18n.t("canvas.imageTools.upscaleTitle"),
|
||||
icon: () => <ZoomIn className="size-4" />,
|
||||
run: (node, handlers) => handlers.onUpscale(node),
|
||||
},
|
||||
{
|
||||
id: "superResolve",
|
||||
defaultVisible: false,
|
||||
panelLabel: "超分",
|
||||
label: "超分",
|
||||
title: "AI 超分",
|
||||
label: () => i18n.t("canvas.imageTools.superResolve"),
|
||||
title: () => i18n.t("canvas.imageTools.superResolveTitle"),
|
||||
icon: () => <Sparkles className="size-4" />,
|
||||
run: (node, handlers) => handlers.onSuperResolve(node),
|
||||
},
|
||||
{
|
||||
id: "angle",
|
||||
defaultVisible: false,
|
||||
panelLabel: "多角度",
|
||||
label: "多角度",
|
||||
title: "生成角度",
|
||||
label: () => i18n.t("canvas.imageTools.angle"),
|
||||
title: () => i18n.t("canvas.imageTools.angleTitle"),
|
||||
icon: () => <Camera className="size-4" />,
|
||||
run: (node, handlers) => handlers.onAngle(node),
|
||||
},
|
||||
{
|
||||
id: "view",
|
||||
defaultVisible: true,
|
||||
panelLabel: "查看大图",
|
||||
label: "查看大图",
|
||||
title: "查看图片详情",
|
||||
label: () => i18n.t("canvas.imageTools.view"),
|
||||
title: () => i18n.t("canvas.imageTools.viewTitle"),
|
||||
icon: () => <Maximize2 className="size-4" />,
|
||||
run: (node, handlers) => handlers.onViewImage(node),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Modal, Segmented, Slider } from "antd";
|
||||
import { RotateCcw, WandSparkles } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type CanvasImageAngleParams = {
|
||||
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 }) {
|
||||
const { t } = useTranslation();
|
||||
const [params, setParams] = useState(defaultParams);
|
||||
|
||||
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>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">AI 多角度</h2>
|
||||
<p className="mt-1 text-sm opacity-60">左侧只预览方向,结果会基于原图重新生成</p>
|
||||
<h2 className="text-xl font-semibold">{t("canvas.editors.angleTitle")}</h2>
|
||||
<p className="mt-1 text-sm opacity-60">{t("canvas.editors.angleDescription")}</p>
|
||||
</div>
|
||||
<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">
|
||||
@@ -41,21 +43,21 @@ export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: { d
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-fit" icon={<RotateCcw className="size-4" />} onClick={() => setParams(defaultParams)}>
|
||||
重置
|
||||
{t("canvas.editors.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
<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="俯仰角度" 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.horizontal")} value={params.horizontalAngle} min={-60} max={60} step={1} suffix="deg" onChange={(value) => update("horizontalAngle", 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={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">
|
||||
<span className="font-medium opacity-75">广角镜头</span>
|
||||
<span className="font-medium opacity-75">{t("canvas.editors.lens")}</span>
|
||||
<Segmented
|
||||
className="w-fit"
|
||||
value={params.wideAngle ? "wide" : "standard"}
|
||||
options={[
|
||||
{ label: "标准", value: "standard" },
|
||||
{ label: "广角", value: "wide" },
|
||||
{ label: t("canvas.editors.standard"), value: "standard" },
|
||||
{ label: t("canvas.editors.wide"), value: "wide" },
|
||||
]}
|
||||
onChange={(value) => update("wideAngle", value === "wide")}
|
||||
/>
|
||||
@@ -64,7 +66,7 @@ export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: { d
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="primary" size="large" icon={<WandSparkles className="size-4" />} onClick={() => onConfirm(params)}>
|
||||
AI 生成
|
||||
{t("canvas.editors.aiGenerate")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { Button, Modal, Segmented, Tooltip } from "antd";
|
||||
import { Check, X, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useImageEditorViewport } from "@/components/canvas/use-image-editor-viewport";
|
||||
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 minSize = 0.06;
|
||||
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 }) {
|
||||
const { t } = useTranslation();
|
||||
const [crop, setCrop] = useState<CanvasImageCropRect>(defaultCrop);
|
||||
const [ratioPreset, setRatioPreset] = useState("free");
|
||||
const [fixedRatio, setFixedRatio] = useState<number | null>(null);
|
||||
@@ -77,7 +69,7 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da
|
||||
};
|
||||
|
||||
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
|
||||
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"
|
||||
style={handleStyle(handle)}
|
||||
onPointerDown={(event) => startDrag("resize", event, handle)}
|
||||
aria-label="调整裁剪框"
|
||||
aria-label={t("canvas.editors.adjustCrop")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -111,31 +103,34 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Tooltip title="缩小">
|
||||
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label="缩小" onClick={viewport.zoomOut} />
|
||||
<Tooltip title={t("canvas.editors.zoomOut")}>
|
||||
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label={t("canvas.editors.zoomOut")} onClick={viewport.zoomOut} />
|
||||
</Tooltip>
|
||||
<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)}%
|
||||
</button>
|
||||
<Tooltip title="放大">
|
||||
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label="放大" onClick={viewport.zoomIn} />
|
||||
<Tooltip title={t("canvas.editors.zoomIn")}>
|
||||
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label={t("canvas.editors.zoomIn")} onClick={viewport.zoomIn} />
|
||||
</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 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">
|
||||
<span>裁剪尺寸 {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"}</span>
|
||||
<span>比例 {cropSize ? formatRatio(cropSize.width, cropSize.height) : "未知"}</span>
|
||||
<span>{t("canvas.editors.cropSize", { size: cropSize ? `${cropSize.width} x ${cropSize.height}` : t("canvas.editors.unknown") })}</span>
|
||||
<span>{t("canvas.editors.ratio", { ratio: cropSize ? formatRatio(cropSize.width, cropSize.height) : t("canvas.editors.unknown") })}</span>
|
||||
{image ? (
|
||||
<span>
|
||||
原图 {image.width} x {image.height}
|
||||
</span>
|
||||
<span>{t("canvas.editors.original", { width: image.width, height: image.height })}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Segmented
|
||||
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}
|
||||
onChange={(value) => {
|
||||
const preset = String(value);
|
||||
@@ -150,12 +145,12 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da
|
||||
</div>
|
||||
|
||||
<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}>
|
||||
取消
|
||||
{t("canvas.editors.cancel")}
|
||||
</Button>
|
||||
<Button type="primary" icon={<Check className="size-4" />} onClick={() => onConfirm(crop)}>
|
||||
确认裁剪
|
||||
{t("canvas.editors.confirmCrop")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AiTextMessage } from "@/services/api/image";
|
||||
import i18n from "@/i18n";
|
||||
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
||||
import { seedanceReferenceLabel } from "@/lib/seedance-video";
|
||||
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 === "video") return seedanceReferenceLabel("video", 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 {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
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 { useTranslation } from "react-i18next";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
@@ -82,6 +84,7 @@ export function CanvasNodeHoverToolbar({
|
||||
const [draftShowImageToolLabels, setDraftShowImageToolLabels] = useState(true);
|
||||
const [imageToolSettingsOpen, setImageToolSettingsOpen] = useState(false);
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const copyText = useCopyText();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -120,10 +123,10 @@ export function CanvasNodeHoverToolbar({
|
||||
const copyImagePrompt = (target: CanvasNodeData) => {
|
||||
const prompt = target.metadata?.prompt?.trim();
|
||||
if (!prompt) {
|
||||
message.warning("暂无可复制的提示词");
|
||||
message.warning(t("canvas.nodeToolbar.noPrompt"));
|
||||
return;
|
||||
}
|
||||
copyText(prompt, "提示词已复制");
|
||||
copyText(prompt, t("common.promptCopied"));
|
||||
};
|
||||
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[] = [
|
||||
{ id: "info", title: "查看节点信息", label: "信息", icon: <Info className="size-4" />, onClick: () => onInfo(node) },
|
||||
{ id: "delete", title: "移除节点", label: "删除", icon: <Trash2 className="size-4" />, onClick: () => onDelete(node), danger: true },
|
||||
{ id: "info", title: t("canvas.nodeToolbar.infoTitle"), label: t("canvas.nodeToolbar.info"), icon: <Info className="size-4" />, onClick: () => onInfo(node) },
|
||||
{ id: "delete", title: t("canvas.nodeToolbar.removeTitle"), label: t("common.delete"), icon: <Trash2 className="size-4" />, onClick: () => onDelete(node), danger: true },
|
||||
];
|
||||
const nodeToolbarTools: ToolbarTool[] = [
|
||||
...(canRetry ? [{ id: "retry", title: "重新生成", label: "重试", 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 || hasAudio ? [{ id: "download", title: hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片", label: "下载", icon: <Download className="size-4" />, onClick: () => onDownload(node) }] : []),
|
||||
...(canOpenDialog ? [{ id: "edit", title: "编辑", label: "编辑", icon: <MessageSquare className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
|
||||
...(isText ? [{ id: "editText", title: "编辑文本", label: "编辑文字", icon: <Pencil className="size-4" />, onClick: () => onEditText(node) }] : []),
|
||||
...(isText ? [{ id: "generateImage", title: "用文本生图", label: "生图", icon: <ImageIcon className="size-4" />, onClick: () => onGenerateImage(node) }] : []),
|
||||
...(isConfig ? [{ id: "config", title: "生成配置", label: "生成配置", icon: <Settings2 className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
|
||||
...(isText ? [{ id: "decreaseFont", title: "减小字号", label: "缩小", icon: <Minus className="size-4" />, onClick: () => onDecreaseFont(node) }] : []),
|
||||
...(isText ? [{ id: "increaseFont", title: "增大字号", label: "放大", icon: <Plus className="size-4" />, onClick: () => onIncreaseFont(node) }] : []),
|
||||
...(isImage && !hasImage ? [{ id: "uploadImage", title: "上传图片", label: "上传图片", icon: <Upload className="size-4" />, onClick: () => onUpload(node) }] : []),
|
||||
...(isVideo ? [{ id: "uploadVideo", title: hasVideo ? "替换视频" : "上传视频", label: hasVideo ? "替换视频" : "上传视频", icon: <Video className="size-4" />, onClick: () => onUpload(node) }] : []),
|
||||
...(isAudio ? [{ id: "uploadAudio", title: hasAudio ? "替换音频" : "上传音频", label: hasAudio ? "替换音频" : "上传音频", icon: <Music2 className="size-4" />, onClick: () => onUpload(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: t("common.addToAssets"), label: t("canvas.nodeToolbar.saveAsset"), icon: <FolderPlus className="size-4" />, onClick: () => onSaveAsset(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: t("common.edit"), label: t("common.edit"), icon: <MessageSquare className="size-4" />, onClick: () => onToggleDialog(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: t("canvas.node.generateImage"), label: t("canvas.node.generate"), icon: <ImageIcon className="size-4" />, onClick: () => onGenerateImage(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: t("canvas.nodeToolbar.decreaseFont"), label: t("canvas.nodeToolbar.zoomOut"), icon: <Minus className="size-4" />, onClick: () => onDecreaseFont(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: t("canvas.nodeToolbar.uploadImage"), label: t("canvas.nodeToolbar.uploadImage"), icon: <Upload 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: 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 })) : []),
|
||||
];
|
||||
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) => (
|
||||
<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>
|
||||
{hasImage ? (
|
||||
<ImageToolSettingsModal
|
||||
@@ -213,6 +216,7 @@ export function CanvasNodeHoverToolbar({
|
||||
|
||||
export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeData | null; open: boolean; onClose: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const { t } = useTranslation();
|
||||
const [view, setView] = useState<"info" | "json">("info");
|
||||
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;
|
||||
@@ -236,13 +240,13 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
|
||||
|
||||
const title = (
|
||||
<div className="flex items-center justify-between gap-4 pr-12">
|
||||
<span>节点信息</span>
|
||||
<span>{t("canvas.nodeToolbar.nodeInfo")}</span>
|
||||
<Segmented
|
||||
size="small"
|
||||
value={view}
|
||||
onChange={(value) => setView(value as "info" | "json")}
|
||||
options={[
|
||||
{ label: "信息", value: "info" },
|
||||
{ label: t("canvas.nodeToolbar.info"), value: "info" },
|
||||
{ label: "JSON", value: "json" },
|
||||
]}
|
||||
/>
|
||||
@@ -256,14 +260,14 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
|
||||
{view === "info" ? (
|
||||
<div className="thin-scrollbar h-full space-y-3 overflow-auto pr-1">
|
||||
<InfoRow label="ID" value={node.id} />
|
||||
<InfoRow label="名称" value={node.title || "未命名节点"} />
|
||||
<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="尺寸" 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="状态" value={node.metadata?.status || "idle"} />
|
||||
{batchCount > 1 ? <InfoRow label="图片组" value={`${batchCount} 张`} /> : null}
|
||||
{node.metadata?.prompt ? <InfoRow label="提示词" value={node.metadata.prompt} /> : null}
|
||||
{imageBytes ? <InfoRow label="图片大小" value={formatBytes(imageBytes)} /> : null}
|
||||
<InfoRow label={t("canvas.nodeToolbar.name")} value={node.title || t("canvas.node.untitled")} />
|
||||
<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={t("canvas.nodeToolbar.size")} value={`${Math.round(node.width)} x ${Math.round(node.height)}`} />
|
||||
<InfoRow label={t("canvas.nodeToolbar.position")} value={`${Math.round(node.position.x)}, ${Math.round(node.position.y)}`} />
|
||||
<InfoRow label={t("canvas.nodeToolbar.status")} value={node.metadata?.status || "idle"} />
|
||||
{batchCount > 1 ? <InfoRow label={t("canvas.nodeToolbar.imageGroup")} value={t("canvas.configNode.images", { count: batchCount })} /> : null}
|
||||
{node.metadata?.prompt ? <InfoRow label={t("canvas.configNode.prompt")} value={node.metadata.prompt} /> : null}
|
||||
{imageBytes ? <InfoRow label={t("canvas.nodeToolbar.imageSize")} value={formatBytes(imageBytes)} /> : null}
|
||||
{node.metadata?.errorDetails ? (
|
||||
<div className="rounded-lg border p-3 text-red-400" style={{ borderColor: theme.node.stroke }}>
|
||||
{node.metadata.errorDetails}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPoi
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button, Input, Modal, Slider, Tooltip } from "antd";
|
||||
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 { useImageEditorViewport } from "@/components/canvas/use-image-editor-viewport";
|
||||
@@ -20,6 +21,7 @@ const defaultBrushSize = 100;
|
||||
const maskFillColor = "rgba(37, 99, 235, .38)";
|
||||
|
||||
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 previewCanvasRef = useRef<HTMLCanvasElement>(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 nextPrompt = prompt.trim();
|
||||
const canvas = maskCanvasRef.current;
|
||||
if (!nextPrompt) return setError("请输入修改要求");
|
||||
if (!nextPrompt) return setError(t("canvas.editors.maskPromptRequired"));
|
||||
if (!canvas) return;
|
||||
if (!canvasHasPaint(canvas)) return setError("请先涂抹局部区域");
|
||||
if (!canvasHasPaint(canvas)) return setError(t("canvas.editors.maskRequired"));
|
||||
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>
|
||||
<h2 className="text-xl font-semibold">局部遮罩编辑</h2>
|
||||
<div className="mt-2 text-sm opacity-60">{image ? `${image.width} x ${image.height}px` : "读取中"}</div>
|
||||
<div className="mt-2 text-xs leading-5 opacity-55">滚轮缩放 · 中键或空格+左键拖动画面 · Alt+左/右键横拖调笔刷 · Ctrl/Cmd+Z 撤回 · Ctrl/Cmd+Shift+Z 重做</div>
|
||||
<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` : t("canvas.editors.loading")}</div>
|
||||
<div className="mt-2 text-xs leading-5 opacity-55">{t("canvas.editors.maskHint")}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button type={mode === "paint" ? "primary" : "default"} icon={<Brush className="size-4" />} onClick={() => setMode("paint")}>
|
||||
画笔
|
||||
{t("canvas.editors.brush")}
|
||||
</Button>
|
||||
<Button type={mode === "erase" ? "primary" : "default"} icon={<Eraser className="size-4" />} onClick={() => setMode("erase")}>
|
||||
擦除
|
||||
{t("canvas.editors.erase")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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)">
|
||||
<Button type="text" icon={<Undo2 className="size-4" />} disabled={!historySize} aria-label="撤回局部涂抹" onClick={undoMask} />
|
||||
<Tooltip title={t("canvas.editors.undoMaskTitle")}>
|
||||
<Button type="text" icon={<Undo2 className="size-4" />} disabled={!historySize} aria-label={t("canvas.editors.undoMask")} onClick={undoMask} />
|
||||
</Tooltip>
|
||||
<Tooltip title="重做局部涂抹 (Ctrl/Cmd+Shift+Z)">
|
||||
<Button type="text" icon={<Redo2 className="size-4" />} disabled={!redoSize} aria-label="重做局部涂抹" onClick={redoMask} />
|
||||
<Tooltip title={t("canvas.editors.redoMaskTitle")}>
|
||||
<Button type="text" icon={<Redo2 className="size-4" />} disabled={!redoSize} aria-label={t("canvas.editors.redoMask")} onClick={redoMask} />
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip title="缩小">
|
||||
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label="缩小" onClick={viewport.zoomOut} />
|
||||
<Tooltip title={t("canvas.editors.zoomOut")}>
|
||||
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label={t("canvas.editors.zoomOut")} onClick={viewport.zoomOut} />
|
||||
</Tooltip>
|
||||
<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)}%
|
||||
</button>
|
||||
<Tooltip title="放大">
|
||||
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label="放大" onClick={viewport.zoomIn} />
|
||||
<Tooltip title={t("canvas.editors.zoomIn")}>
|
||||
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label={t("canvas.editors.zoomIn")} onClick={viewport.zoomIn} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
</div>
|
||||
<Slider min={8} max={160} step={2} value={brushSize} onChange={setBrushSize} />
|
||||
</div>
|
||||
|
||||
<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
|
||||
rows={6}
|
||||
value={prompt}
|
||||
status={error && !prompt.trim() ? "error" : undefined}
|
||||
placeholder="例如:把选中区域改成金属材质,保持原图光影"
|
||||
placeholder={t("canvas.editors.maskPlaceholder")}
|
||||
onChange={(event) => {
|
||||
setPrompt(event.target.value);
|
||||
setError("");
|
||||
@@ -316,14 +318,14 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-2">
|
||||
<Button icon={<RotateCcw className="size-4" />} onClick={resetMask}>
|
||||
重置
|
||||
{t("canvas.editors.reset")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button icon={<X className="size-4" />} onClick={onClose}>
|
||||
取消
|
||||
{t("canvas.editors.cancel")}
|
||||
</Button>
|
||||
<Button type="primary" icon={<WandSparkles className="size-4" />} onClick={submit}>
|
||||
AI 修改
|
||||
{t("canvas.editors.aiEdit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowUp, LoaderCircle, Square } from "lucide-react";
|
||||
import { Button } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
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) {
|
||||
const { t } = useTranslation();
|
||||
const globalConfig = useEffectiveConfig();
|
||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
@@ -73,7 +75,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
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"
|
||||
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">
|
||||
@@ -114,14 +116,14 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
danger={isRunning}
|
||||
disabled={!isRunning && !prompt.trim()}
|
||||
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">
|
||||
{isRunning ? (
|
||||
<>
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
<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" />
|
||||
@@ -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) {
|
||||
if (key === "videoSeconds") return { seconds: value };
|
||||
if (key === "videoGenerateAudio") return { generateAudio: value };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { Button, InputNumber, Modal, Tooltip } from "antd";
|
||||
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 type { ImageSplitParams } from "@/lib/canvas/canvas-image-data";
|
||||
@@ -13,6 +14,7 @@ const maxGridSize = 12;
|
||||
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 }) {
|
||||
const { t } = useTranslation();
|
||||
const [params, setParams] = useState(defaultParams);
|
||||
const [image, setImage] = useState<{ width: number; height: number } | null>(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="">
|
||||
<div className="space-y-5" data-canvas-no-zoom>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">切分图片</h2>
|
||||
<p className="mt-1 text-sm opacity-60">生成 {total} 个图片子节点,并按原图网格排列到画布右侧</p>
|
||||
<p className="mt-2 text-xs leading-5 opacity-55">滚轮缩放 · 中键或空格+左键拖动画面 · Delete 删除选中线 · Ctrl/Cmd+Z 撤回 · Ctrl/Cmd+Shift+Z 重做</p>
|
||||
<h2 className="text-xl font-semibold">{t("canvas.editors.splitTitle")}</h2>
|
||||
<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">{t("canvas.editors.splitHint")}</p>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_280px]">
|
||||
<div className="rounded-xl border p-4">
|
||||
@@ -171,54 +173,54 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip title="撤回切图调整 (Ctrl/Cmd+Z)">
|
||||
<Button type="text" icon={<Undo2 className="size-4" />} disabled={!historySize} aria-label="撤回切图调整" onClick={undoSplit} />
|
||||
<Tooltip title={t("canvas.editors.undoSplitTitle")}>
|
||||
<Button type="text" icon={<Undo2 className="size-4" />} disabled={!historySize} aria-label={t("canvas.editors.undoSplit")} onClick={undoSplit} />
|
||||
</Tooltip>
|
||||
<Tooltip title="重做切图调整 (Ctrl/Cmd+Shift+Z)">
|
||||
<Button type="text" icon={<Redo2 className="size-4" />} disabled={!redoSize} aria-label="重做切图调整" onClick={redoSplit} />
|
||||
<Tooltip title={t("canvas.editors.redoSplitTitle")}>
|
||||
<Button type="text" icon={<Redo2 className="size-4" />} disabled={!redoSize} aria-label={t("canvas.editors.redoSplit")} onClick={redoSplit} />
|
||||
</Tooltip>
|
||||
<Tooltip title="缩小">
|
||||
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label="缩小" onClick={viewport.zoomOut} />
|
||||
<Tooltip title={t("canvas.editors.zoomOut")}>
|
||||
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label={t("canvas.editors.zoomOut")} onClick={viewport.zoomOut} />
|
||||
</Tooltip>
|
||||
<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)}%
|
||||
</button>
|
||||
<Tooltip title="放大">
|
||||
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label="放大" onClick={viewport.zoomIn} />
|
||||
<Tooltip title={t("canvas.editors.zoomIn")}>
|
||||
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label={t("canvas.editors.zoomIn")} onClick={viewport.zoomIn} />
|
||||
</Tooltip>
|
||||
</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 className="space-y-5 py-2">
|
||||
<NumberField label="行数" value={rows} onChange={(value) => update("rows", value)} />
|
||||
<NumberField label="列数" value={columns} onChange={(value) => update("columns", value)} />
|
||||
<NumberField label={t("canvas.editors.rows")} value={rows} onChange={(value) => update("rows", value)} />
|
||||
<NumberField label={t("canvas.editors.columns")} value={columns} onChange={(value) => update("columns", value)} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button icon={<Rows3 className="size-4" />} onClick={() => addLine("horizontal")}>
|
||||
横向线
|
||||
{t("canvas.editors.horizontalLine")}
|
||||
</Button>
|
||||
<Button icon={<PanelTop className="size-4 rotate-90" />} onClick={() => addLine("vertical")}>
|
||||
纵向线
|
||||
{t("canvas.editors.verticalLine")}
|
||||
</Button>
|
||||
<Button icon={<Trash2 className="size-4" />} disabled={!active} onClick={deleteLine}>
|
||||
删除线
|
||||
{t("canvas.editors.deleteLine")}
|
||||
</Button>
|
||||
<Button icon={<ListRestart className="size-4" />} onClick={resetLines}>
|
||||
重置线
|
||||
{t("canvas.editors.resetLines")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-xl border px-4 py-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="opacity-60">切片数量</span>
|
||||
<span className="font-semibold">{total} 个</span>
|
||||
<span className="opacity-60">{t("canvas.editors.pieceCount")}</span>
|
||||
<span className="font-semibold">{t("canvas.editors.pieces", { count: total })}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="opacity-60">平均约</span>
|
||||
<span className="font-semibold">{pieceSize ? `${pieceSize.width} x ${pieceSize.height}` : "未知"}</span>
|
||||
<span className="opacity-60">{t("canvas.editors.averageSize")}</span>
|
||||
<span className="font-semibold">{pieceSize ? `${pieceSize.width} x ${pieceSize.height}` : t("canvas.editors.unknown")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" size="large" className="w-full" icon={<Grid2x2 className="size-4" />} onClick={() => onConfirm(confirmParams)}>
|
||||
生成子节点
|
||||
{t("canvas.editors.generateChildren")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Modal, Segmented } from "antd";
|
||||
import { ImagePlus } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { readImageMeta } from "@/lib/image-utils";
|
||||
import { MAX_UPSCALE_LONG_EDGE, resolveUpscaleSize, type ImageUpscaleAlgorithm, type ImageUpscaleParams } from "@/lib/canvas/canvas-image-data";
|
||||
|
||||
export type CanvasImageUpscaleParams = ImageUpscaleParams;
|
||||
|
||||
const algorithms: Array<{ value: ImageUpscaleAlgorithm; title: string; description: string }> = [
|
||||
{ value: "high", title: "高清插值", description: "适合照片和细节图" },
|
||||
{ value: "bilinear", title: "双线性", description: "平滑、速度快" },
|
||||
{ value: "nearest", title: "最近邻", description: "适合像素风格" },
|
||||
];
|
||||
const algorithms: ImageUpscaleAlgorithm[] = ["high", "bilinear", "nearest"];
|
||||
|
||||
const targetOptions = [
|
||||
{ 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 }) {
|
||||
const { t } = useTranslation();
|
||||
const [params, setParams] = useState<CanvasImageUpscaleParams>(defaultParams);
|
||||
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
|
||||
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>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">图片放大</h2>
|
||||
<h2 className="text-xl font-semibold">{t("canvas.editors.upscaleTitle")}</h2>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_360px]">
|
||||
<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} />
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between text-sm">
|
||||
<span className="opacity-60">源图</span>
|
||||
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : "读取中"}</span>
|
||||
<span className="opacity-60">{t("canvas.editors.source")}</span>
|
||||
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : t("canvas.editors.loading")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6 py-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
|
||||
block
|
||||
value={params.targetLongEdge}
|
||||
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) }))}
|
||||
/>
|
||||
{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 className="space-y-2">
|
||||
<div className="font-medium opacity-75">放大算法</div>
|
||||
<div className="font-medium opacity-75">{t("canvas.editors.algorithm")}</div>
|
||||
<Segmented
|
||||
block
|
||||
value={params.algorithm}
|
||||
options={algorithms.map((item) => ({
|
||||
value: item.value,
|
||||
options={algorithms.map((algorithm) => ({
|
||||
value: algorithm,
|
||||
label: (
|
||||
<span className="flex min-h-12 flex-col justify-center text-left leading-5">
|
||||
<span className="font-medium">{item.title}</span>
|
||||
<span className="text-xs opacity-55">{item.description}</span>
|
||||
<span className="font-medium">{t(`canvas.editors.${algorithm}`)}</span>
|
||||
<span className="text-xs opacity-55">{t(`canvas.editors.${algorithm}Description`)}</span>
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
@@ -95,15 +93,15 @@ export function CanvasNodeUpscaleDialog({ dataUrl, open, onClose, onConfirm }: {
|
||||
</div>
|
||||
<div className="rounded-xl border px-4 py-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="opacity-60">输出尺寸</span>
|
||||
<span className="font-semibold">{outputSize ? `${outputSize.width} x ${outputSize.height} px` : "未知"}</span>
|
||||
<span className="opacity-60">{t("canvas.editors.outputSize")}</span>
|
||||
<span className="font-semibold">{outputSize ? `${outputSize.width} x ${outputSize.height} px` : t("canvas.editors.unknown")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="primary" size="large" icon={<ImagePlus className="size-4" />} disabled={!canUpscale} onClick={() => onConfirm(params)}>
|
||||
生成放大图
|
||||
{t("canvas.editors.upscale")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textare
|
||||
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
|
||||
import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
const selectionBlue = "#2f80ff";
|
||||
@@ -120,6 +121,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
onContextMenu,
|
||||
}: CanvasNodeProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const { t } = useTranslation();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const definition = getNodeDefinition(data.type);
|
||||
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]);
|
||||
|
||||
const finishTitleEditing = useCallback(() => {
|
||||
const title = titleDraft.trim() || data.title || "未命名节点";
|
||||
const title = titleDraft.trim() || data.title || t("canvas.node.untitled");
|
||||
setTitleDraft(title);
|
||||
setIsEditingTitle(false);
|
||||
if (title !== data.title) onTitleChange(data.id, title);
|
||||
}, [data.id, data.title, onTitleChange, titleDraft]);
|
||||
}, [data.id, data.title, onTitleChange, t, titleDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditingTitle) return;
|
||||
@@ -342,13 +344,13 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
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"
|
||||
style={{ color: theme.node.text }}
|
||||
title="双击修改节点名称"
|
||||
title={t("canvas.node.renameHint")}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setIsEditingTitle(true);
|
||||
}}
|
||||
>
|
||||
{data.title || "未命名节点"}
|
||||
{data.title || t("canvas.node.untitled")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -466,15 +468,16 @@ const nodeContentRenderers = {
|
||||
} satisfies Record<CanvasNodeType, (props: NodeContentRendererProps) => ReactNode>;
|
||||
|
||||
function GroupNodeContent({ node, theme, groupChildCount }: NodeContentRendererProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<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 }}>
|
||||
<span className="grid size-8 place-items-center rounded-xl" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
|
||||
<Group className="size-4" />
|
||||
</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 }}>
|
||||
{groupChildCount} 个节点
|
||||
{t("canvas.node.nodeCount", { count: groupChildCount })}
|
||||
</span>
|
||||
</div>
|
||||
<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">) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<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 }} />
|
||||
<span className="text-[10px] tracking-[0.2em]">生成中</span>
|
||||
<span className="text-[10px] tracking-[0.2em]">{t("canvas.node.generating")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "node" | "theme" | "onRetry">) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<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
|
||||
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]"
|
||||
@@ -506,23 +511,25 @@ function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
重试
|
||||
{t("canvas.node.retry")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MissingPluginContent({ theme, type }: Pick<NodeContentRendererProps, "theme"> & { type: string }) {
|
||||
const { t } = useTranslation();
|
||||
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 }}>
|
||||
<Puzzle className="size-7 opacity-40" />
|
||||
<span className="text-sm">缺少插件</span>
|
||||
<span className="text-[11px] opacity-70">节点类型 “{type}” 的插件未安装或未启用</span>
|
||||
<span className="text-sm">{t("canvas.node.missingPlugin")}</span>
|
||||
<span className="text-[11px] opacity-70">{t("canvas.node.missingPluginDescription", { type })}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing, onGenerateImage }: NodeContentRendererProps) {
|
||||
const { t } = useTranslation();
|
||||
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;
|
||||
|
||||
@@ -538,11 +545,11 @@ function TextContent({ node, theme, isEditingContent, textareaRef, mentionRefere
|
||||
}}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
title="用文本生图"
|
||||
aria-label="用文本生图"
|
||||
title={t("canvas.node.generateImage")}
|
||||
aria-label={t("canvas.node.generateImage")}
|
||||
>
|
||||
<ImageIcon className="size-3.5" />
|
||||
生图
|
||||
{t("canvas.node.generate")}
|
||||
</button>
|
||||
{isEditingContent ? (
|
||||
<CanvasResourceMentionTextarea
|
||||
@@ -567,7 +574,7 @@ function TextContent({ node, theme, isEditingContent, textareaRef, mentionRefere
|
||||
style={textStyle}
|
||||
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>
|
||||
@@ -607,12 +614,13 @@ function ImageNodeContent(props: NodeContentRendererProps) {
|
||||
}
|
||||
|
||||
function EmptyImageContent({ theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: NodeContentRendererProps) {
|
||||
const { t } = useTranslation();
|
||||
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 size-14 items-center justify-center rounded-2xl" style={{ background: theme.toolbar.activeBg }}>
|
||||
<ImageIcon className="size-6 opacity-30" />
|
||||
</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>
|
||||
);
|
||||
if (isBatchRoot)
|
||||
@@ -625,29 +633,31 @@ function EmptyImageContent({ theme, isBatchRoot, batchCount, batchExpanded, batc
|
||||
}
|
||||
|
||||
function VideoNodeContent({ node, theme }: NodeContentRendererProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!node.metadata?.content)
|
||||
return (
|
||||
<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" />
|
||||
<span className="text-sm">空视频节点</span>
|
||||
<span className="text-sm">{t("canvas.node.emptyVideo")}</span>
|
||||
</div>
|
||||
);
|
||||
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) {
|
||||
const { t } = useTranslation();
|
||||
if (!node.metadata?.content)
|
||||
return (
|
||||
<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" />
|
||||
<span className="text-sm">空音频节点</span>
|
||||
<span className="text-sm">{t("canvas.node.emptyAudio")}</span>
|
||||
</div>
|
||||
);
|
||||
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 min-w-0 items-center gap-2 text-sm opacity-70">
|
||||
<Music2 className="size-4 shrink-0" />
|
||||
<span className="truncate">音频</span>
|
||||
<span className="truncate">{t("canvas.node.audio")}</span>
|
||||
</div>
|
||||
<audio src={node.metadata.content} controls className="w-full" data-canvas-no-zoom />
|
||||
</div>
|
||||
@@ -674,6 +684,7 @@ function ImageContent({
|
||||
onSetBatchPrimary?: () => void;
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const { t } = useTranslation();
|
||||
const isBatchChild = Boolean(node.metadata?.batchRootId);
|
||||
|
||||
return (
|
||||
@@ -692,7 +703,7 @@ function ImageContent({
|
||||
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]"
|
||||
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) => {
|
||||
event.stopPropagation();
|
||||
onToggleBatch?.();
|
||||
@@ -717,7 +728,7 @@ function ImageContent({
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Star className="size-3.5 text-[#2f80ff]" />
|
||||
设为主图
|
||||
{t("canvas.node.setPrimary")}
|
||||
</button>
|
||||
) : null}
|
||||
</BatchFrame>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { App, Button, Input, Modal, Popconfirm, Switch, Tabs } from "antd";
|
||||
import { AlertTriangle, Download, Puzzle, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
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";
|
||||
|
||||
export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const { message } = App.useApp();
|
||||
const plugins = usePluginStore((state) => state.plugins);
|
||||
@@ -47,10 +49,10 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
|
||||
setInstalling(true);
|
||||
try {
|
||||
const plugin = await installPluginFromUrl(target);
|
||||
message.success(`已安装插件 ${plugin.name}`);
|
||||
message.success(t("canvas.plugins.installedPlugin", { name: plugin.name }));
|
||||
setUrl("");
|
||||
} 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 {
|
||||
setInstalling(false);
|
||||
}
|
||||
@@ -60,9 +62,9 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
|
||||
setBusyId(entry.id);
|
||||
try {
|
||||
const plugin = await installPluginFromUrl(entry.url, { official: true });
|
||||
message.success(`已安装 ${plugin.name}`);
|
||||
message.success(t("canvas.plugins.installed", { name: plugin.name }));
|
||||
} 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 {
|
||||
setBusyId(null);
|
||||
}
|
||||
@@ -84,7 +86,7 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
|
||||
// upgradable=true 时(远程有更高版本),更新按钮高亮为主色以提示升级
|
||||
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 && (
|
||||
<>
|
||||
<Button
|
||||
@@ -92,11 +94,11 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
|
||||
size="small"
|
||||
icon={<RefreshCw className="size-4" />}
|
||||
loading={busyId === record.id}
|
||||
title={upgradable ? "有新版本,点击升级" : "从来源更新"}
|
||||
onClick={() => runOnPlugin(record, async () => void (await updatePlugin(record)), "已更新")}
|
||||
title={t(upgradable ? "canvas.plugins.upgradeAvailable" : "canvas.plugins.updateFromSource")}
|
||||
onClick={() => runOnPlugin(record, async () => void (await updatePlugin(record)), t("canvas.plugins.updated"))}
|
||||
/>
|
||||
<Popconfirm title="卸载该插件?" okText="卸载" cancelText="取消" onConfirm={() => uninstallPlugin(record.id)}>
|
||||
<Button type="text" size="small" danger icon={<Trash2 className="size-4" />} title="卸载" />
|
||||
<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={t("canvas.plugins.uninstall")} />
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
@@ -108,7 +110,7 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
|
||||
const withUpgradeDot = (icon: ReactNode) => (
|
||||
<span className="relative inline-flex">
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -149,20 +151,20 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs" style={{ color: theme.node.muted }}>
|
||||
本项目官方插件,来自仓库注册表
|
||||
{t("canvas.plugins.officialDescription")}
|
||||
</div>
|
||||
<Button type="text" size="small" icon={<RefreshCw className={`size-4 ${loadingOfficial ? "animate-spin" : ""}`} />} onClick={loadOfficial} disabled={loadingOfficial}>
|
||||
刷新
|
||||
{t("canvas.plugins.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
{officialError ? (
|
||||
<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>
|
||||
) : loadingOfficial && official.length === 0 ? (
|
||||
emptyHint("正在获取官方插件…")
|
||||
emptyHint(t("canvas.plugins.loadingOfficial"))
|
||||
) : official.length === 0 ? (
|
||||
emptyHint("暂无官方插件")
|
||||
emptyHint(t("canvas.plugins.noOfficial"))
|
||||
) : (
|
||||
<div className="thin-scrollbar max-h-[46vh] space-y-2 overflow-auto">
|
||||
{official.map((entry) => {
|
||||
@@ -181,7 +183,7 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
|
||||
installedControls(record, upgradable)
|
||||
) : (
|
||||
<Button type="primary" size="small" icon={<Download className="size-4" />} loading={busyId === entry.id} onClick={() => handleInstallOfficial(entry)}>
|
||||
安装
|
||||
{t("canvas.plugins.install")}
|
||||
</Button>
|
||||
),
|
||||
);
|
||||
@@ -196,27 +198,27 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
|
||||
const thirdPartyTab = (
|
||||
<div className="space-y-3">
|
||||
<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" />}>
|
||||
安装
|
||||
{t("canvas.plugins.install")}
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
|
||||
const tabs = [
|
||||
{ key: "official", label: "官方插件", children: officialTab },
|
||||
...(localPlugins.length > 0 ? [{ key: "local", label: "本地插件", children: localTab }] : []),
|
||||
{ key: "third", label: "第三方插件", children: thirdPartyTab },
|
||||
{ key: "official", label: t("canvas.plugins.official"), children: officialTab },
|
||||
...(localPlugins.length > 0 ? [{ key: "local", label: t("canvas.plugins.local"), children: localTab }] : []),
|
||||
{ key: "third", label: t("canvas.plugins.thirdParty"), children: thirdPartyTab },
|
||||
];
|
||||
|
||||
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="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" />
|
||||
<span>插件代码会在当前页面内直接执行,可访问本地数据(包含 AI API Key)。请仅安装你信任来源的插件。</span>
|
||||
<span>{t("canvas.plugins.warning")}</span>
|
||||
</div>
|
||||
<Tabs defaultActiveKey="official" items={tabs} />
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Check, Download, Pencil, Trash2, X } from "lucide-react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Button, Input } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
|
||||
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
|
||||
|
||||
export function CanvasProjectCard({ project }: { project: CanvasProject }) {
|
||||
const { i18n, t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const renameProject = useCanvasStore((state) => state.renameProject);
|
||||
@@ -35,7 +37,7 @@ export function CanvasProjectCard({ project }: { project: CanvasProject }) {
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) => toggleSelected(project.id, event.target.checked)}
|
||||
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 ? (
|
||||
<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>
|
||||
<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>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<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()}>
|
||||
{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={<X className="size-4" />} onClick={stopEditing} 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={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={<Pencil className="size-4" />} onClick={() => startEditing(project.id, project.title)} aria-label="重命名" />
|
||||
<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={<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={t("canvas.project.rename")} />
|
||||
<Button type="text" size="small" shape="circle" icon={<Trash2 className="size-4" />} onClick={() => setDeleteIds([project.id])} aria-label={t("canvas.project.delete")} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createPortal } from "react-dom";
|
||||
import { Image } from "antd";
|
||||
import { FileText, Image as ImageIcon, Music2, Video } from "lucide-react";
|
||||
|
||||
import i18n from "@/i18n";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { isImeComposing, isPlainEnterKey } from "@/lib/keyboard-event";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
@@ -192,7 +193,7 @@ export function CanvasPromptChipInput({ value, references, onChange, onSubmit, c
|
||||
{mention && candidates.length ? (
|
||||
<MentionMenu rect={mention.rect} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} />
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Tooltip } from "antd";
|
||||
import { BookOpen } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
|
||||
export function CanvasPromptLibrary({ onSelect }: { onSelect: (prompt: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title="提示词库">
|
||||
<Tooltip title={t("navigation.prompts")}>
|
||||
<Button
|
||||
type="text"
|
||||
className="!h-8 !w-8 !min-w-8 shrink-0 !rounded-full !bg-transparent !p-0"
|
||||
style={{ color: theme.node.text }}
|
||||
icon={<BookOpen className="size-3.5" />}
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="提示词库"
|
||||
aria-label={t("navigation.prompts")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<PromptSelectDialog open={open} onOpenChange={setOpen} onSelect={onSelect} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { App, Empty, Input, Popconfirm, Select, Spin, Tag } from "antd";
|
||||
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 { motion } from "motion/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
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) {
|
||||
const { t } = useTranslation();
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const [tab, setTab] = useState<PanelTab>("canvas");
|
||||
const width = useCanvasSidePanelStore((state) => state.width);
|
||||
@@ -98,9 +100,9 @@ export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onPreview
|
||||
data-canvas-no-zoom
|
||||
>
|
||||
<div className="flex items-center gap-5 px-4 pt-3.5">
|
||||
<TabButton label="画布" active={tab === "canvas"} theme={theme} onClick={() => setTab("canvas")} />
|
||||
<TabButton label="资产" active={tab === "assets"} theme={theme} onClick={() => setTab("assets")} />
|
||||
<TabButton label="提示词库" active={tab === "prompts"} theme={theme} onClick={() => setTab("prompts")} />
|
||||
<TabButton label={t("canvas.sidePanel.canvas")} active={tab === "canvas"} theme={theme} onClick={() => setTab("canvas")} />
|
||||
<TabButton label={t("canvas.sidePanel.assets")} active={tab === "assets"} theme={theme} onClick={() => setTab("assets")} />
|
||||
<TabButton label={t("canvas.sidePanel.prompts")} active={tab === "prompts"} theme={theme} onClick={() => setTab("prompts")} />
|
||||
</div>
|
||||
<div className="mt-2 min-h-0 flex-1 overflow-hidden">
|
||||
{tab === "canvas" ? (
|
||||
@@ -111,7 +113,7 @@ export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onPreview
|
||||
<CanvasPromptsTab onInsert={onInsertAsset} theme={theme} />
|
||||
)}
|
||||
</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.div>
|
||||
);
|
||||
@@ -130,15 +132,7 @@ function TabButton({ label, active, theme, onClick }: { label: string; active: b
|
||||
// 画布 Tab —— 列出节点,点击居中放大并选中
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NODE_FILTER_OPTIONS = [
|
||||
{ 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 },
|
||||
];
|
||||
const NODE_FILTER_VALUES = ["all", CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Text, CanvasNodeType.Audio, CanvasNodeType.Config, CanvasNodeType.Group];
|
||||
|
||||
function nodePreviewText(node: CanvasNodeData) {
|
||||
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 }) {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
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));
|
||||
if (!targets.length) return;
|
||||
setExporting(true);
|
||||
const hide = message.loading("正在导出选中元素…", 0);
|
||||
const hide = message.loading(t("canvas.sidePanel.exporting"), 0);
|
||||
try {
|
||||
await exportCanvasNodes(targets, `画布元素-${targets.length}个`);
|
||||
message.success(`已导出 ${targets.length} 个元素`);
|
||||
await exportCanvasNodes(targets, t("canvas.sidePanel.exportName", { count: targets.length }));
|
||||
message.success(t("canvas.sidePanel.exported", { count: targets.length }));
|
||||
exitSelect();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error("导出失败,请重试");
|
||||
message.error(t("canvas.sidePanel.exportFailed"));
|
||||
} finally {
|
||||
hide();
|
||||
setExporting(false);
|
||||
@@ -192,7 +187,7 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, th
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<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}
|
||||
<button
|
||||
type="button"
|
||||
@@ -201,12 +196,12 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, th
|
||||
style={selectMode ? { color: theme.toolbar.activeText, opacity: 1 } : undefined}
|
||||
>
|
||||
<ListChecks className="size-3.5" />
|
||||
{selectMode ? "取消" : "选择"}
|
||||
{selectMode ? t("common.cancel") : t("canvas.sidePanel.select")}
|
||||
</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 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 className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
|
||||
{filtered.length ? (
|
||||
@@ -218,20 +213,20 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, th
|
||||
const active = selectMode ? isChecked : selectedNodeIds.has(node.id);
|
||||
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}>
|
||||
<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}
|
||||
<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" />}
|
||||
</span>
|
||||
<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>
|
||||
{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>
|
||||
{selectMode || !isImage ? null : (
|
||||
<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" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -241,15 +236,15 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, th
|
||||
})}
|
||||
</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>
|
||||
{selectMode ? (
|
||||
<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">
|
||||
{allChecked ? "取消全选" : "全选"}
|
||||
{allChecked ? t("canvas.sidePanel.clearAll") : t("workbench.selectAll")}
|
||||
</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
|
||||
type="button"
|
||||
onClick={() => void handleExport()}
|
||||
@@ -258,7 +253,7 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, th
|
||||
style={{ color: theme.node.text }}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
导出选中
|
||||
{t("canvas.exportSelected")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -278,10 +273,10 @@ function CheckMark({ checked, theme }: { checked: boolean; theme: CanvasTheme })
|
||||
// 资产 Tab —— 按类型折叠分组 + 标签筛选,点击插入画布
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ASSET_GROUPS: { kind: AssetKind; label: string; icon: typeof Square }[] = [
|
||||
{ kind: "image", label: "图片", icon: ImageIcon },
|
||||
{ kind: "video", label: "视频", icon: Video },
|
||||
{ kind: "text", label: "文本", icon: FileText },
|
||||
const ASSET_GROUPS: { kind: AssetKind; icon: typeof Square }[] = [
|
||||
{ kind: "image", icon: ImageIcon },
|
||||
{ kind: "video", icon: Video },
|
||||
{ kind: "text", icon: FileText },
|
||||
];
|
||||
|
||||
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 { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
const removeAsset = useAssetStore((state) => state.removeAsset);
|
||||
@@ -314,25 +310,25 @@ const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onI
|
||||
const files = Array.from(fileList || []);
|
||||
if (!files.length) return;
|
||||
setUploading(true);
|
||||
const hide = message.loading("正在添加资产…", 0);
|
||||
const hide = message.loading(t("canvas.sidePanel.addingAssets"), 0);
|
||||
let added = 0;
|
||||
try {
|
||||
for (const file of files) {
|
||||
if (file.type.startsWith("image/")) {
|
||||
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;
|
||||
} else if (file.type.startsWith("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;
|
||||
}
|
||||
}
|
||||
if (added) message.success(`已添加 ${added} 个资产`);
|
||||
else message.warning("仅支持图片或视频文件");
|
||||
if (added) message.success(t("canvas.sidePanel.addedAssets", { count: added }));
|
||||
else message.warning(t("canvas.sidePanel.mediaOnly"));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error("添加失败,请重试");
|
||||
message.error(t("canvas.sidePanel.addFailed"));
|
||||
} finally {
|
||||
hide();
|
||||
setUploading(false);
|
||||
@@ -343,7 +339,7 @@ const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onI
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<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
|
||||
type="button"
|
||||
disabled={uploading}
|
||||
@@ -352,14 +348,14 @@ const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onI
|
||||
style={{ color: theme.node.text }}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
添加
|
||||
{t("canvas.sidePanel.add")}
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept="image/*,video/*" multiple className="hidden" onChange={(e) => void handleFiles(e.target.files)} />
|
||||
</div>
|
||||
{allTags.length ? (
|
||||
<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")}>
|
||||
全部
|
||||
{t("common.all")}
|
||||
</Tag.CheckableTag>
|
||||
{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))}>
|
||||
@@ -382,13 +378,13 @@ const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onI
|
||||
>
|
||||
<ChevronRight className={cn("size-3.5 transition-transform", !isCollapsed && "rotate-90")} />
|
||||
<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>
|
||||
</button>
|
||||
{isCollapsed ? null : (
|
||||
<div className="grid grid-cols-2 gap-2 px-1 pb-2 pt-1">
|
||||
{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>
|
||||
)}
|
||||
@@ -397,7 +393,7 @@ const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onI
|
||||
})}
|
||||
</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>
|
||||
@@ -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 }) {
|
||||
const { t } = useTranslation();
|
||||
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 }}>
|
||||
<AssetCover asset={asset} />
|
||||
@@ -413,15 +410,15 @@ function AssetCard({ asset, theme, onInsert, onRemove }: { asset: Asset; theme:
|
||||
type="button"
|
||||
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"
|
||||
aria-label="插入画布"
|
||||
aria-label={t("canvas.sidePanel.inserted")}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</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
|
||||
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"
|
||||
aria-label="移除资产"
|
||||
aria-label={t("canvas.sidePanel.removeAsset")}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
@@ -446,6 +443,7 @@ function AssetCover({ asset }: { asset: Asset }) {
|
||||
|
||||
const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const sources = usePromptSourceStore((state) => state.sources);
|
||||
const enabledSources = useMemo(() => sources.filter((source) => source.enabled), [sources]);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
@@ -455,16 +453,16 @@ const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { o
|
||||
const copyPrompt = async (prompt: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(prompt);
|
||||
message.success("已复制提示词");
|
||||
message.success(t("canvas.sidePanel.promptCopied"));
|
||||
} catch {
|
||||
message.error("复制失败");
|
||||
message.error(t("canvas.sidePanel.copyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<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 className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
|
||||
<div className="space-y-1">
|
||||
@@ -480,7 +478,7 @@ const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { o
|
||||
onInsert={onInsert}
|
||||
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>
|
||||
<PromptDetailDialog prompt={detail} onClose={() => setDetail(null)} onCopy={(prompt) => void copyPrompt(prompt)} />
|
||||
@@ -507,6 +505,7 @@ function PromptSourceGroup({
|
||||
onInsert: (payload: InsertAssetPayload) => void;
|
||||
onView: (prompt: Prompt) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// 展开过一次即缓存,避免收起后重复请求;搜索命中时也需要拿到数据来计数。
|
||||
const showResults = open || !!keyword.trim();
|
||||
const query = useQuery({ queryKey: ["side-panel-prompts", sourceId], queryFn: () => fetchSourcePrompts(sourceId), enabled: showResults, staleTime: 1000 * 60 * 60 });
|
||||
@@ -536,7 +535,7 @@ function PromptSourceGroup({
|
||||
</div>
|
||||
) : 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">
|
||||
加载失败,点击重试
|
||||
{t("canvas.sidePanel.loadFailedRetry")}
|
||||
</button>
|
||||
) : filtered.length ? (
|
||||
<div className="space-y-1.5">
|
||||
@@ -545,7 +544,7 @@ function PromptSourceGroup({
|
||||
))}
|
||||
</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>
|
||||
) : null}
|
||||
@@ -554,6 +553,7 @@ function PromptSourceGroup({
|
||||
}
|
||||
|
||||
function PromptRow({ item, theme, onInsert, onView }: { item: Prompt; theme: CanvasTheme; onInsert: () => void; onView: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
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">
|
||||
{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>
|
||||
</button>
|
||||
<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" />
|
||||
</button>
|
||||
<button
|
||||
@@ -576,8 +576,8 @@ function PromptRow({ item, theme, onInsert, onView }: { item: Prompt; theme: Can
|
||||
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"
|
||||
style={{ color: theme.toolbar.activeText }}
|
||||
aria-label="插入画布"
|
||||
title="插入画布"
|
||||
aria-label={t("canvas.sidePanel.inserted")}
|
||||
title={t("canvas.sidePanel.inserted")}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Select } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -12,6 +13,7 @@ type CanvasSizePickerProps = {
|
||||
};
|
||||
|
||||
export function CanvasSizePicker({ value, className, onChange }: CanvasSizePickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
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")}
|
||||
value={value || undefined}
|
||||
searchValue={search}
|
||||
placeholder="比例"
|
||||
placeholder={t("canvas.controls.ratio")}
|
||||
options={options}
|
||||
popupMatchSelectWidth={false}
|
||||
popupRender={(menu) => (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Settings2 } from "lucide-react";
|
||||
import { Button } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { reasoningEffortLabel, TextSettingsPanel } from "@/components/text-settings-panel";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
@@ -16,6 +17,7 @@ type CanvasTextSettingsPopoverProps = {
|
||||
};
|
||||
|
||||
export function CanvasTextSettingsPopover({ config, onConfigChange, buttonClassName, placement = "topLeft" }: CanvasTextSettingsPopoverProps) {
|
||||
const { t } = useTranslation();
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const buttonRef = useRef<HTMLSpanElement>(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">
|
||||
<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>
|
||||
</span>
|
||||
{panel}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type Ca
|
||||
import { getNodePluginId, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function CanvasToolbar({
|
||||
selectedCount,
|
||||
@@ -52,6 +53,7 @@ export function CanvasToolbar({
|
||||
onShowImageInfoChange: (show: boolean) => void;
|
||||
}) {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const colorTheme = useThemeStore((state) => state.theme);
|
||||
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 hoverStyle = { background: theme.toolbar.itemHover, 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(() => {
|
||||
@@ -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 }}>
|
||||
{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}>
|
||||
<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" />
|
||||
</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" />
|
||||
</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" />
|
||||
</ToolbarButton>
|
||||
<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" />
|
||||
</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" />
|
||||
</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" />
|
||||
</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" />
|
||||
</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" />
|
||||
</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" />
|
||||
</ToolbarButton>
|
||||
{extensionDefs.length ? (
|
||||
<ToolbarButton
|
||||
id="tool-extensions"
|
||||
label="扩展节点"
|
||||
label={t("canvas.toolbar.extensions")}
|
||||
active={extensionsOpen}
|
||||
hovered={hovered}
|
||||
activeStyle={activeStyle}
|
||||
@@ -135,13 +137,13 @@ export function CanvasToolbar({
|
||||
<Puzzle className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
) : 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" />
|
||||
</ToolbarButton>
|
||||
<Divider theme={theme} />
|
||||
<ToolbarButton
|
||||
id="tool-style"
|
||||
label="画布外观"
|
||||
label={t("canvas.toolbar.appearance")}
|
||||
active={appearanceOpen}
|
||||
hovered={hovered}
|
||||
activeStyle={activeStyle}
|
||||
@@ -160,13 +162,13 @@ export function CanvasToolbar({
|
||||
{selectedCount ? (
|
||||
<>
|
||||
<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" />
|
||||
</ToolbarButton>
|
||||
</>
|
||||
) : null}
|
||||
<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" />
|
||||
</ToolbarButton>
|
||||
</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"
|
||||
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">
|
||||
{extensionDefs.map((def) => (
|
||||
<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"
|
||||
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-1.5 text-[11px] font-medium opacity-50">主题模式</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">{t("canvas.toolbar.themeMode")}</div>
|
||||
<div className="grid grid-cols-2 gap-1 rounded-lg p-1" style={{ background: theme.toolbar.itemHover }}>
|
||||
<CanvasThemeButton colorTheme={colorTheme} targetTheme="light" onThemeChange={setTheme}>
|
||||
<Sun className="size-4" />
|
||||
浅色
|
||||
{t("canvas.toolbar.light")}
|
||||
</CanvasThemeButton>
|
||||
<CanvasThemeButton colorTheme={colorTheme} targetTheme="dark" onThemeChange={setTheme}>
|
||||
<Moon className="size-4" />
|
||||
深色
|
||||
{t("canvas.toolbar.dark")}
|
||||
</CanvasThemeButton>
|
||||
</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
|
||||
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}
|
||||
@@ -228,7 +230,7 @@ export function CanvasToolbar({
|
||||
value: "dots",
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CircleDot className="size-4" />点
|
||||
<CircleDot className="size-4" />{t("canvas.toolbar.dots")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -236,7 +238,7 @@ export function CanvasToolbar({
|
||||
value: "lines",
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Grid2x2 className="size-4" />线
|
||||
<Grid2x2 className="size-4" />{t("canvas.toolbar.lines")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -245,7 +247,7 @@ export function CanvasToolbar({
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Square className="size-4" />
|
||||
空白
|
||||
{t("canvas.toolbar.blank")}
|
||||
</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">
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 text-[11px] font-medium opacity-65">
|
||||
<Info className="size-3.5" />
|
||||
图片信息
|
||||
{t("canvas.toolbar.imageInfo")}
|
||||
</span>
|
||||
<Switch size="small" checked={showImageInfo} onChange={onShowImageInfoChange} />
|
||||
</div>
|
||||
@@ -321,6 +323,8 @@ function CanvasThemeButton({ colorTheme, targetTheme, onThemeChange, children }:
|
||||
const theme = canvasThemes[colorTheme];
|
||||
const active = colorTheme === targetTheme;
|
||||
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 (
|
||||
<AnimatedThemeToggler
|
||||
@@ -329,8 +333,8 @@ function CanvasThemeButton({ colorTheme, targetTheme, onThemeChange, children }:
|
||||
onThemeChange={onThemeChange}
|
||||
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 }}
|
||||
aria-label={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
|
||||
title={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
{children}
|
||||
</AnimatedThemeToggler>
|
||||
@@ -345,21 +349,21 @@ function DockTip({ label, x, theme }: { label: string; x: number; theme: CanvasT
|
||||
);
|
||||
}
|
||||
|
||||
function toolLabel(id: string) {
|
||||
if (id === "tool-hand") return "移动/选择";
|
||||
if (id === "tool-undo") return "撤销";
|
||||
if (id === "tool-redo") return "重做";
|
||||
if (id === "tool-text") return "文本";
|
||||
if (id === "tool-image") return "图片";
|
||||
if (id === "tool-video") return "视频";
|
||||
if (id === "tool-audio") return "音频";
|
||||
if (id === "tool-config") return "生成配置";
|
||||
if (id === "tool-group") return "组";
|
||||
if (id === "tool-extensions") return "扩展节点";
|
||||
if (id === "tool-upload") return "上传资产";
|
||||
if (id === "tool-style") return "画布外观";
|
||||
if (id === "tool-delete") return "删除选中";
|
||||
if (id === "tool-clear") return "清空画布";
|
||||
function toolLabel(id: string, t: (key: string) => string) {
|
||||
if (id === "tool-hand") return t("canvas.toolbar.move");
|
||||
if (id === "tool-undo") return t("canvas.undo");
|
||||
if (id === "tool-redo") return t("canvas.redo");
|
||||
if (id === "tool-text") return t("canvas.toolbar.text");
|
||||
if (id === "tool-image") return t("canvas.toolbar.image");
|
||||
if (id === "tool-video") return t("canvas.toolbar.video");
|
||||
if (id === "tool-audio") return t("canvas.toolbar.audio");
|
||||
if (id === "tool-config") return t("canvas.toolbar.config");
|
||||
if (id === "tool-group") return t("canvas.toolbar.group");
|
||||
if (id === "tool-extensions") return t("canvas.toolbar.extensions");
|
||||
if (id === "tool-upload") return t("canvas.toolbar.upload");
|
||||
if (id === "tool-style") return t("canvas.toolbar.appearance");
|
||||
if (id === "tool-delete") return t("canvas.deleteSelected");
|
||||
if (id === "tool-clear") return t("canvas.toolbar.clear");
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "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 { useTranslation } from "react-i18next";
|
||||
|
||||
import { UserStatusActions } from "@/components/layout/user-status-actions";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
@@ -54,6 +55,7 @@ export function CanvasTopBar({
|
||||
onToggleAgent: () => void;
|
||||
}) {
|
||||
const colorTheme = useThemeStore((state) => state.theme);
|
||||
const { t } = useTranslation();
|
||||
const theme = canvasThemes[colorTheme];
|
||||
const titleRef = useRef<HTMLDivElement>(null);
|
||||
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-auto flex min-w-0 items-center gap-2">
|
||||
<Tooltip title={sidePanelOpen ? "收起面板" : "展开面板"}>
|
||||
<Tooltip title={sidePanelOpen ? t("canvas.collapsePanel") : t("canvas.expandPanel")}>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
style={{ color: theme.node.text }}
|
||||
>
|
||||
@@ -88,22 +90,22 @@ export function CanvasTopBar({
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: "home", icon: <Home className="size-4" />, label: "主页", onClick: onHome },
|
||||
{ key: "docs", icon: <BookOpen className="size-4" />, label: "文档", onClick: () => window.open(DOCS_URL, "_blank", "noopener,noreferrer") },
|
||||
{ key: "projects", icon: <Images className="size-4" />, label: "我的画布", onClick: onProjects },
|
||||
{ key: "home", icon: <Home className="size-4" />, label: t("canvas.home"), onClick: onHome },
|
||||
{ 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: t("canvas.projects"), onClick: onProjects },
|
||||
{ type: "divider" },
|
||||
{ key: "new", icon: <Plus className="size-4" />, label: "新建画布", onClick: onCreateProject },
|
||||
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
|
||||
{ key: "new", icon: <Plus className="size-4" />, label: t("canvas.create"), onClick: onCreateProject },
|
||||
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: t("canvas.deleteCurrent"), onClick: onDeleteProject },
|
||||
{ type: "divider" },
|
||||
{ key: "import", icon: <Upload className="size-4" />, label: "导入资产", onClick: onImportImage },
|
||||
{ key: "export", icon: <Download className="size-4" />, label: "导出当前画布", onClick: onExportProject },
|
||||
{ key: "import", icon: <Upload className="size-4" />, label: t("canvas.importAsset"), onClick: onImportImage },
|
||||
{ key: "export", icon: <Download className="size-4" />, label: t("canvas.exportCurrent"), onClick: onExportProject },
|
||||
{ type: "divider" },
|
||||
{ key: "undo", disabled: !canUndo, icon: <Undo2 className="size-4" />, label: <MenuLabel text="撤销" shortcut="⌘ Z" />, onClick: onUndo },
|
||||
{ key: "redo", disabled: !canRedo, icon: <Redo2 className="size-4" />, label: <MenuLabel text="重做" shortcut="⌘ ⇧ Z / ⌘ Y" />, onClick: onRedo },
|
||||
{ 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={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" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
@@ -127,7 +129,7 @@ export function CanvasTopBar({
|
||||
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"
|
||||
onDoubleClick={onStartTitleEditing}
|
||||
title="双击修改画布名称"
|
||||
title={t("canvas.renameHint")}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
@@ -150,21 +152,21 @@ export function CanvasTopBar({
|
||||
</Button>
|
||||
</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 }}>
|
||||
<Shortcut keys={["拖动画布"]} value="平移视图" />
|
||||
<Shortcut keys={["滚轮"]} value="缩放画布" />
|
||||
<Shortcut keys={["缩放滑杆"]} value="精确调整缩放" />
|
||||
<Shortcut keys={["Ctrl / Cmd", "拖动"]} value="框选多个节点" />
|
||||
<Shortcut keys={["Shift / Ctrl / Cmd", "点击"]} value="追加选择节点" />
|
||||
<Shortcut keys={["Ctrl / Cmd", "A"]} value="全选节点" />
|
||||
<Shortcut keys={["Ctrl / Cmd", "C / V"]} value="复制 / 粘贴节点,或粘贴剪切板文本/图片" />
|
||||
<Shortcut keys={["Ctrl / Cmd", "Z"]} value="撤销" />
|
||||
<Shortcut keys={["Ctrl / Cmd", "Shift", "Z"]} value="重做" />
|
||||
<Shortcut keys={["Ctrl / Cmd", "Y"]} value="重做" />
|
||||
<Shortcut keys={["Delete / Backspace"]} value="删除选中" />
|
||||
<Shortcut keys={["Esc"]} value="取消选择并关闭浮层" />
|
||||
<Shortcut keys={["拖入图片/视频/音频"]} value="上传到画布" />
|
||||
<Shortcut keys={[t("canvas.shortcut.dragCanvas")]} value={t("canvas.shortcut.pan")} />
|
||||
<Shortcut keys={[t("canvas.shortcut.wheel")]} value={t("canvas.shortcut.zoom")} />
|
||||
<Shortcut keys={[t("canvas.shortcut.zoomSlider")]} value={t("canvas.shortcut.preciseZoom")} />
|
||||
<Shortcut keys={["Ctrl / Cmd", t("canvas.shortcut.drag")]} value={t("canvas.shortcut.boxSelect")} />
|
||||
<Shortcut keys={["Shift / Ctrl / Cmd", t("canvas.shortcut.click")]} value={t("canvas.shortcut.addSelection")} />
|
||||
<Shortcut keys={["Ctrl / Cmd", "A"]} value={t("canvas.shortcut.selectAll")} />
|
||||
<Shortcut keys={["Ctrl / Cmd", "C / V"]} value={t("canvas.shortcut.copyPaste")} />
|
||||
<Shortcut keys={["Ctrl / Cmd", "Z"]} value={t("canvas.undo")} />
|
||||
<Shortcut keys={["Ctrl / Cmd", "Shift", "Z"]} value={t("canvas.redo")} />
|
||||
<Shortcut keys={["Ctrl / Cmd", "Y"]} value={t("canvas.redo")} />
|
||||
<Shortcut keys={["Delete / Backspace"]} value={t("canvas.shortcut.delete")} />
|
||||
<Shortcut keys={["Esc"]} value={t("canvas.shortcut.escape")} />
|
||||
<Shortcut keys={[t("canvas.shortcut.dropMedia")]} value={t("canvas.shortcut.upload")} />
|
||||
</div>
|
||||
</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 }) {
|
||||
const colorTheme = useThemeStore((state) => state.theme);
|
||||
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;
|
||||
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="max-w-[140px] truncate">{label}</span>
|
||||
</button>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
|
||||
import { Compass, Focus, HelpCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button, Modal, Tooltip } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
@@ -16,6 +17,7 @@ type CanvasZoomControlsProps = {
|
||||
|
||||
export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpen, onToggleMiniMap }: CanvasZoomControlsProps) {
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const colorTheme = useThemeStore((state) => state.theme);
|
||||
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)" };
|
||||
@@ -24,20 +26,20 @@ export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpe
|
||||
return (
|
||||
<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}>
|
||||
<Tooltip title={isMiniMapOpen ? "关闭小地图" : "打开小地图"}>
|
||||
<Tooltip title={isMiniMapOpen ? t("canvas.miniMapClose") : t("canvas.miniMapOpen")}>
|
||||
<Button
|
||||
type="text"
|
||||
className="!h-8 !w-8 !min-w-8 !p-0"
|
||||
style={isMiniMapOpen ? activeStyle : { color: theme.toolbar.item }}
|
||||
icon={<Compass className="size-4" />}
|
||||
onClick={onToggleMiniMap}
|
||||
aria-label={isMiniMapOpen ? "关闭小地图" : "打开小地图"}
|
||||
aria-label={isMiniMapOpen ? t("canvas.miniMapClose") : t("canvas.miniMapOpen")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="重置视图">
|
||||
<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="重置视图" />
|
||||
<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={t("canvas.resetView")} />
|
||||
</Tooltip>
|
||||
<Tooltip title="放大/缩小画布">
|
||||
<Tooltip title={t("canvas.zoom")}>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
@@ -47,24 +49,24 @@ export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpe
|
||||
className="w-24"
|
||||
style={{ accentColor: theme.node.activeStroke }}
|
||||
onChange={(event) => onScaleChange(Number(event.target.value) / 100)}
|
||||
aria-label="放大/缩小画布"
|
||||
aria-label={t("canvas.zoom")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<span className="w-10 text-right text-xs tabular-nums" style={{ color: theme.node.muted }}>
|
||||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
<Tooltip title="快捷键">
|
||||
<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="快捷键" />
|
||||
<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={t("canvas.shortcuts")} />
|
||||
</Tooltip>
|
||||
</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 }}>
|
||||
<Shortcut label="拖动画布" value="平移视图" />
|
||||
<Shortcut label="滚轮" value="缩放画布" />
|
||||
<Shortcut label="Ctrl / Cmd + 拖动" value="框选多个节点" />
|
||||
<Shortcut label="Shift / Ctrl / Cmd + 点击" value="追加选择节点" />
|
||||
<Shortcut label="Ctrl / Cmd + C / V" value="复制 / 粘贴节点" />
|
||||
<Shortcut label="Delete / Backspace" value="删除选中" />
|
||||
<Shortcut label={t("canvas.shortcut.dragCanvas")} value={t("canvas.shortcut.pan")} />
|
||||
<Shortcut label={t("canvas.shortcut.wheel")} value={t("canvas.shortcut.zoom")} />
|
||||
<Shortcut label={`Ctrl / Cmd + ${t("canvas.shortcut.drag")}`} value={t("canvas.shortcut.boxSelect")} />
|
||||
<Shortcut label={`Shift / Ctrl / Cmd + ${t("canvas.shortcut.click")}`} value={t("canvas.shortcut.addSelection")} />
|
||||
<Shortcut label="Ctrl / Cmd + C / V" value={t("canvas.shortcut.copyPasteNodes")} />
|
||||
<Shortcut label="Delete / Backspace" value={t("canvas.shortcut.delete")} />
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FileText, Group, Image as ImageIcon, Music2, Settings2, Video } from "lucide-react";
|
||||
|
||||
import i18n from "@/i18n";
|
||||
|
||||
import { NODE_SPECS } from "@/constant/canvas";
|
||||
import { registerNodeDefinitions } from "@/lib/canvas/node-registry";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
@@ -18,12 +20,12 @@ function builtinResource(node: CanvasNodeData): CanvasNodeResource | null {
|
||||
const iconClass = "size-5";
|
||||
|
||||
const BUILTIN_DEFINITIONS: CanvasNodeDefinition[] = [
|
||||
{ type: CanvasNodeType.Text, title: "文本", 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.Video, title: "视频", icon: <Video className={iconClass} />, minimapColor: "#f97316", keepAspectRatio: () => true, resource: builtinResource },
|
||||
{ type: CanvasNodeType.Audio, title: "音频", icon: <Music2 className={iconClass} />, minimapColor: "#a855f7", resource: builtinResource },
|
||||
{ type: CanvasNodeType.Config, title: "生成配置", icon: <Settings2 className={iconClass} />, minimapColor: "#60a5fa", hasSourceHandle: false },
|
||||
{ type: CanvasNodeType.Group, title: "组", icon: <Group className={iconClass} />, minimapColor: "#94a3b8" },
|
||||
{ type: CanvasNodeType.Text, title: i18n.t("assets.kinds.text"), icon: <FileText className={iconClass} />, minimapColor: undefined, 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: i18n.t("assets.kinds.video"), icon: <Video className={iconClass} />, minimapColor: "#f97316", keepAspectRatio: () => true, resource: builtinResource },
|
||||
{ type: CanvasNodeType.Audio, title: i18n.t("assets.kinds.audio"), icon: <Music2 className={iconClass} />, minimapColor: "#a855f7", resource: builtinResource },
|
||||
{ type: CanvasNodeType.Config, title: i18n.t("canvas.configNode.title"), icon: <Settings2 className={iconClass} />, minimapColor: "#60a5fa", hasSourceHandle: false },
|
||||
{ type: CanvasNodeType.Group, title: i18n.t("canvas.node.group"), icon: <Group className={iconClass} />, minimapColor: "#94a3b8" },
|
||||
].map((def) => {
|
||||
const spec = NODE_SPECS[def.type];
|
||||
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 { LoaderCircle } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatDuration } from "@/lib/image-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const pendingMessages = ["正在创建图片", "马上就好了", "再等等", "正在整理细节"];
|
||||
|
||||
export function ImageGenerationPending({ className, label, compact = false }: { className?: string; label?: string; compact?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const [tick, setTick] = useState(0);
|
||||
const pendingMessages = t("generation.pending", { returnObjects: true }) as string[];
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setTick((value) => value + 1), 1000);
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { ConfigProvider, Switch } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import i18n from "@/i18n";
|
||||
import { type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
const qualityOptions = [
|
||||
{ value: "auto", label: "自动" },
|
||||
{ value: "high", label: "高" },
|
||||
{ value: "medium", label: "中" },
|
||||
{ value: "low", label: "低" },
|
||||
{ value: "auto", labelKey: "auto" },
|
||||
{ value: "high", labelKey: "high" },
|
||||
{ value: "medium", labelKey: "medium" },
|
||||
{ value: "low", labelKey: "low" },
|
||||
];
|
||||
const DIMENSION_STEP = 16;
|
||||
|
||||
@@ -28,7 +30,7 @@ const aspectOptions = [
|
||||
{ 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 }));
|
||||
|
||||
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) {
|
||||
const { t } = useTranslation();
|
||||
const [snapDimensionToStep, setSnapDimensionToStep] = useState(true);
|
||||
const quality = config.quality || "auto";
|
||||
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();
|
||||
}}
|
||||
>
|
||||
{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">
|
||||
<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">
|
||||
{qualityOptions.map((item) => (
|
||||
<OptionPill key={item.value} selected={quality === item.value} theme={theme} onClick={() => onConfigChange("quality", item.value)}>
|
||||
{item.label}
|
||||
{t(`settingsPanels.common.${item.labelKey}`)}
|
||||
</OptionPill>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
<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">
|
||||
<span className="text-xs font-medium" style={{ color: theme.node.muted }}>
|
||||
16倍数对齐
|
||||
{t("settingsPanels.image.align16")}
|
||||
</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} />
|
||||
</span>
|
||||
</div>
|
||||
@@ -101,7 +104,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
{aspectOptions.map((item) => (
|
||||
<button
|
||||
@@ -120,9 +123,9 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<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 }}>
|
||||
开启后生成无背景的透明图像(仅部分模型可用)
|
||||
{t("settingsPanels.image.transparentHint")}
|
||||
</div>
|
||||
</div>
|
||||
<span onMouseDown={(event) => event.stopPropagation()}>
|
||||
@@ -130,11 +133,11 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
{Array.from({ length: quickCount }, (_, index) => index + 1).map((value) => (
|
||||
<OptionPill key={value} selected={count === value} theme={theme} onClick={() => onConfigChange("count", String(value))}>
|
||||
{value} 张
|
||||
{t("settingsPanels.image.images", { count: value })}
|
||||
</OptionPill>
|
||||
))}
|
||||
<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) {
|
||||
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) {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
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 { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { ChannelEditorDrawer } from "@/components/layout/channel-editor-drawer";
|
||||
import { ConfigPromptSources } from "@/components/layout/config-prompt-sources";
|
||||
import { changeAppLocale, type AppLocale } from "@/i18n";
|
||||
import { exportAppConfig, importAppConfig } from "@/services/config-file";
|
||||
import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync";
|
||||
import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
|
||||
@@ -14,11 +17,10 @@ import { createModelChannel, modelOptionsFromChannels, normalizeModelOptionValue
|
||||
type ModelGroup = {
|
||||
capability: ModelCapability;
|
||||
modelKey: "imageModel" | "videoModel" | "textModel" | "audioModel";
|
||||
defaultLabel: string;
|
||||
labelKey: string;
|
||||
};
|
||||
|
||||
type WebdavDomainProgress = {
|
||||
label: string;
|
||||
stage: string;
|
||||
current?: number;
|
||||
total?: number;
|
||||
@@ -26,25 +28,18 @@ type WebdavDomainProgress = {
|
||||
};
|
||||
|
||||
const modelGroups: ModelGroup[] = [
|
||||
{ capability: "image", modelKey: "imageModel", defaultLabel: "默认生图模型" },
|
||||
{ capability: "video", modelKey: "videoModel", defaultLabel: "默认视频模型" },
|
||||
{ capability: "text", modelKey: "textModel", defaultLabel: "默认文本模型" },
|
||||
{ capability: "audio", modelKey: "audioModel", defaultLabel: "默认音频模型" },
|
||||
{ capability: "image", modelKey: "imageModel", labelKey: "config.preferences.defaultImageModel" },
|
||||
{ capability: "video", modelKey: "videoModel", labelKey: "config.preferences.defaultVideoModel" },
|
||||
{ capability: "text", modelKey: "textModel", labelKey: "config.preferences.defaultTextModel" },
|
||||
{ capability: "audio", modelKey: "audioModel", labelKey: "config.preferences.defaultAudioModel" },
|
||||
];
|
||||
|
||||
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> {
|
||||
return webdavDomainKeys.reduce(
|
||||
(progress, key) => ({
|
||||
...progress,
|
||||
[key]: { label: webdavDomainLabels[key], stage: "等待同步" },
|
||||
[key]: { stage: "等待同步" },
|
||||
}),
|
||||
{} as Record<AppSyncDomainKey, WebdavDomainProgress>,
|
||||
);
|
||||
@@ -52,6 +47,7 @@ function createWebdavDomainProgress(): Record<AppSyncDomainKey, WebdavDomainProg
|
||||
|
||||
export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" }: { showDoneButton?: boolean; initialTab?: ConfigTabKey }) {
|
||||
const { message } = App.useApp();
|
||||
const { i18n, t } = useTranslation();
|
||||
const configInputRef = useRef<HTMLInputElement>(null);
|
||||
const [activeTab, setActiveTab] = useState<ConfigTabKey>(initialTab);
|
||||
const [editingChannelId, setEditingChannelId] = useState("");
|
||||
@@ -68,6 +64,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue);
|
||||
const webdavReady = Boolean(webdav.url.trim());
|
||||
const editingChannel = config.channels.find((channel) => channel.id === editingChannelId) || null;
|
||||
const locale = i18n.resolvedLanguage as AppLocale;
|
||||
useEffect(() => setActiveTab(initialTab), [initialTab]);
|
||||
|
||||
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);
|
||||
setConfigDialogOpen(false);
|
||||
if (!ready) return;
|
||||
message.success(shouldPromptContinue ? "配置已保存,请继续刚才的请求" : "配置已保存");
|
||||
message.success(t(shouldPromptContinue ? "config.savedContinue" : "config.saved"));
|
||||
clearPromptContinue();
|
||||
};
|
||||
|
||||
const loadConfigFile = async (file: File) => {
|
||||
try {
|
||||
await importAppConfig(file);
|
||||
message.success("配置与用户偏好已导入");
|
||||
message.success(t("config.imported"));
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "配置文件读取失败");
|
||||
message.error(error instanceof Error ? error.message : t("config.importFailed"));
|
||||
} finally {
|
||||
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 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]);
|
||||
setEditingChannelId(channel.id);
|
||||
};
|
||||
|
||||
const deleteChannel = (id: string) => {
|
||||
if (config.channels.length <= 1) {
|
||||
message.warning("至少保留一个渠道");
|
||||
message.warning(t("config.channels.keepOne"));
|
||||
return;
|
||||
}
|
||||
updateChannels(config.channels.filter((channel) => channel.id !== id));
|
||||
@@ -115,15 +112,15 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
|
||||
const testWebdav = async () => {
|
||||
if (!webdavReady) {
|
||||
message.error("请先填写 WebDAV 地址");
|
||||
message.error(t("config.webdav.missingUrl"));
|
||||
return;
|
||||
}
|
||||
setTestingWebdav(true);
|
||||
try {
|
||||
await testWebdavConnection(webdav);
|
||||
message.success("WebDAV 连接可用");
|
||||
message.success(t("config.webdav.available"));
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "WebDAV 连接测试失败");
|
||||
message.error(error instanceof Error ? error.message : t("config.webdav.testFailed"));
|
||||
} finally {
|
||||
setTestingWebdav(false);
|
||||
}
|
||||
@@ -135,7 +132,6 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
setWebdavDomainProgress((current) => ({
|
||||
...current,
|
||||
[event.domain as AppSyncDomainKey]: {
|
||||
label: event.label || webdavDomainLabels[event.domain as AppSyncDomainKey],
|
||||
stage: event.stage,
|
||||
current: event.current,
|
||||
total: event.total,
|
||||
@@ -146,19 +142,19 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
|
||||
const syncWebdav = async () => {
|
||||
if (!webdavReady) {
|
||||
message.error("请先填写 WebDAV 地址");
|
||||
message.error(t("config.webdav.missingUrl"));
|
||||
return;
|
||||
}
|
||||
setSyncingWebdav(true);
|
||||
setWebdavDomainProgress(createWebdavDomainProgress());
|
||||
setWebdavSyncStatus("准备同步");
|
||||
setWebdavSyncStatus(t("config.webdav.preparing"));
|
||||
try {
|
||||
const result = await syncAppDataToWebdav(webdav, updateWebdavProgress);
|
||||
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) {
|
||||
setWebdavSyncStatus(error instanceof Error ? error.message : "WebDAV 同步失败");
|
||||
message.error(error instanceof Error ? error.message : "WebDAV 同步失败");
|
||||
setWebdavSyncStatus(error instanceof Error ? error.message : t("config.webdav.failed"));
|
||||
message.error(error instanceof Error ? error.message : t("config.webdav.failed"));
|
||||
} finally {
|
||||
setSyncingWebdav(false);
|
||||
}
|
||||
@@ -167,13 +163,13 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
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="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">
|
||||
<Button icon={<Upload className="size-4" />} onClick={() => configInputRef.current?.click()}>
|
||||
导入配置
|
||||
{t("config.import")}
|
||||
</Button>
|
||||
<Button icon={<Download className="size-4" />} onClick={exportAppConfig}>
|
||||
导出配置
|
||||
{t("config.export")}
|
||||
</Button>
|
||||
<input ref={configInputRef} type="file" accept="application/json,.json" className="hidden" onChange={(event) => event.target.files?.[0] && void loadConfigFile(event.target.files[0])} />
|
||||
</div>
|
||||
@@ -184,27 +180,27 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
items={[
|
||||
{
|
||||
key: "channels",
|
||||
label: "渠道",
|
||||
label: t("config.tabs.channels"),
|
||||
children: (
|
||||
<div>
|
||||
<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}>
|
||||
新增渠道
|
||||
{t("config.channels.add")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{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 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">
|
||||
{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 className="flex shrink-0 gap-2">
|
||||
<Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingChannelId(channel.id)}>
|
||||
编辑
|
||||
{t("common.edit")}
|
||||
</Button>
|
||||
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => deleteChannel(channel.id)} />
|
||||
</div>
|
||||
@@ -216,20 +212,31 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
},
|
||||
{
|
||||
key: "preferences",
|
||||
label: "偏好设置",
|
||||
label: t("config.tabs.preferences"),
|
||||
children: (
|
||||
<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">
|
||||
{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 />
|
||||
</Form.Item>
|
||||
))}
|
||||
</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">
|
||||
<Form.Item label="画布默认生图张数" extra="新建画布生图和配置节点默认使用,单个节点仍可单独覆盖。" className="mb-4">
|
||||
<Form.Item label={t("config.preferences.canvasImageCount")} extra={t("config.preferences.canvasImageCountDescription")} className="mb-4">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -239,13 +246,13 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
onBlur={(event) => updateConfig("canvasImageCount", normalizeImageCount(event.target.value))}
|
||||
/>
|
||||
</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)} />
|
||||
</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)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="默认音频语速" className="mb-4">
|
||||
<Form.Item label={t("config.preferences.audioSpeed")} className="mb-4">
|
||||
<Input
|
||||
type="number"
|
||||
min={0.25}
|
||||
@@ -257,18 +264,18 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item label="默认音频指令" className="mb-4">
|
||||
<Input.TextArea rows={2} value={config.audioInstructions} placeholder="例如:自然、温暖、适合旁白。" onChange={(event) => updateConfig("audioInstructions", event.target.value)} />
|
||||
<Form.Item label={t("config.preferences.audioInstructions")} className="mb-4">
|
||||
<Input.TextArea rows={2} value={config.audioInstructions} placeholder={t("config.preferences.audioInstructionsPlaceholder")} onChange={(event) => updateConfig("audioInstructions", event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="系统提示词" className="mb-0">
|
||||
<Input.TextArea rows={4} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
|
||||
<Form.Item label={t("config.preferences.systemPrompt")} className="mb-0">
|
||||
<Input.TextArea rows={4} value={config.systemPrompt} placeholder={t("config.preferences.systemPromptPlaceholder")} onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "prompt-sources",
|
||||
label: "提示词来源",
|
||||
label: t("config.tabs.promptSources"),
|
||||
children: <ConfigPromptSources />,
|
||||
},
|
||||
{
|
||||
@@ -281,36 +288,36 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Cloud className="size-4" />
|
||||
WebDAV 同步
|
||||
{t("config.webdav.title")}
|
||||
</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 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 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)} />
|
||||
</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)} />
|
||||
</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)} />
|
||||
</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)} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<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()}>
|
||||
测试连接
|
||||
{t("config.webdav.test")}
|
||||
</Button>
|
||||
<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>
|
||||
{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>
|
||||
{syncingWebdav || webdavSyncStatus ? <WebdavProgressGrid progress={webdavDomainProgress} /> : null}
|
||||
{syncingWebdav || webdavSyncStatus ? <WebdavProgressGrid progress={webdavDomainProgress} t={t} /> : null}
|
||||
</section>
|
||||
</Form>
|
||||
),
|
||||
@@ -320,7 +327,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
{showDoneButton ? (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button type="primary" onClick={finishConfig}>
|
||||
完成
|
||||
{t("common.done")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -330,6 +337,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
}
|
||||
|
||||
export function AppConfigModal() {
|
||||
const { t } = useTranslation();
|
||||
const isConfigOpen = useConfigStore((state) => state.isConfigOpen);
|
||||
const configTab = useConfigStore((state) => state.configTab);
|
||||
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
|
||||
@@ -337,8 +345,8 @@ export function AppConfigModal() {
|
||||
<Modal
|
||||
title={
|
||||
<div>
|
||||
<div className="text-lg font-semibold">配置与用户偏好</div>
|
||||
<div className="mt-1 text-xs font-normal text-stone-500">渠道聚合、默认模型和同步偏好</div>
|
||||
<div className="text-lg font-semibold">{t("config.title")}</div>
|
||||
<div className="mt-1 text-xs font-normal text-stone-500">{t("config.modalDescription")}</div>
|
||||
</div>
|
||||
}
|
||||
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))));
|
||||
}
|
||||
|
||||
function apiFormatLabel(apiFormat: ApiCallFormat) {
|
||||
function apiFormatLabel(apiFormat: ApiCallFormat, t: TFunction) {
|
||||
if (apiFormat === "gemini") return "Gemini";
|
||||
if (apiFormat === "ark") return "火山方舟";
|
||||
if (apiFormat === "ark") return t("config.protocols.ark");
|
||||
return "OpenAI";
|
||||
}
|
||||
|
||||
function formatWebdavTime(value: string) {
|
||||
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
function formatWebdavTime(value: string, locale: AppLocale) {
|
||||
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 (
|
||||
<div className="mt-3 grid gap-2">
|
||||
{webdavDomainKeys.map((key) => {
|
||||
@@ -400,9 +408,9 @@ function WebdavProgressGrid({ progress }: { progress: Record<AppSyncDomainKey, W
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
{item.stage}
|
||||
{syncStageLabel(item.stage, t)}
|
||||
{count ? ` · ${count}` : ""}
|
||||
</span>
|
||||
</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) {
|
||||
if (item.status === "success") return 100;
|
||||
if (item.total) return Math.min(100, Math.round(((item.current || 0) / item.total) * 100));
|
||||
|
||||
@@ -3,9 +3,14 @@ import { useEffect } from "react";
|
||||
import { ProConfigProvider } from "@ant-design/pro-components";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
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 type { AppLocale } from "@/i18n";
|
||||
import { getAntThemeConfig } from "@/lib/app-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
|
||||
@@ -20,16 +25,25 @@ const queryClient = new QueryClient({
|
||||
});
|
||||
|
||||
export function AppProviders({ children }: { children: ReactNode }) {
|
||||
const { i18n, t } = useTranslation();
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const dark = theme === "dark";
|
||||
const locale = i18n.resolvedLanguage as AppLocale;
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
document.documentElement.style.colorScheme = 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 (
|
||||
<ConfigProvider locale={zhCN} theme={getAntThemeConfig(dark)}>
|
||||
<ConfigProvider locale={locale === "zh-CN" ? zhCN : enUS} theme={getAntThemeConfig(dark)}>
|
||||
<ProConfigProvider dark={dark}>
|
||||
<App>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Bot, Menu } from "lucide-react";
|
||||
import { Button, Tooltip } from "antd";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
|
||||
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";
|
||||
|
||||
export function AppTopNav() {
|
||||
const { t } = useTranslation();
|
||||
const { pathname } = useLocation();
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const autoConnectRef = useRef(false);
|
||||
@@ -44,15 +46,15 @@ export function AppTopNav() {
|
||||
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>
|
||||
|
||||
<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"
|
||||
onClick={() => setMobileNavOpen(true)}
|
||||
aria-label="打开导航菜单"
|
||||
title="导航菜单"
|
||||
aria-label={t("topNav.openMenu")}
|
||||
title={t("topNav.menu")}
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
@@ -73,7 +75,7 @@ export function AppTopNav() {
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span className="truncate">{tool.label}</span>
|
||||
<span className="truncate">{t(`navigation.${tool.slug}`)}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -81,8 +83,8 @@ export function AppTopNav() {
|
||||
</div>
|
||||
|
||||
<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"}>
|
||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" icon={<Bot className="size-4" />} onClick={togglePanel} aria-label="打开 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={t(panelOpen ? "topNav.closeAgent" : "topNav.openAgent")} />
|
||||
</Tooltip>
|
||||
<UserStatusActions />
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
import { Button, Drawer, Input, Segmented, Select, Space } from "antd";
|
||||
import { ListPlus, Trash2 } from "lucide-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 { ModelScriptEditor } from "./model-script-editor";
|
||||
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 };
|
||||
|
||||
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 [selectOpen, setSelectOpen] = useState(false);
|
||||
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(() => {
|
||||
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 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();
|
||||
};
|
||||
|
||||
@@ -58,29 +53,29 @@ export function ChannelEditorDrawer({ open, channel, onSave, onClose }: { open:
|
||||
<Drawer
|
||||
open={open}
|
||||
width={640}
|
||||
title="编辑渠道"
|
||||
title={t("config.channelEditor.title")}
|
||||
onClose={onClose}
|
||||
styles={{ body: { paddingTop: 16 } }}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button onClick={onClose}>{t("common.cancel")}</Button>
|
||||
<Button type="primary" onClick={save}>
|
||||
保存
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<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 })} />
|
||||
</label>
|
||||
<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} />
|
||||
</label>
|
||||
<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" />
|
||||
</label>
|
||||
<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>
|
||||
<div className="text-sm font-semibold">渠道模型</div>
|
||||
<div className="mt-0.5 text-xs text-stone-500">已选 {draft.models.length} 个;为每个模型指定能力并可自定义调用脚本。</div>
|
||||
<div className="text-sm font-semibold">{t("config.channelEditor.models")}</div>
|
||||
<div className="mt-0.5 text-xs text-stone-500">{t("config.channelEditor.modelDescription", { count: draft.models.length })}</div>
|
||||
</div>
|
||||
<Button type="primary" icon={<ListPlus className="size-4" />} onClick={() => setSelectOpen(true)}>
|
||||
选择模型
|
||||
{t("config.channelEditor.selectModels")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -109,14 +104,14 @@ export function ChannelEditorDrawer({ open, channel, onSave, onClose }: { open:
|
||||
<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)} />
|
||||
<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 size="small" danger type="text" icon={<Trash2 className="size-3.5" />} onClick={() => removeModel(model.name)} />
|
||||
</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>
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { App } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { createModelChannel, useConfigStore } from "@/stores/use-config-store";
|
||||
import { usePromptSourceScheduler } from "@/hooks/use-prompt-source-scheduler";
|
||||
|
||||
export function ClientRootInit({ children }: { children: ReactNode }) {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const handledConfigParams = useRef(false);
|
||||
const updateConfig = useConfigStore((state) => state.updateConfig);
|
||||
const config = useConfigStore((state) => state.config);
|
||||
@@ -39,13 +41,13 @@ export function ClientRootInit({ children }: { children: ReactNode }) {
|
||||
}
|
||||
: 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 (apiKey) updateConfig("apiKey", apiKey);
|
||||
openConfigDialog(false);
|
||||
message.success("已导入本地直连配置");
|
||||
}, [config.channels, message, openConfigDialog, updateConfig]);
|
||||
message.success(t("config.importedDirectConfig"));
|
||||
}, [config.channels, message, openConfigDialog, t, updateConfig]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -2,17 +2,19 @@ import { App, Button, Select, Switch, Tag } from "antd";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Eye, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PromptSourceEditorDrawer } from "./prompt-source-editor-drawer";
|
||||
import { PromptSourceContentModal } from "./prompt-source-content-modal";
|
||||
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";
|
||||
|
||||
const STATUS_QUERY_KEY = ["prompt-source-statuses"];
|
||||
|
||||
export function ConfigPromptSources() {
|
||||
const { message, modal } = App.useApp();
|
||||
const { i18n, t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const sources = usePromptSourceStore((state) => state.sources);
|
||||
const schedule = usePromptSourceStore((state) => state.schedule);
|
||||
@@ -28,6 +30,7 @@ export function ConfigPromptSources() {
|
||||
const [refreshingId, setRefreshingId] = useState("");
|
||||
const [refreshingAll, setRefreshingAll] = useState(false);
|
||||
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 () => {
|
||||
await Promise.all([
|
||||
@@ -44,11 +47,11 @@ export function ConfigPromptSources() {
|
||||
|
||||
const handleDelete = (source: PromptSource) => {
|
||||
modal.confirm({
|
||||
title: `删除「${source.name}」?`,
|
||||
content: "来源配置会被移除,已经加入我的资产的内容不受影响。",
|
||||
okText: "删除",
|
||||
title: t("config.promptSources.deleteTitle", { name: source.name }),
|
||||
content: t("config.promptSources.deleteDescription"),
|
||||
okText: t("common.delete"),
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: "取消",
|
||||
cancelText: t("common.cancel"),
|
||||
onOk: async () => {
|
||||
removeSource(source.id);
|
||||
await invalidatePrompts();
|
||||
@@ -61,10 +64,10 @@ export function ConfigPromptSources() {
|
||||
try {
|
||||
const result = await refreshSource(source.id);
|
||||
await invalidatePrompts();
|
||||
message.success(`「${source.name}」已更新 ${result.count} 条`);
|
||||
message.success(t("config.promptSources.refreshed", { name: source.name, count: result.count }));
|
||||
} catch (error) {
|
||||
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 {
|
||||
setRefreshingId("");
|
||||
}
|
||||
@@ -76,10 +79,10 @@ export function ConfigPromptSources() {
|
||||
const result = await refreshAllSources();
|
||||
updateSchedule("lastFetchedAt", new Date().toISOString());
|
||||
await invalidatePrompts();
|
||||
if (result.failureCount) message.warning(`更新完成:${result.successCount} 个成功,${result.failureCount} 个失败,失败来源已保留旧缓存`);
|
||||
else message.success(`已更新 ${result.successCount} 个来源,共 ${result.total} 条`);
|
||||
if (result.failureCount) message.warning(t("config.promptSources.refreshPartial", { success: result.successCount, failed: result.failureCount }));
|
||||
else message.success(t("config.promptSources.refreshAllSuccess", { sources: result.successCount, total: result.total }));
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "更新失败");
|
||||
message.error(error instanceof Error ? error.message : t("config.promptSources.refreshFailed"));
|
||||
} finally {
|
||||
setRefreshingAll(false);
|
||||
}
|
||||
@@ -89,7 +92,7 @@ export function ConfigPromptSources() {
|
||||
<div>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-end gap-3">
|
||||
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => setEditingSource(addSource())}>
|
||||
新增来源
|
||||
{t("config.promptSources.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -102,26 +105,26 @@ export function ConfigPromptSources() {
|
||||
<div className="min-w-[220px] flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<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 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">
|
||||
{source.homepage || source.url}
|
||||
</a>
|
||||
<span className="tabular-nums">{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>}
|
||||
<span>{status?.lastSuccessAt ? `上次成功 ${formatTime(status.lastSuccessAt)}` : "尚未拉取"}</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}>{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 ? t("config.promptSources.lastSuccess", { time: formatTime(status.lastSuccessAt, i18n.resolvedLanguage) }) : t("config.promptSources.neverFetched")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap justify-end gap-2">
|
||||
<Button size="small" icon={<Eye className="size-3.5" />} onClick={() => setViewingId(source.id)}>
|
||||
查看内容
|
||||
{t("config.promptSources.view")}
|
||||
</Button>
|
||||
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={refreshingId === source.id} onClick={() => void handleRefreshOne(source)}>
|
||||
立即拉取
|
||||
{t("config.promptSources.refresh")}
|
||||
</Button>
|
||||
{!source.builtIn ? <Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingSource(source)}>编辑来源</Button> : null}
|
||||
{!source.builtIn ? <Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => handleDelete(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)}>{t("common.delete")}</Button> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -129,18 +132,18 @@ export function ConfigPromptSources() {
|
||||
</div>
|
||||
|
||||
<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 items-center gap-2">
|
||||
<span className="text-xs text-stone-500">拉取周期</span>
|
||||
<Select size="small" className="w-36" value={schedule.intervalMinutes} options={PROMPT_SOURCE_INTERVAL_OPTIONS} onChange={(value) => updateSchedule("intervalMinutes", value)} />
|
||||
<span className="text-xs text-stone-500">{t("config.promptSources.interval")}</span>
|
||||
<Select size="small" className="w-36" value={schedule.intervalMinutes} options={intervalOptions} onChange={(value) => updateSchedule("intervalMinutes", value)} />
|
||||
</div>
|
||||
<Button size="small" type="primary" icon={<RefreshCw className="size-3.5" />} loading={refreshingAll} onClick={() => void handleRefreshAll()}>
|
||||
全部立即拉取
|
||||
{t("config.promptSources.refreshAll")}
|
||||
</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 className="mt-2 text-xs text-stone-400">开启周期后,页面打开期间会按周期自动拉取所有启用的来源。</div>
|
||||
<div className="mt-2 text-xs text-stone-400">{t("config.promptSources.scheduleDescription")}</div>
|
||||
</section>
|
||||
|
||||
<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);
|
||||
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 { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -11,8 +12,10 @@ type MobileNavDrawerProps = {
|
||||
};
|
||||
|
||||
export function MobileNavDrawer({ open, activeToolSlug, onClose }: MobileNavDrawerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
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">
|
||||
{navigationTools.map((tool) => {
|
||||
const Icon = tool.icon;
|
||||
@@ -28,7 +31,7 @@ export function MobileNavDrawer({ open, activeToolSlug, onClose }: MobileNavDraw
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
<span>{tool.label}</span>
|
||||
<span>{t(`navigation.${tool.slug}`)}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,23 +2,24 @@ import { javascript } from "@codemirror/lang-javascript";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { Button, Modal } from "antd";
|
||||
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";
|
||||
|
||||
const capabilityLabels: Record<ModelCapability, string> = { image: "生图", video: "视频", text: "文本", audio: "音频" };
|
||||
|
||||
function isDarkMode() {
|
||||
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 }) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState(value);
|
||||
useEffect(() => {
|
||||
if (open) setDraft(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 (
|
||||
<Modal
|
||||
@@ -26,10 +27,10 @@ export function ModelScriptEditor({ open, capability, modelName, value, onSave,
|
||||
title={
|
||||
<div>
|
||||
<div className="text-base font-semibold">
|
||||
{capabilityLabels[capability]}
|
||||
{t(`config.channelEditor.capabilities.${capability}`)}
|
||||
{modelName ? ` - ${modelName}` : ""}
|
||||
</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>
|
||||
}
|
||||
width={1080}
|
||||
@@ -39,17 +40,17 @@ export function ModelScriptEditor({ open, capability, modelName, value, onSave,
|
||||
footer={
|
||||
<div className="flex flex-wrap items-center justify-between 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)}>
|
||||
插入{template.label}模板
|
||||
{t("config.scriptEditor.insertTemplate", { name: template.label })}
|
||||
</Button>
|
||||
))}
|
||||
<Button size="small" danger onClick={() => setDraft("")}>
|
||||
恢复默认调用
|
||||
{t("config.scriptEditor.restoreDefault")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button onClick={onClose}>{t("common.cancel")}</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
@@ -57,7 +58,7 @@ export function ModelScriptEditor({ open, capability, modelName, value, onSave,
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
保存
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</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">
|
||||
<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="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-stone-400">返回要求</div>
|
||||
<div className="text-xs leading-6 text-stone-600 dark:text-stone-300">{PLUGIN_RETURNS[capability]}</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">{getPluginReturn(capability)}</div>
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<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-[10px] 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">{t("config.scriptEditor.insert")}</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{variables.map((variable) => (
|
||||
@@ -101,7 +102,7 @@ export function ModelScriptEditor({ open, capability, modelName, value, onSave,
|
||||
height="100%"
|
||||
theme={isDarkMode() ? "dark" : "light"}
|
||||
extensions={[javascript()]}
|
||||
placeholder={"// 留空使用系统默认调用;点击右下角「插入模板」查看示例。"}
|
||||
placeholder={t("config.scriptEditor.placeholder")}
|
||||
style={{ height: "100%", fontSize: 13 }}
|
||||
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 { RefreshCw, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { fetchChannelModels } from "@/services/api/image";
|
||||
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 }) {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const [existing, setExisting] = useState<string[]>([]);
|
||||
const [fetched, setFetched] = useState<string[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
@@ -60,7 +62,7 @@ export function ModelSelectModal({ open, channel, selectedNames, onConfirm, onCl
|
||||
const fetchModels = async () => {
|
||||
if (!channel) return;
|
||||
if (!channel.baseUrl.trim() || !channel.apiKey.trim()) {
|
||||
message.error("请先填写接口地址和 API Key");
|
||||
message.error(t("config.modelSelect.missingConfig"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
@@ -68,9 +70,9 @@ export function ModelSelectModal({ open, channel, selectedNames, onConfirm, onCl
|
||||
const models = await fetchChannelModels(channel);
|
||||
setFetched(models);
|
||||
setActiveTab("new");
|
||||
message.success(`已拉取 ${models.length} 个模型`);
|
||||
message.success(t("config.modelSelect.fetched", { count: models.length }));
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "拉取模型失败");
|
||||
message.error(error instanceof Error ? error.message : t("config.modelSelect.fetchFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -90,47 +92,47 @@ export function ModelSelectModal({ open, channel, selectedNames, onConfirm, onCl
|
||||
onCancel={onClose}
|
||||
title={
|
||||
<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>
|
||||
}
|
||||
styles={{ body: { maxHeight: "62vh", overflowY: "auto" } }}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onClose}>
|
||||
取消
|
||||
{t("common.cancel")}
|
||||
</Button>,
|
||||
<Button key="confirm" type="primary" onClick={confirm}>
|
||||
确定
|
||||
{t("config.modelSelect.confirm")}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<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-[180px] flex-1" value={manual} onChange={(event) => setManual(event.target.value)} onPressEnter={addManual} placeholder="输入模型名称" />
|
||||
<Button onClick={addManual}>增加模型</Button>
|
||||
<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={t("config.modelSelect.modelName")} />
|
||||
<Button onClick={addManual}>{t("config.modelSelect.add")}</Button>
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void fetchModels()}>
|
||||
拉取模型列表
|
||||
{t("config.modelSelect.fetch")}
|
||||
</Button>
|
||||
</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
|
||||
className="mt-3"
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{ key: "new", label: `新获取的模型 (${fetched.length})` },
|
||||
{ key: "existing", label: `已有的模型 (${existing.length})` },
|
||||
{ key: "new", label: t("config.modelSelect.fetchedTab", { count: fetched.length }) },
|
||||
{ key: "existing", label: t("config.modelSelect.existingTab", { count: existing.length }) },
|
||||
]}
|
||||
/>
|
||||
|
||||
<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">
|
||||
<Button size="small" disabled={!visibleList.length} onClick={() => selectVisible(true)}>
|
||||
全选当前列表
|
||||
{t("config.modelSelect.selectVisible")}
|
||||
</Button>
|
||||
<Button size="small" disabled={!visibleSelectedCount} onClick={() => selectVisible(false)}>
|
||||
取消当前列表
|
||||
{t("config.modelSelect.clearVisible")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,7 +148,7 @@ export function ModelSelectModal({ open, channel, selectedNames, onConfirm, onCl
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { App, Button, Empty, Modal, Space, Table, Tag } from "antd";
|
||||
import { Copy, FolderPlus, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PromptDetailDialog } from "@/pages/prompts/components/prompt-detail-dialog";
|
||||
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 }) {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const [items, setItems] = useState<Prompt[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<Prompt | null>(null);
|
||||
@@ -23,12 +25,12 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
|
||||
try {
|
||||
setItems(force ? await refreshSourceItems(source.id) : await fetchSourcePrompts(source.id));
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "拉取提示词失败");
|
||||
message.error(error instanceof Error ? error.message : t("config.promptSources.content.loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[source, message],
|
||||
[source, message, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,7 +40,7 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
|
||||
|
||||
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 } });
|
||||
message.success("已加入我的资产");
|
||||
message.success(t("common.addedToAssets"));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -51,11 +53,11 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
|
||||
title={
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pr-6">
|
||||
<div>
|
||||
<div className="text-base font-semibold">{source?.name || ""} · 提示词内容</div>
|
||||
<div className="mt-0.5 text-xs font-normal text-stone-500">共 {items.length} 条</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">{t("config.promptSources.content.count", { count: items.length })}</div>
|
||||
</div>
|
||||
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={() => void load(true)}>
|
||||
立即更新
|
||||
{t("config.promptSources.content.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
@@ -67,16 +69,16 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
|
||||
dataSource={items}
|
||||
pagination={{ pageSize: 10, showSizeChanger: false, size: "small" }}
|
||||
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={[
|
||||
{
|
||||
title: "封面",
|
||||
title: t("config.promptSources.content.cover"),
|
||||
dataIndex: "coverUrl",
|
||||
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" />),
|
||||
},
|
||||
{
|
||||
title: "标题",
|
||||
title: t("config.promptSources.content.titleColumn"),
|
||||
dataIndex: "title",
|
||||
render: (title: string, item) => (
|
||||
<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",
|
||||
width: 200,
|
||||
render: (tags: string[]) => (
|
||||
@@ -100,18 +102,18 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
title: t("config.promptSources.content.actions"),
|
||||
width: 210,
|
||||
render: (_, item) => (
|
||||
<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 size="small" type="text" onClick={() => setDetail(item)}>
|
||||
详情
|
||||
{t("common.details")}
|
||||
</Button>
|
||||
<Button size="small" type="text" icon={<FolderPlus className="size-3.5" />} onClick={() => saveAsset(item)}>
|
||||
加入资产
|
||||
{t("common.addToAssets")}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
@@ -119,7 +121,7 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
|
||||
]}
|
||||
/>
|
||||
</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 { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
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 }) {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState<PromptSource | null>(source);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -18,9 +20,9 @@ export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { op
|
||||
const save = () => {
|
||||
const name = draft.name.trim();
|
||||
const url = draft.url.trim();
|
||||
if (!name) return message.warning("请输入来源名称");
|
||||
if (!isHttpUrl(url)) return message.warning("请输入有效的 JSON URL");
|
||||
if (draft.homepage.trim() && !isHttpUrl(draft.homepage.trim())) return message.warning("请输入有效的主页地址");
|
||||
if (!name) return message.warning(t("config.promptSources.editor.nameRequired"));
|
||||
if (!isHttpUrl(url)) return message.warning(t("config.promptSources.editor.invalidUrl"));
|
||||
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 });
|
||||
onClose();
|
||||
};
|
||||
@@ -29,46 +31,46 @@ export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { op
|
||||
<Drawer
|
||||
open={open}
|
||||
width={560}
|
||||
title={source?.name === "新来源" ? "新增提示词来源" : "编辑提示词来源"}
|
||||
title={t(source?.name ? "config.promptSources.editor.editTitle" : "config.promptSources.editor.addTitle")}
|
||||
onClose={onClose}
|
||||
styles={{ body: { paddingTop: 16 } }}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button onClick={onClose}>{t("common.cancel")}</Button>
|
||||
<Button type="primary" onClick={save}>
|
||||
保存
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium">来源名称</span>
|
||||
<Input value={draft.name} onChange={(event) => patch({ name: event.target.value })} placeholder="用于分类展示" />
|
||||
<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={t("config.promptSources.editor.namePlaceholder")} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<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" />
|
||||
</label>
|
||||
<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" />
|
||||
</label>
|
||||
<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 })} />
|
||||
</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">{`[
|
||||
{
|
||||
"id": "product-photo-1",
|
||||
"title": "白底商品图",
|
||||
"prompt": "生成专业白底商品摄影图",
|
||||
"title": "Product photo",
|
||||
"prompt": "Generate a professional product photo on a white background",
|
||||
"description": "",
|
||||
"coverUrl": "",
|
||||
"referenceImageUrls": [],
|
||||
"tags": ["商品", "摄影"]
|
||||
"tags": ["product", "photography"]
|
||||
}
|
||||
]`}</pre>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { BookOpen, Keyboard, Puzzle, Settings2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
||||
import { GitHubLink } from "@/components/layout/github-link";
|
||||
@@ -18,6 +19,7 @@ type UserStatusActionsProps = {
|
||||
};
|
||||
|
||||
export function UserStatusActions({ showConfig = true, variant = "default", onOpenShortcuts, onOpenPlugins }: UserStatusActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||
@@ -31,23 +33,23 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
|
||||
return (
|
||||
<div className="inline-flex shrink-0 items-center gap-1">
|
||||
{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" />
|
||||
</button>
|
||||
) : 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" />
|
||||
</a>
|
||||
{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" />
|
||||
</button>
|
||||
) : 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} />
|
||||
<GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} />
|
||||
{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" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { Modal, Tag, Timeline } from "antd";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useVersionCheck } from "@/hooks/use-version-check";
|
||||
import { APP_VERSION } from "@/constant/env";
|
||||
|
||||
function getTagColor(type: string) {
|
||||
if (type === "新增") return "green";
|
||||
if (type === "修复") return "red";
|
||||
if (type === "调整") return "blue";
|
||||
if (type === "文档") return "purple";
|
||||
if (type === "新增" || type === "Added") return "green";
|
||||
if (type === "修复" || type === "Fixed") return "red";
|
||||
if (type === "调整" || type === "Changed") return "blue";
|
||||
if (type === "文档" || type === "Docs") return "purple";
|
||||
return "default";
|
||||
}
|
||||
|
||||
function getReleaseTitle(version: string) {
|
||||
return version === "Unreleased" ? "未发布" : version;
|
||||
function releaseTypeLabel(type: string, t: TFunction) {
|
||||
const key = ({ 新增: "added", 修复: "fixed", 调整: "changed", 优化: "optimized", 文档: "docs" } as Record<string, string>)[type];
|
||||
return key ? t(`version.types.${key}`) : type;
|
||||
}
|
||||
|
||||
type VersionReleaseModalProps = {
|
||||
@@ -21,6 +24,7 @@ type VersionReleaseModalProps = {
|
||||
};
|
||||
|
||||
export function VersionReleaseModal({ className, style }: VersionReleaseModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { open, setOpen, openReleaseModal, latestVersion, releases, checking, hasNewVersion, checkLatestRelease } = useVersionCheck();
|
||||
|
||||
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"}
|
||||
style={style}
|
||||
onClick={openReleaseModal}
|
||||
title="查看版本更新"
|
||||
title={t("version.viewUpdates")}
|
||||
>
|
||||
<span className="relative inline-flex">
|
||||
{APP_VERSION}
|
||||
{hasNewVersion ? <span className="absolute -right-1.5 -top-1 size-1.5 rounded-full bg-green-500" /> : null}
|
||||
</span>
|
||||
</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="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>
|
||||
<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="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
|
||||
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"
|
||||
onClick={() => void checkLatestRelease(true)}
|
||||
>
|
||||
{checking ? "检查中..." : "检查更新"}
|
||||
{t(checking ? "version.checking" : "version.checkUpdates")}
|
||||
</button>
|
||||
</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: (
|
||||
<div>
|
||||
<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>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{release.version === latestVersion ? <Tag color="green">最新</Tag> : null}
|
||||
{release.version === APP_VERSION ? <Tag>当前</Tag> : null}
|
||||
{release.version === latestVersion ? <Tag color="green">{t("version.latest")}</Tag> : null}
|
||||
{release.version === APP_VERSION ? <Tag>{t("version.current")}</Tag> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{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">
|
||||
<Tag color={getTagColor(item.type)} className="m-0 mt-0.5 shrink-0 whitespace-nowrap">
|
||||
{item.type}
|
||||
{releaseTypeLabel(item.type, t)}
|
||||
</Tag>
|
||||
<span className="min-w-0 flex-1">{item.content}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useId, useMemo, useState } from "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 { cn } from "@/lib/utils";
|
||||
import { modelOptionLabel, modelOptionName, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
@@ -16,11 +18,13 @@ type ModelPickerProps = {
|
||||
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 [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 current = value || "";
|
||||
const pickerPlaceholder = placeholder || t("settingsPanels.model.select");
|
||||
|
||||
useEffect(() => {
|
||||
const closeOtherPicker = (event: Event) => {
|
||||
@@ -50,10 +54,10 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
)}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
title={current ? modelOptionLabel(config, current) : placeholder}
|
||||
title={current ? modelOptionLabel(config, current) : pickerPlaceholder}
|
||||
>
|
||||
<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>
|
||||
<SelectContent
|
||||
data-canvas-no-zoom
|
||||
@@ -82,9 +86,9 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
}
|
||||
|
||||
function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
|
||||
const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : capability === "audio" ? "音频" : "";
|
||||
if (capability && config.models.length) return `请先在渠道里为${label}指定模型`;
|
||||
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里添加渠道和模型";
|
||||
const label = capability ? i18n.t(`settingsPanels.model.capabilities.${capability}`) : "";
|
||||
if (capability && config.models.length) return i18n.t("settingsPanels.model.assign", { capability: 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 }) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Copy, FileText } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button, Card, Tag } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatPromptDate, type Prompt } from "@/services/api/prompts";
|
||||
|
||||
@@ -8,7 +9,7 @@ export function PromptCard({
|
||||
item,
|
||||
onOpen,
|
||||
onCopy,
|
||||
actionLabel = "复制",
|
||||
actionLabel,
|
||||
actionIcon = <Copy className="size-3.5" />,
|
||||
actionType = "text",
|
||||
extraAction,
|
||||
@@ -21,6 +22,7 @@ export function PromptCard({
|
||||
actionType?: "text" | "primary";
|
||||
extraAction?: ReactNode;
|
||||
}) {
|
||||
const { i18n, t } = useTranslation();
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
@@ -36,7 +38,7 @@ export function PromptCard({
|
||||
<div className="p-4">
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
@@ -50,7 +52,7 @@ export function PromptCard({
|
||||
</button>
|
||||
<div className="flex items-center gap-2 px-4 pb-4">
|
||||
<Button block={actionType === "primary"} type={actionType} size="small" icon={actionIcon} onClick={onCopy}>
|
||||
{actionLabel}
|
||||
{actionLabel || t("common.copy")}
|
||||
</Button>
|
||||
{extraAction}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { type UIEvent, useEffect, useState } from "react";
|
||||
import { App, Empty, Input, Modal, Spin, Tag } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ALL_PROMPTS_OPTION } from "@/services/api/prompts";
|
||||
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 }) {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION);
|
||||
@@ -23,8 +25,8 @@ export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boo
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (query.isError) message.error(query.error instanceof Error ? query.error.message : "获取提示词失败");
|
||||
}, [message, query.error, query.isError]);
|
||||
if (query.isError) message.error(query.error instanceof Error ? query.error.message : t("prompts.loadFailed"));
|
||||
}, [message, query.error, query.isError, t]);
|
||||
|
||||
const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget;
|
||||
@@ -32,30 +34,30 @@ export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boo
|
||||
};
|
||||
|
||||
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 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 className="mt-5 grid gap-3">
|
||||
<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">
|
||||
{promptCategories.map((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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
{promptTags.map((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 === ALL_PROMPTS_OPTION ? t("common.all") : tag}
|
||||
</Tag.CheckableTag>
|
||||
);
|
||||
})}
|
||||
@@ -70,10 +72,10 @@ export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boo
|
||||
) : null}
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{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>
|
||||
{!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 ? (
|
||||
<div className="py-4 text-center">
|
||||
<Spin size="small" />
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import i18n from "@/i18n";
|
||||
import { ImageSettingsTheme } from "@/components/image-settings-panel";
|
||||
import { type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { AiConfig, ReasoningEffort } from "@/stores/use-config-store";
|
||||
|
||||
const reasoningEffortOptions: Array<{ value: ReasoningEffort; label: string }> = [
|
||||
{ value: "auto", label: "自动" },
|
||||
{ value: "low", label: "低" },
|
||||
{ value: "medium", label: "中" },
|
||||
{ value: "high", label: "高" },
|
||||
{ value: "xhigh", label: "极高" },
|
||||
];
|
||||
const reasoningEffortOptions: ReasoningEffort[] = ["auto", "low", "medium", "high", "xhigh"];
|
||||
|
||||
type TextSettingsPanelProps = {
|
||||
config: AiConfig;
|
||||
@@ -20,18 +16,19 @@ type TextSettingsPanelProps = {
|
||||
};
|
||||
|
||||
export function TextSettingsPanel({ config, onConfigChange, theme, className = "space-y-4" }: TextSettingsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ImageSettingsTheme theme={theme}>
|
||||
<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="text-sm font-medium" style={{ color: theme.node.muted }}>
|
||||
推理强度
|
||||
{t("settingsPanels.text.reasoning")}
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{reasoningEffortOptions.map((item) => (
|
||||
<OptionPill key={item.value} selected={config.reasoningEffort === item.value} theme={theme} onClick={() => onConfigChange("reasoningEffort", item.value)}>
|
||||
{item.label}
|
||||
{reasoningEffortOptions.map((value) => (
|
||||
<OptionPill key={value} selected={config.reasoningEffort === value} theme={theme} onClick={() => onConfigChange("reasoningEffort", value)}>
|
||||
{t(`settingsPanels.common.${value}`)}
|
||||
</OptionPill>
|
||||
))}
|
||||
</div>
|
||||
@@ -42,7 +39,7 @@ export function TextSettingsPanel({ config, onConfigChange, theme, className = "
|
||||
}
|
||||
|
||||
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 }) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
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) => {
|
||||
const { t } = useTranslation();
|
||||
const shape = variant ?? "circle";
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -186,7 +188,7 @@ export const AnimatedThemeToggler = ({ children, className, duration = 400, vari
|
||||
return (
|
||||
<button type="button" ref={buttonRef} onClick={toggleTheme} className={cn(className)} {...props}>
|
||||
{children ?? (isDark ? <Sun /> : <Moon />)}
|
||||
<span className="sr-only">{props["aria-label"] || "切换主题"}</span>
|
||||
<span className="sr-only">{props["aria-label"] || t("theme.toggle")}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { Switch } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import i18n from "@/i18n";
|
||||
import { ImageSettingsTheme } from "@/components/image-settings-panel";
|
||||
import { boolConfig, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceDurationOptions, seedancePixelLabel, seedanceRatioOptions, seedanceResolutionOptions } from "@/lib/seedance-video";
|
||||
import { type CanvasTheme } from "@/lib/canvas-theme";
|
||||
@@ -12,18 +14,19 @@ const resolutionOptions = [
|
||||
];
|
||||
|
||||
const sizeOptions = [
|
||||
{ value: "1280x720", label: "横屏", width: 1280, height: 720 },
|
||||
{ value: "720x1280", label: "竖屏", width: 720, height: 1280 },
|
||||
{ value: "1024x1024", label: "方形", width: 1024, height: 1024 },
|
||||
{ value: "1792x1024", label: "宽屏", width: 1792, height: 1024 },
|
||||
{ value: "1024x1792", label: "长图", width: 1024, height: 1792 },
|
||||
{ value: "auto", label: "auto", width: 0, height: 0 },
|
||||
{ value: "1280x720", labelKey: "landscape", width: 1280, height: 720 },
|
||||
{ value: "720x1280", labelKey: "portrait", width: 720, height: 1280 },
|
||||
{ value: "1024x1024", labelKey: "square", width: 1024, height: 1024 },
|
||||
{ value: "1792x1024", labelKey: "widescreen", width: 1792, height: 1024 },
|
||||
{ value: "1024x1792", labelKey: "tall", width: 1024, height: 1792 },
|
||||
{ value: "auto", labelKey: "auto", width: 0, height: 0 },
|
||||
];
|
||||
|
||||
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 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));
|
||||
|
||||
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) {
|
||||
const { t } = useTranslation();
|
||||
if (isSeedanceVideoConfig(config)) {
|
||||
return <SeedanceVideoSettingsPanel config={config} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
|
||||
}
|
||||
@@ -51,8 +55,8 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
return (
|
||||
<ImageSettingsTheme theme={theme}>
|
||||
<div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
|
||||
{showTitle ? <div className="text-lg font-semibold">视频设置</div> : null}
|
||||
<SettingGroup title="清晰度" color={theme.node.muted}>
|
||||
{showTitle ? <div className="text-lg font-semibold">{t("settingsPanels.video.title")}</div> : null}
|
||||
<SettingGroup title={t("settingsPanels.video.quality")} color={theme.node.muted}>
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
{resolutionOptions.map((item) => (
|
||||
<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)} />
|
||||
</div>
|
||||
</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">
|
||||
<DimensionInput prefix="W" value={dimensions.width} disabled={size === "auto"} theme={theme} onChange={(value) => updateDimension("width", value)} />
|
||||
<span className="text-lg opacity-45">↔</span>
|
||||
@@ -79,7 +83,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
onClick={() => onConfigChange("size", item.value)}
|
||||
>
|
||||
<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 : (
|
||||
<span className="text-[11px] leading-none opacity-55">
|
||||
{item.value}
|
||||
@@ -89,7 +93,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
))}
|
||||
</div>
|
||||
</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">
|
||||
{secondOptions.map((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) {
|
||||
const { t } = useTranslation();
|
||||
const resolution = normalizeSeedanceResolution(config.vquality);
|
||||
const ratio = normalizeSeedanceRatio(config.size);
|
||||
const duration = normalizeSeedanceDuration(config.videoSeconds);
|
||||
@@ -114,8 +119,8 @@ function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle,
|
||||
return (
|
||||
<ImageSettingsTheme theme={theme}>
|
||||
<div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
|
||||
{showTitle ? <div className="text-lg font-semibold">视频设置</div> : null}
|
||||
<SettingGroup title="分辨率" color={theme.node.muted}>
|
||||
{showTitle ? <div className="text-lg font-semibold">{t("settingsPanels.video.title")}</div> : null}
|
||||
<SettingGroup title={t("settingsPanels.video.resolution")} color={theme.node.muted}>
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
{seedanceResolutionOptions.map((item) => (
|
||||
<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>
|
||||
</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">
|
||||
{seedanceRatioOptions.map((item) => (
|
||||
<button
|
||||
@@ -136,26 +141,26 @@ function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle,
|
||||
onClick={() => onConfigChange("size", item.value)}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</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">
|
||||
{seedanceDurationOptions.map((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>
|
||||
))}
|
||||
</div>
|
||||
<NumberInput value={String(duration)} min={-1} max={15} theme={theme} onChange={(value) => onConfigChange("videoSeconds", value)} />
|
||||
</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 }}>
|
||||
<SwitchRow label="生成声音" 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.generateAudio")} checked={generateAudio} theme={theme} onChange={(checked) => onConfigChange("videoGenerateAudio", String(checked))} />
|
||||
<SwitchRow label={t("settingsPanels.video.watermark")} checked={watermark} theme={theme} onChange={(checked) => onConfigChange("videoWatermark", String(checked))} />
|
||||
</div>
|
||||
</SettingGroup>
|
||||
</div>
|
||||
@@ -169,14 +174,15 @@ export function videoResolutionLabel(value: string) {
|
||||
|
||||
export function videoSizeLabel(value: string) {
|
||||
const ratio = normalizeSeedanceRatio(value);
|
||||
if (value === "adaptive" || value === "auto") return "自适应";
|
||||
if (ratio === value) return seedanceRatioOptions.find((item) => item.value === ratio)?.label || ratio;
|
||||
if (value === "adaptive" || value === "auto") return i18n.t("settingsPanels.video.adaptive");
|
||||
if (ratio === value) return i18n.t(`settingsPanels.video.ratios.${seedanceRatioLabelKeys[ratio]}`);
|
||||
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) {
|
||||
if (String(value).trim() === "-1") return "智能";
|
||||
if (String(value).trim() === "-1") return i18n.t("settingsPanels.video.smart");
|
||||
return `${value || "6"}s`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user