feat(platform): 开源发布 - 初始化 Open AI Canvas

This commit is contained in:
ddCat committed 2026-07-22 14:29:10 +08:00
commit aea35896eb
365 files changed
+66410

No files matched your search

+513
View File
@@ -0,0 +1,513 @@
import { App, Button, Empty, Form, Input, Modal, Segmented, Select, Space, Table, Typography } from "antd";
import type { ColumnsType } from "antd/es/table";
import { FileText, Plus, RefreshCw, RotateCcw, Search, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ListToolbar, PageHeader, TableSurface, WorkspacePage } from "@/components/layout/workspace-page";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import { formatTaskKind, operationOptions, statusLabel } from "@/lib/generation-task-display";
import { cancelGenerationTask, createAgentSession, createGenerationTask, listGenerationTasks, listTaskLogs, queryGenerationTask, retryGenerationTask, type CreateTaskInput, type GenerationTask, type TaskLog, type TaskStatus } from "@/services/api/task-center";
import { syncGenerationTaskToCanvasStore } from "@/lib/canvas/canvas-generation-task-sync";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
const taskTableClassName = "app-data-table";
export default function TasksPage() {
const { message } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const projects = useCanvasStore((state) => state.projects);
const [form] = Form.useForm<CreateTaskInput & { operation: string }>();
const [tasks, setTasks] = useState<GenerationTask[]>([]);
const [loading, setLoading] = useState(false);
const [actingId, setActingId] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "succeeded" | "failed">("all");
const [keyword, setKeyword] = useState("");
const [projectFilter, setProjectFilter] = useState("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [detailTask, setDetailTask] = useState<GenerationTask | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [taskLogs, setTaskLogs] = useState<TaskLog[]>([]);
const [logsLoading, setLogsLoading] = useState(false);
const syncedCanvasTaskIdsRef = useRef(new Set<string>());
const tasksRef = useRef<GenerationTask[]>([]);
const projectTitleById = useMemo(() => new Map(projects.map((project) => [project.id, project.title || "未命名画布"])), [projects]);
const projectOptions = useMemo(() => projects.map((project) => ({ label: project.title || "未命名画布", value: project.id })), [projects]);
const filteredTasks = useMemo(() => tasks.filter((task) => {
if (statusFilter === "active") return task.status === "queued" || task.status === "running";
if (statusFilter === "failed") return task.status === "failed" || task.status === "cancelled";
if (statusFilter === "succeeded") return task.status === "succeeded";
return true;
}).filter((task) => {
if (projectFilter !== "all" && task.projectId !== projectFilter) return false;
const query = keyword.trim().toLowerCase();
return !query || `${task.prompt} ${task.model || ""} ${formatTaskKind(task)} ${getCanvasTitle(task, projectTitleById)}`.toLowerCase().includes(query);
}), [keyword, projectFilter, projectTitleById, statusFilter, tasks]);
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(filteredTasks.length / pageSize));
if (page > maxPage) setPage(maxPage);
}, [filteredTasks.length, page, pageSize]);
const syncCompletedCanvasTasks = useCallback(async (items: GenerationTask[]) => {
const pendingTaskIds = new Set(
useCanvasStore
.getState()
.projects.flatMap((project) => project.nodes)
.filter((node) => node.metadata?.taskId && (node.metadata.status !== "success" || !node.metadata.content))
.map((node) => node.metadata!.taskId!),
);
const candidates = items.filter((task) => task.status === "succeeded" && pendingTaskIds.has(task.id) && task.projectId && task.type.startsWith("canvas_") && !syncedCanvasTaskIdsRef.current.has(task.id));
await Promise.all(
candidates.map(async (task) => {
syncedCanvasTaskIdsRef.current.add(task.id);
try {
const detail = task.resultJson ? task : await queryGenerationTask(task.id);
await syncGenerationTaskToCanvasStore(detail);
} catch {
syncedCanvasTaskIdsRef.current.delete(task.id);
}
}),
);
}, []);
const loadTasks = useCallback(async (showLoading = false) => {
if (showLoading) setLoading(true);
try {
const next = await listGenerationTasks();
setTasks((current) => reconcileTaskSummaries(current, next));
void syncCompletedCanvasTasks(next);
return next;
} catch (error) {
if (showLoading) message.error(error instanceof Error ? error.message : "任务加载失败");
return undefined;
} finally {
if (showLoading) setLoading(false);
}
}, [message, syncCompletedCanvasTasks]);
const openTaskDetail = useCallback(
async (task: GenerationTask) => {
setDetailTask(task);
setTaskLogs([]);
setDetailLoading(true);
setLogsLoading(true);
try {
const [detail, logs] = await Promise.all([queryGenerationTask(task.id), listTaskLogs(task.id)]);
setDetailTask(detail);
setTaskLogs(logs);
if (await syncGenerationTaskToCanvasStore(detail)) message.success("已同步到画布");
} catch (error) {
message.error(error instanceof Error ? error.message : "任务详情加载失败");
} finally {
setDetailLoading(false);
setLogsLoading(false);
}
},
[message],
);
useEffect(() => {
tasksRef.current = tasks;
}, [tasks]);
useEffect(() => {
let stopped = false;
let timer = 0;
const poll = async (initial = false) => {
const next = await loadTasks(initial);
if (stopped) return;
const items = next || tasksRef.current;
const hasActiveTasks = items.some((task) => task.status === "queued" || task.status === "running");
timer = window.setTimeout(() => void poll(false), document.hidden ? 60_000 : hasActiveTasks ? 10_000 : 60_000);
};
const handleVisibility = () => {
if (document.hidden) return;
window.clearTimeout(timer);
void poll(false);
};
void poll(true);
document.addEventListener("visibilitychange", handleVisibility);
return () => {
stopped = true;
window.clearTimeout(timer);
document.removeEventListener("visibilitychange", handleVisibility);
};
}, [loadTasks]);
const runAction = async (id: string, action: "retry" | "cancel") => {
setActingId(id);
try {
const next = action === "retry" ? await retryGenerationTask(id) : await cancelGenerationTask(id);
setTasks((items) => items.map((item) => (item.id === id ? next : item)));
message.success(action === "retry" ? "任务已重新入队" : "任务已取消");
} catch (error) {
message.error(error instanceof Error ? error.message : "操作失败");
} finally {
setActingId("");
}
};
const submitTask = async () => {
const values = await form.validateFields();
setCreating(true);
try {
if (values.operation === "agent_session") {
const textModel = values.model?.trim() || effectiveConfig.textModel || effectiveConfig.model;
if (!isAiConfigReady(effectiveConfig, textModel)) {
message.error("请先在设置里配置可用的文本模型、Base URL 和 API Key");
return;
}
const requestConfig = resolveModelRequestConfig(effectiveConfig, textModel);
const detail = await createAgentSession({ projectId: values.projectId, prompt: values.prompt, config: backendProviderConfig(requestConfig) });
setTasks((items) => [...detail.tasks, ...items]);
} else {
const videoModel = values.model?.trim() || effectiveConfig.videoModel || effectiveConfig.model;
if (values.operation !== "compare_versions" && !isAiConfigReady(effectiveConfig, videoModel)) {
message.error("请先在设置里配置可用的视频模型、Base URL 和 API Key");
return;
}
const requestConfig = resolveModelRequestConfig(effectiveConfig, videoModel);
const task = await createGenerationTask({
projectId: values.projectId,
type: `video_${values.operation}`,
operation: values.operation,
prompt: values.prompt,
provider: values.operation === "compare_versions" ? "internal-agent" : "openai-compatible",
model: values.operation === "compare_versions" ? "version-router" : requestConfig.model,
input: {
source: "tasks-page",
mode: values.operation === "compare_versions" ? "workflow" : "video",
prompt: buildVideoOperationPrompt(values.operation, values.prompt),
config: values.operation === "compare_versions" ? undefined : backendProviderConfig(requestConfig),
metadata: { videoEditOperation: values.operation },
},
});
setTasks((items) => [task, ...items]);
}
setCreateOpen(false);
form.resetFields();
message.success("任务已创建");
} catch (error) {
message.error(error instanceof Error ? error.message : "任务创建失败");
} finally {
setCreating(false);
}
};
const columns = useMemo<ColumnsType<GenerationTask>>(
() => [
{
title: "任务",
dataIndex: "prompt",
width: 420,
render: (prompt, task) => (
<div className="min-w-0 space-y-1 overflow-hidden">
<div title={prompt} className="line-clamp-2 max-w-full break-words text-sm font-medium leading-5 text-stone-950 dark:text-stone-100">
{prompt}
</div>
<Typography.Text className="block !text-xs !text-stone-500 dark:!text-stone-400">
{formatTaskKind(task)}
</Typography.Text>
</div>
),
},
{
title: "画布名称",
width: 180,
render: (_, task) => {
const name = getCanvasTitle(task, projectTitleById);
return (
<Typography.Text ellipsis className={task.projectId ? "block max-w-[160px] !text-stone-700 dark:!text-stone-200" : "block max-w-[160px] !text-stone-400 dark:!text-stone-500"}>
{name}
</Typography.Text>
);
},
},
{
title: "状态",
width: 160,
render: (_, task) => (
<div className="min-w-0">
<span className={`inline-flex h-7 items-center rounded-md border px-2.5 text-xs font-medium ${statusBadgeClassName(task.status)}`}>{statusLabel[task.status]}</span>
<div className="mt-1 truncate text-[10px] text-stone-500 dark:text-stone-400" title={task.stage || statusLabel[task.status]}>{task.stage || statusLabel[task.status]}{typeof task.progress === "number" ? ` · ${task.progress}%` : ""}</div>
</div>
),
},
{
title: "模型",
width: 190,
render: (_, task) => (
<Typography.Text ellipsis className="block max-w-[170px] !text-stone-700 dark:!text-stone-200">
{formatModelName(task)}
</Typography.Text>
),
},
{
title: "尝试",
dataIndex: "attempts",
width: 90,
render: (attempts: number) => <span className="text-stone-500 dark:text-stone-400">第 {attempts || 1} 次</span>,
},
{
title: "创建时间",
dataIndex: "createdAt",
width: 190,
render: (value) => <span className="text-stone-500 dark:text-stone-400">{formatDate(value)}</span>,
},
{
title: "操作",
width: 140,
render: (_, task) => (
<Space size={2}>
<Button type="text" size="small" title="查看详情" className="!text-stone-500 hover:!text-stone-950 dark:!text-stone-400 dark:hover:!text-white" icon={<FileText className="size-3.5" />} onClick={() => openTaskDetail(task)} />
<Button type="text" size="small" title={task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error) ? "内容审核未通过,请修改提示词后新建任务" : "重新生成"} className="!text-stone-500 hover:!text-stone-950 dark:!text-stone-400 dark:hover:!text-white" icon={<RotateCcw className="size-3.5" />} loading={actingId === task.id} disabled={(task.status !== "failed" && task.status !== "cancelled") || task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)} onClick={() => runAction(task.id, "retry")} />
<Button type="text" size="small" title="取消任务" className="!text-stone-500 hover:!text-red-600 dark:!text-stone-400 dark:hover:!text-red-300" danger icon={<X className="size-3.5" />} loading={actingId === task.id} disabled={task.status === "succeeded" || task.status === "cancelled"} onClick={() => runAction(task.id, "cancel")} />
</Space>
),
},
],
[actingId, openTaskDetail, projectTitleById],
);
return (
<>
<WorkspacePage grid>
<PageHeader
title="任务中心"
description="查看生成进度、结果和失败原因。"
meta={<span className="text-xs text-foreground/45">{filteredTasks.length} 个任务{loading ? " · 正在同步" : ""}</span>}
actions={(
<>
<Button icon={<RefreshCw className={`size-3.5 ${loading ? "animate-spin" : ""}`} />} onClick={() => void loadTasks(true)}>刷新</Button>
<Button type="primary" icon={<Plus className="size-3.5" />} onClick={() => setCreateOpen(true)}>新建任务</Button>
</>
)}
/>
<ListToolbar active={Boolean(keyword || projectFilter !== "all" || statusFilter !== "all")} onReset={() => { setKeyword(""); setProjectFilter("all"); setStatusFilter("all"); setPage(1); }}>
<Input allowClear className="w-full sm:w-80" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索指令、模型、类型或画布" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} />
<Select className="w-44" value={projectFilter} onChange={(value) => { setProjectFilter(value); setPage(1); }} options={[{ label: "全部画布", value: "all" }, ...projectOptions]} />
<Segmented
size="small"
value={statusFilter}
onChange={(value) => { setStatusFilter(value as typeof statusFilter); setPage(1); }}
options={[
{ label: `全部 ${tasks.length}`, value: "all" },
{ label: `进行中 ${tasks.filter((task) => task.status === "queued" || task.status === "running").length}`, value: "active" },
{ label: `已完成 ${tasks.filter((task) => task.status === "succeeded").length}`, value: "succeeded" },
{ label: `异常 ${tasks.filter((task) => task.status === "failed" || task.status === "cancelled").length}`, value: "failed" },
]}
/>
</ListToolbar>
<TableSurface>
<Table
rowKey="id"
size="middle"
className={taskTableClassName}
columns={columns}
dataSource={filteredTasks}
loading={loading}
rowClassName={() => "align-top"}
tableLayout="fixed"
pagination={{ current: page, pageSize, total: filteredTasks.length, showSizeChanger: true, pageSizeOptions: [20, 50, 100], showTotal: (total, range) => `${range[0]}-${range[1]} / 共 ${total} 个任务`, onChange: (nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); } }}
scroll={{ x: 1320 }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={<span className="text-stone-500 dark:text-stone-400">暂无任务</span>} className="py-12" /> }}
/>
</TableSurface>
</WorkspacePage>
<Modal title="新建异步生成任务" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={submitTask} confirmLoading={creating} okText="创建任务">
<Form form={form} layout="vertical" initialValues={{ operation: "agent_session" }}>
<Form.Item name="operation" label="任务类型" rules={[{ required: true, message: "请选择任务类型" }]}>
<Select options={operationOptions} />
</Form.Item>
<Form.Item name="prompt" label="创作指令" rules={[{ required: true, message: "请输入创作指令" }]}>
<Input.TextArea rows={5} placeholder="描述短剧、MV、TVC 或要执行的视频编辑操作" />
</Form.Item>
<Form.Item name="projectId" label="绑定画布">
<Select allowClear showSearch optionFilterProp="label" options={projectOptions} placeholder={projectOptions.length ? "可选,选择要绑定的画布" : "暂无本地画布"} />
</Form.Item>
<Form.Item name="model" label="目标模型">
<Input placeholder="可选,例如 seedance、kling、wan、nano-banana" />
</Form.Item>
</Form>
</Modal>
<Modal title="任务详情" open={Boolean(detailTask)} onCancel={() => setDetailTask(null)} footer={null} width={820}>
{detailTask ? (
<div className="space-y-4">
<div className="grid gap-3 text-sm md:grid-cols-2">
<InfoItem label="状态" value={statusLabel[detailTask.status]} />
<InfoItem label="画布名称" value={getCanvasTitle(detailTask, projectTitleById)} />
<InfoItem label="任务类型" value={formatTaskKind(detailTask)} />
<InfoItem label="模型" value={formatModelName(detailTask)} />
<InfoItem label="尝试次数" value={`第 ${detailTask.attempts || 1} 次`} />
<InfoItem label="创建时间" value={formatDate(detailTask.createdAt)} />
</div>
{detailTask.error ? <pre className="max-h-28 overflow-auto whitespace-pre-wrap rounded-lg bg-red-50 p-3 text-xs text-red-700">{generationErrorMessage(detailTask.error)}</pre> : null}
<TaskResultMedia value={detailTask.resultJson} taskType={detailTask.type} />
<DetailBlock title="输入" value={detailLoading ? "详情加载中..." : formatTaskJson(detailTask.inputJson)} />
<DetailBlock title="结果" value={detailLoading ? "详情加载中..." : formatTaskJson(detailTask.resultJson)} />
<div>
<Typography.Text strong>日志</Typography.Text>
<div className="mt-2 max-h-60 overflow-auto rounded-lg bg-slate-950 p-3 text-xs text-slate-100">
{logsLoading ? "日志加载中..." : taskLogs.length ? taskLogs.map((log) => `[${new Date(log.createdAt).toLocaleString()}] ${log.level.toUpperCase()} ${log.message}${log.payload ? `\n${generationErrorMessage(log.payload)}` : ""}`).join("\n\n") : "暂无日志"}
</div>
</div>
</div>
) : null}
</Modal>
</>
);
}
function reconcileTaskSummaries(current: GenerationTask[], next: GenerationTask[]) {
if (current.length !== next.length) return next;
const currentById = new Map(current.map((task) => [task.id, task]));
let changed = false;
const reconciled = next.map((task) => {
const previous = currentById.get(task.id);
if (previous?.updatedAt === task.updatedAt) return previous;
changed = true;
return task;
});
return changed ? reconciled : current;
}
function TaskResultMedia({ value, taskType }: { value?: string; taskType: string }) {
const urls = resultMediaUrls(value);
if (!urls.length) return null;
return (
<div>
<Typography.Text strong>生成结果</Typography.Text>
<div className="mt-2 grid max-h-[360px] grid-cols-2 gap-2 overflow-auto rounded-lg bg-stone-950 p-2 md:grid-cols-3">
{urls.map((url, index) => isVideoResult(url, taskType)
? <video key={`${url}-${index}`} src={url} className="aspect-video w-full rounded-md bg-black object-contain" controls preload="metadata" />
: <img key={`${url}-${index}`} src={url} alt={`生成结果 ${index + 1}`} className="aspect-square w-full rounded-md bg-black object-contain" />)}
</div>
</div>
);
}
function resultMediaUrls(value?: string) {
if (!value) return [];
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
parsed = value;
}
const urls: string[] = [];
const visit = (item: unknown, key = "") => {
if (typeof item === "string") {
const isInlineMedia = /^(data:image\/|data:video\/)/.test(item);
const isMediaPath = /\.(png|jpe?g|webp|gif|avif|mp4|webm|mov)(?:$|\?)/i.test(item);
const isNamedMediaUrl = /^(https?:|blob:)/.test(item) && /(url|image|video|result|output|media)/i.test(key);
if ((isInlineMedia || isMediaPath || isNamedMediaUrl) && !urls.includes(item)) urls.push(item);
return;
}
if (Array.isArray(item)) return item.forEach((value) => visit(value, key));
if (item && typeof item === "object") Object.entries(item).forEach(([field, value]) => visit(value, field));
};
visit(parsed);
return urls.slice(0, 12);
}
function isVideoResult(value: string, taskType: string) {
return value.startsWith("data:video/") || /\.(mp4|webm|mov)(?:$|\?)/i.test(value) || taskType.includes("video");
}
function getCanvasTitle(task: GenerationTask, projectTitleById: Map<string, string>) {
if (!task.projectId) return "未绑定";
return projectTitleById.get(task.projectId) || "未同步画布";
}
function statusBadgeClassName(status: TaskStatus) {
if (status === "succeeded") return "border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
if (status === "running") return "border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300";
if (status === "queued") return "border-blue-500/20 bg-blue-500/10 text-blue-700 dark:text-blue-300";
if (status === "failed") return "border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300";
return "border-stone-300 bg-stone-100 text-stone-600 dark:border-stone-700 dark:bg-stone-900 dark:text-stone-400";
}
function formatModelName(task: GenerationTask) {
const raw = (task.model || task.provider || "").trim();
const model = raw.includes("::") ? raw.split("::").pop()?.trim() || raw : raw;
if (!model) return "工作流";
if (model === "version-router") return "版本对比工作流";
if (model === "workflow-router") return "工作流路由";
if (model === "internal-agent") return "内置工作流";
if (model === "openai-compatible") return "OpenAI 兼容接口";
return model;
}
function formatDate(value?: string) {
if (!value) return "-";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString();
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<Typography.Text type="secondary" className="block text-xs">
{label}
</Typography.Text>
<Typography.Text className="block truncate text-sm" title={value}>
{value}
</Typography.Text>
</div>
);
}
function DetailBlock({ title, value }: { title: string; value: string }) {
return (
<div>
<Typography.Text strong>{title}</Typography.Text>
<pre className="mt-2 max-h-60 overflow-auto rounded-lg bg-slate-950 p-3 text-xs text-slate-100">{value}</pre>
</div>
);
}
function formatTaskJson(value?: string) {
if (!value) return "无";
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
function backendProviderConfig(config: ReturnType<typeof resolveModelRequestConfig>) {
return {
apiFormat: config.apiFormat,
interfaceType: config.interfaceType,
baseUrl: config.baseUrl,
apiKey: config.apiKey,
model: config.model,
size: config.size,
quality: config.quality,
count: config.count,
videoSeconds: config.videoSeconds,
vquality: config.vquality,
videoGenerateAudio: config.videoGenerateAudio,
videoWatermark: config.videoWatermark,
audioVoice: config.audioVoice,
audioFormat: config.audioFormat,
audioSpeed: config.audioSpeed,
audioInstructions: config.audioInstructions,
systemPrompt: config.systemPrompt,
};
}
function buildVideoOperationPrompt(operation: string, prompt: string) {
const operationLabel = operationOptions.find((item) => item.value === operation)?.label || operation;
if (operation === "compare_versions") return `请对以下视频结果版本做对比分析,输出推荐版本、差异点和修改建议:\n${prompt}`;
return `视频编辑任务:${operationLabel}\n创作要求:${prompt}`;
}