From 2adbe9e43acd6d010966cee0b7d1b1c6431a4151 Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Thu, 9 Jul 2026 16:53:09 +0800 Subject: [PATCH] feat(agent): add site tools support for canvas project listing and media generation --- canvas-agent/src/canvas-session.ts | 14 +- canvas-agent/src/config.ts | 2 +- canvas-agent/src/schemas.ts | 24 ++ .../canvas/canvas-local-agent-panel.tsx | 30 ++ web/src/components/image-settings-panel.tsx | 3 + web/src/components/video-settings-panel.tsx | 4 + web/src/lib/agent/agent-site-tools.ts | 256 ++++++++++++++++++ web/src/pages/image/index.tsx | 20 ++ web/src/pages/video/index.tsx | 20 ++ web/src/stores/use-agent-store.ts | 2 +- web/src/stores/use-workbench-agent-store.ts | 32 +++ 11 files changed, 404 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/agent/agent-site-tools.ts create mode 100644 web/src/stores/use-workbench-agent-store.ts diff --git a/canvas-agent/src/canvas-session.ts b/canvas-agent/src/canvas-session.ts index afb1a28..6bc480c 100644 --- a/canvas-agent/src/canvas-session.ts +++ b/canvas-agent/src/canvas-session.ts @@ -7,6 +7,18 @@ import type { CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js"; type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void }; +const SITE_TOOLS = new Set([ + "site_navigate", + "canvas_list_projects", + "workbench_image_get_config", + "workbench_image_generate", + "workbench_video_get_config", + "workbench_video_generate", + "prompts_search", + "assets_list", + "assets_add", +]); + export class CanvasSession { private clients = new Map(); private pending = new Map(); @@ -49,7 +61,7 @@ export class CanvasSession { if (!isToolName(name)) throw new Error(`未知工具:${String(name)}`); let tool: ToolName = name; let input = parseToolInput(tool, rawInput) as Record; - if (tool === "site_navigate") { + if (SITE_TOOLS.has(tool)) { if (!this.clients.size) throw new Error("当前没有已连接网页"); return await this.requestCanvasTool(tool, input); } diff --git a/canvas-agent/src/config.ts b/canvas-agent/src/config.ts index 69e1f68..9c43170 100644 --- a/canvas-agent/src/config.ts +++ b/canvas-agent/src/config.ts @@ -7,7 +7,7 @@ export const DEFAULT_PORT = 17371; export const CONFIG_DIR = path.join(os.homedir(), ".infinite-canvas"); export const CONFIG_FILE = path.join(CONFIG_DIR, "canvas-agent.json"); export const VERSION = readPackageVersion(); -export const AGENT_PROMPT = "你正在帮助用户操作 Infinite Canvas 网站。切换网站页面用 site_navigate,可跳 / (首页)、/canvas (我的画布)、/canvas/:id (指定画布)、/image、/video、/prompts、/assets、/config。需要改动画布时优先使用已配置的 infinite-canvas MCP 工具:先 canvas_get_state 读取当前画布,再根据任务使用 canvas_create_text_node、canvas_generate_text、canvas_generate_image、canvas_generate_video、canvas_generate_audio、canvas_create_generation_flow、canvas_create_config_node、canvas_run_generation、canvas_update_node、canvas_connect_nodes 等通用工具;复杂批量改动再用 canvas_apply_ops,删除连线可用 delete_connections。若当前不在画布页,画布工具会报错,需先用 site_navigate 打开画布。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。"; +export const AGENT_PROMPT = "你正在帮助用户操作 Infinite Canvas 网站。切换网站页面用 site_navigate,可跳 / (首页)、/canvas (我的画布)、/canvas/:id (指定画布)、/image、/video、/prompts、/assets、/config。需要改动画布时优先使用已配置的 infinite-canvas MCP 工具:先 canvas_get_state 读取当前画布,再根据任务使用 canvas_create_text_node、canvas_generate_text、canvas_generate_image、canvas_generate_video、canvas_generate_audio、canvas_create_generation_flow、canvas_create_config_node、canvas_run_generation、canvas_update_node、canvas_connect_nodes 等通用工具;复杂批量改动再用 canvas_apply_ops,删除连线可用 delete_connections。若当前不在画布页,画布工具会报错,需先用 site_navigate 打开画布。想了解或打开用户已有画布,用 canvas_list_projects 获取画布清单和 id,再用 site_navigate 跳 /canvas/:id 打开。生图工作台可用 workbench_image_get_config 看可选项、workbench_image_generate 填提示词并生成;视频创作台对应 workbench_video_get_config 与 workbench_video_generate;用 prompts_search 分页搜索提示词库;用 assets_list 查看「我的素材」、assets_add 新增文本或图片素材。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。"; export type SiteWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] }; export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; workspace?: SiteWorkspaceConfig }; diff --git a/canvas-agent/src/schemas.ts b/canvas-agent/src/schemas.ts index 9b8b02e..a07af63 100644 --- a/canvas-agent/src/schemas.ts +++ b/canvas-agent/src/schemas.ts @@ -8,6 +8,7 @@ const generationModeSchema = z.enum(["text", "image", "video", "audio"]); export const toolNames = [ "site_navigate", + "canvas_list_projects", "canvas_get_state", "canvas_get_selection", "canvas_export_snapshot", @@ -31,6 +32,13 @@ export const toolNames = [ "canvas_select_nodes", "canvas_set_viewport", "canvas_run_generation", + "workbench_image_get_config", + "workbench_image_generate", + "workbench_video_get_config", + "workbench_video_generate", + "prompts_search", + "assets_list", + "assets_add", ] as const; export type ToolName = (typeof toolNames)[number]; @@ -79,6 +87,7 @@ const generationFlowSchema = z.object({ export const toolInputSchemas = { site_navigate: z.object({ path: z.string() }), + canvas_list_projects: z.object({ keyword: z.string().optional(), page: z.number().optional(), pageSize: z.number().optional() }), canvas_get_state: z.object({}).passthrough(), canvas_get_selection: z.object({}).passthrough(), canvas_export_snapshot: z.object({}).passthrough(), @@ -102,10 +111,18 @@ export const toolInputSchemas = { canvas_select_nodes: z.object({ ids: z.array(z.string()) }), canvas_set_viewport: z.object({ viewport: viewportSchema }), canvas_run_generation: z.object({ nodeId: z.string(), mode: generationModeSchema.optional(), prompt: z.string().optional() }), + workbench_image_get_config: z.object({}).passthrough(), + workbench_image_generate: z.object({ prompt: z.string(), model: z.string().optional(), quality: z.string().optional(), size: z.string().optional(), count: z.number().optional(), run: z.boolean().optional() }), + workbench_video_get_config: z.object({}).passthrough(), + workbench_video_generate: z.object({ prompt: z.string(), model: z.string().optional(), size: z.string().optional(), seconds: z.string().optional(), resolution: z.string().optional(), generateAudio: z.boolean().optional(), watermark: z.boolean().optional(), run: z.boolean().optional() }), + prompts_search: z.object({ keyword: z.string().optional(), category: z.string().optional(), tags: z.array(z.string()).optional(), page: z.number().optional(), pageSize: z.number().optional() }), + assets_list: z.object({ kind: z.enum(["all", "text", "image", "video"]).optional(), keyword: z.string().optional(), page: z.number().optional(), pageSize: z.number().optional() }), + assets_add: z.object({ kind: z.enum(["text", "image"]), title: z.string(), content: z.string().optional(), imageUrl: z.string().optional(), tags: z.array(z.string()).optional(), source: z.string().optional(), note: z.string().optional() }), } satisfies Record; export const toolDescriptions: Record = { site_navigate: "跳转网站页面。path 可为 / (首页)、/canvas (我的画布)、/canvas/:id (指定画布)、/image (生图工作台)、/video (视频创作台)、/prompts (提示词库)、/assets (我的素材)、/config (配置)。操作画布前若不在画布页,先用本工具打开画布。", + canvas_list_projects: "列出用户全部画布(仅标题、创建/更新时间、节点数、连线数,不含完整数据),支持 keyword 搜索和 page/pageSize 分页。返回的 id 可配合 site_navigate 跳转到 /canvas/:id 打开对应画布。", canvas_get_state: "读取当前网页画布的节点、连线、选区和视口。", canvas_get_selection: "读取当前网页画布选中的节点。", canvas_export_snapshot: "导出当前画布快照,用于理解布局。", @@ -129,4 +146,11 @@ export const toolDescriptions: Record = { canvas_select_nodes: "设置当前选中节点。", canvas_set_viewport: "调整画布视口。", canvas_run_generation: "触发指定节点生成,通常用于配置节点或文本/图片/视频/音频节点。", + workbench_image_get_config: "读取生图工作台的当前参数和可选项(可用模型、质量、尺寸/宽高比、张数范围),在调用 workbench_image_generate 前先了解可选值。", + workbench_image_generate: "在生图工作台填入提示词并按需设置 model、quality、size(如 1:1 或 1024x1024)、count,run 默认 true 会自动点击生成按钮。会自动跳转到生图工作台。生成为异步过程,工具返回代表已提交,结果请在工作台查看。", + workbench_video_get_config: "读取视频创作台的当前参数和可选项(可用模型、尺寸/比例、时长、清晰度/分辨率、是否生成声音与水印)。", + workbench_video_generate: "在视频创作台填入提示词并按需设置 model、size、seconds、resolution、generateAudio、watermark,run 默认 true 会自动点击生成按钮。会自动跳转到视频创作台。生成为异步过程,工具返回代表已提交。", + prompts_search: "搜索提示词库(第三方提示词合集),支持 keyword、category、tags 过滤和 page/pageSize 分页,返回标题、提示词、分类、标签、封面等。", + assets_list: "列出用户「我的素材」,支持 kind(text/image/video)过滤、keyword 搜索和 page/pageSize 分页。为控制体积不返回图片/视频原始 data,仅返回封面与元信息。", + assets_add: "向「我的素材」新增素材。kind=text 时用 content 传文本内容;kind=image 时用 imageUrl 传图片地址或 dataURL。可附带 title、tags、source、note。", }; diff --git a/web/src/components/canvas/canvas-local-agent-panel.tsx b/web/src/components/canvas/canvas-local-agent-panel.tsx index fb14270..b0fc80f 100644 --- a/web/src/components/canvas/canvas-local-agent-panel.tsx +++ b/web/src/components/canvas/canvas-local-agent-panel.tsx @@ -9,6 +9,7 @@ import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; import { useAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "@/stores/use-agent-store"; import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops"; +import { isSiteTool, runSiteTool, SITE_TOOL_LABELS } from "@/lib/agent/agent-site-tools"; import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui"; const MAX_ATTACHMENTS = 6; @@ -252,6 +253,23 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb }; const runToolCall = async (endpoint: string, token: string, payload: AgentPendingToolCall) => { + if (isSiteTool(payload.name)) { + try { + setAgentState({ activity: SITE_TOOL_LABELS[payload.name], waiting: true }); + addEventLog(toolName(payload.name), payload, payload); + const result = await runSiteTool(payload.name, payload.input || {}, navigate); + await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result }); + setAgentState({ activity: "工具完成", waiting: true }); + addEventLog(`${toolName(payload.name)}完成`, result, result); + addMessage({ role: "tool", title: `${toolName(payload.name)}完成`, text: siteToolSummary(payload.name, result), detail: { requestId: payload.requestId, name: payload.name, input: payload.input, result } }); + } catch (error) { + const message = error instanceof Error ? error.message : "工具执行失败"; + setAgentState({ activity: "工具失败", waiting: false }); + addMessage({ role: "tool", title: "工具失败", text: message, detail: payload }); + await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message }); + } + return; + } try { const input: { ops?: CanvasAgentOp[]; path?: string } = payload.input || {}; setAgentState({ activity: payload.name === "canvas_apply_ops" ? "执行画布操作" : payload.name === "site_navigate" ? "跳转页面" : "读取画布", waiting: true }); @@ -877,9 +895,21 @@ function toolName(name: string) { if (name === "canvas_set_viewport") return "调整视口"; if (name === "canvas_run_generation") return "触发生成"; if (name === "site_navigate") return "网站跳转"; + if (isSiteTool(name)) return SITE_TOOL_LABELS[name]; return name; } +function siteToolSummary(name: string, result: unknown) { + const data = result && typeof result === "object" ? (result as Record) : {}; + 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 === "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 "已读取工作台配置"; + return "已完成"; +} + function isReadTool(name: string) { return name === "canvas_get_state" || name === "canvas_get_selection" || name === "canvas_export_snapshot"; } diff --git a/web/src/components/image-settings-panel.tsx b/web/src/components/image-settings-panel.tsx index 3a1f2ed..ee86d61 100644 --- a/web/src/components/image-settings-panel.tsx +++ b/web/src/components/image-settings-panel.tsx @@ -28,6 +28,9 @@ 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 imageAspectOptions = aspectOptions.map((item) => ({ value: item.size || item.value, label: item.label })); + type ImageSettingsPanelProps = { config: AiConfig; onConfigChange: (key: "quality" | "size" | "count", value: string) => void; diff --git a/web/src/components/video-settings-panel.tsx b/web/src/components/video-settings-panel.tsx index 9fb977d..9933347 100644 --- a/web/src/components/video-settings-panel.tsx +++ b/web/src/components/video-settings-panel.tsx @@ -22,6 +22,10 @@ const sizeOptions = [ const secondOptions = [6, 10, 12, 16, 20]; +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 videoSecondOptions = secondOptions.map((value) => String(value)); + type VideoSettingsPanelProps = { config: AiConfig; onConfigChange: (key: "vquality" | "size" | "videoSeconds" | "videoGenerateAudio" | "videoWatermark", value: string) => void; diff --git a/web/src/lib/agent/agent-site-tools.ts b/web/src/lib/agent/agent-site-tools.ts new file mode 100644 index 0000000..575a04c --- /dev/null +++ b/web/src/lib/agent/agent-site-tools.ts @@ -0,0 +1,256 @@ +import type { NavigateFunction } from "react-router-dom"; + +import { fetchPrompts } from "@/services/api/prompts"; +import { uploadImage } from "@/services/image-storage"; +import { imageAspectOptions, imageQualityOptions } from "@/components/image-settings-panel"; +import { videoResolutionOptions, videoSecondOptions, videoSizeOptions } from "@/components/video-settings-panel"; +import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; +import { useAssetStore } from "@/stores/use-asset-store"; +import { modelOptionLabel, modelOptionName, normalizeModelOptionValue, useConfigStore } from "@/stores/use-config-store"; +import { useWorkbenchAgentStore } from "@/stores/use-workbench-agent-store"; + +// 在网页端执行 Agent 的「站点级」工具(画布列表、工作台生成、提示词搜索、素材增删查等)。 +// 这些工具的数据都在浏览器本地(localforage / zustand),因此由本模块直接读写对应 store 后返回结果。 + +export const SITE_TOOL_NAMES = [ + "canvas_list_projects", + "workbench_image_get_config", + "workbench_image_generate", + "workbench_video_get_config", + "workbench_video_generate", + "prompts_search", + "assets_list", + "assets_add", +] as const; + +export type SiteToolName = (typeof SITE_TOOL_NAMES)[number]; + +export function isSiteTool(name: string): name is SiteToolName { + return (SITE_TOOL_NAMES as readonly string[]).includes(name); +} + +export const SITE_TOOL_LABELS: Record = { + canvas_list_projects: "画布列表", + workbench_image_get_config: "生图配置", + workbench_image_generate: "生图工作台生成", + workbench_video_get_config: "视频配置", + workbench_video_generate: "视频创作台生成", + prompts_search: "搜索提示词", + assets_list: "素材列表", + assets_add: "添加素材", +}; + +type SiteToolInput = Record; + +export async function runSiteTool(name: SiteToolName, input: SiteToolInput, navigate: NavigateFunction): Promise { + switch (name) { + case "canvas_list_projects": + return listCanvasProjects(input); + case "workbench_image_get_config": + return getImageConfig(); + case "workbench_image_generate": + return runImageWorkbench(input, navigate); + case "workbench_video_get_config": + return getVideoConfig(); + case "workbench_video_generate": + return runVideoWorkbench(input, navigate); + case "prompts_search": + return searchPrompts(input); + case "assets_list": + return listAssets(input); + case "assets_add": + return addAsset(input); + default: + throw new Error(`未知工具:${name}`); + } +} + +function listCanvasProjects(input: SiteToolInput) { + const { projects, hydrated } = useCanvasStore.getState(); + if (!hydrated) throw new Error("画布还在加载中,请稍后重试"); + const keyword = String(input.keyword || "").trim().toLowerCase(); + const filtered = keyword ? projects.filter((project) => project.title.toLowerCase().includes(keyword)) : projects; + const { page, pageSize, start, end } = paginate(input, filtered.length, 20); + const items = filtered.slice(start, end).map((project) => ({ + id: project.id, + title: project.title, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + nodeCount: project.nodes.length, + connectionCount: project.connections.length, + })); + return { total: filtered.length, page, pageSize, items, hint: "用 site_navigate 跳转 /canvas/{id} 打开对应画布" }; +} + +function getImageConfig() { + const { config } = useConfigStore.getState(); + const model = config.imageModel || config.model; + return { + current: { model, modelName: modelOptionName(model), quality: config.quality || "auto", size: config.size || "1:1", count: config.count || "1" }, + models: config.imageModels.map((value) => ({ value, label: modelOptionLabel(config, value) })), + qualityOptions: imageQualityOptions, + sizeOptions: imageAspectOptions, + countRange: { min: 1, max: 15 }, + }; +} + +function runImageWorkbench(input: SiteToolInput, navigate: NavigateFunction) { + const configStore = useConfigStore.getState(); + const applied: Record = {}; + if (typeof input.model === "string" && input.model.trim()) { + const value = normalizeModelOptionValue(input.model, configStore.config.channels) || input.model; + configStore.updateConfig("imageModel", value); + applied.model = value; + } + if (typeof input.quality === "string" && input.quality.trim()) { + configStore.updateConfig("quality", input.quality); + applied.quality = input.quality; + } + if (typeof input.size === "string" && input.size.trim()) { + configStore.updateConfig("size", input.size); + applied.size = input.size; + } + if (input.count != null) { + const count = String(Math.max(1, Math.min(15, Math.floor(Number(input.count)) || 1))); + configStore.updateConfig("count", count); + applied.count = count; + } + const prompt = typeof input.prompt === "string" ? input.prompt : undefined; + const run = input.run !== false; + navigate("/image"); + useWorkbenchAgentStore.getState().dispatchImage({ prompt, run }); + return { ok: true, navigated: "/image", prompt, run, applied, note: run ? "已跳转生图工作台并触发生成,结果请稍后在工作台查看" : "已跳转生图工作台并填入参数,未触发生成" }; +} + +function getVideoConfig() { + const { config } = useConfigStore.getState(); + const model = config.videoModel || config.model; + return { + current: { + model, + modelName: modelOptionName(model), + size: config.size || "1280x720", + seconds: config.videoSeconds || "6", + resolution: config.vquality || "720", + generateAudio: config.videoGenerateAudio !== "false", + watermark: config.videoWatermark === "true", + }, + models: config.videoModels.map((value) => ({ value, label: modelOptionLabel(config, value) })), + sizeOptions: videoSizeOptions, + secondsOptions: videoSecondOptions, + resolutionOptions: videoResolutionOptions, + }; +} + +function runVideoWorkbench(input: SiteToolInput, navigate: NavigateFunction) { + const configStore = useConfigStore.getState(); + const applied: Record = {}; + if (typeof input.model === "string" && input.model.trim()) { + const value = normalizeModelOptionValue(input.model, configStore.config.channels) || input.model; + configStore.updateConfig("videoModel", value); + applied.model = value; + } + if (typeof input.size === "string" && input.size.trim()) { + configStore.updateConfig("size", input.size); + applied.size = input.size; + } + if (typeof input.seconds === "string" && input.seconds.trim()) { + configStore.updateConfig("videoSeconds", input.seconds); + applied.seconds = input.seconds; + } + if (typeof input.resolution === "string" && input.resolution.trim()) { + configStore.updateConfig("vquality", input.resolution); + applied.resolution = input.resolution; + } + if (typeof input.generateAudio === "boolean") { + configStore.updateConfig("videoGenerateAudio", String(input.generateAudio)); + applied.generateAudio = input.generateAudio; + } + if (typeof input.watermark === "boolean") { + configStore.updateConfig("videoWatermark", String(input.watermark)); + applied.watermark = input.watermark; + } + const prompt = typeof input.prompt === "string" ? input.prompt : undefined; + const run = input.run !== false; + navigate("/video"); + useWorkbenchAgentStore.getState().dispatchVideo({ prompt, run }); + return { ok: true, navigated: "/video", prompt, run, applied, note: run ? "已跳转视频创作台并触发生成,结果请稍后在工作台查看" : "已跳转视频创作台并填入参数,未触发生成" }; +} + +async function searchPrompts(input: SiteToolInput) { + const page = Math.max(1, Math.floor(Number(input.page)) || 1); + const pageSize = Math.max(1, Math.min(50, Math.floor(Number(input.pageSize)) || 20)); + const tags = Array.isArray(input.tags) ? input.tags.filter((tag): tag is string => typeof tag === "string") : []; + const result = await fetchPrompts({ keyword: String(input.keyword || ""), category: String(input.category || "全部"), tag: tags, page, pageSize }); + return { + total: result.total, + page, + pageSize, + categories: result.categories, + tags: result.tags.slice(0, 60), + items: result.items.map((prompt) => ({ id: prompt.id, title: prompt.title, prompt: prompt.prompt, category: prompt.category, tags: prompt.tags, coverUrl: prompt.coverUrl, githubUrl: prompt.githubUrl })), + }; +} + +function listAssets(input: SiteToolInput) { + const { assets, hydrated } = useAssetStore.getState(); + if (!hydrated) throw new Error("素材还在加载中,请稍后重试"); + const kind = input.kind === "text" || input.kind === "image" || input.kind === "video" ? input.kind : "all"; + const keyword = String(input.keyword || "").trim().toLowerCase(); + const filtered = assets.filter((asset) => { + if (kind !== "all" && asset.kind !== kind) return false; + if (!keyword) return true; + return [asset.title, asset.note, asset.source, ...asset.tags].filter(Boolean).join(" ").toLowerCase().includes(keyword); + }); + const { page, pageSize, start, end } = paginate(input, filtered.length, 20); + const items = filtered.slice(start, end).map((asset) => ({ + id: asset.id, + kind: asset.kind, + title: asset.title, + tags: asset.tags, + source: asset.source, + note: asset.note, + createdAt: asset.createdAt, + updatedAt: asset.updatedAt, + coverUrl: asset.coverUrl || undefined, + content: asset.kind === "text" ? asset.data.content : undefined, + })); + return { total: filtered.length, page, pageSize, items }; +} + +async function addAsset(input: SiteToolInput) { + const kind = input.kind; + const title = String(input.title || "").trim(); + if (!title) throw new Error("请提供素材标题 title"); + const tags = Array.isArray(input.tags) ? input.tags.filter((tag): tag is string => typeof tag === "string") : []; + const source = typeof input.source === "string" ? input.source : "Agent"; + const note = typeof input.note === "string" ? input.note : undefined; + const store = useAssetStore.getState(); + if (kind === "text") { + const content = String(input.content || "").trim(); + if (!content) throw new Error("kind=text 时需要提供 content 文本内容"); + const id = store.addAsset({ kind: "text", title, coverUrl: "", tags, source, note, data: { content } }); + return { ok: true, id, kind: "text" }; + } + if (kind === "image") { + const imageUrl = String(input.imageUrl || "").trim(); + if (!imageUrl) throw new Error("kind=image 时需要提供 imageUrl(图片地址或 dataURL)"); + let stored; + try { + stored = await uploadImage(imageUrl); + } catch { + throw new Error("无法读取该图片地址,请改用 dataURL 或可跨域访问的图片链接"); + } + const id = store.addAsset({ kind: "image", title, coverUrl: stored.url, tags, source, note, data: { dataUrl: stored.url, storageKey: stored.storageKey, width: stored.width, height: stored.height, bytes: stored.bytes, mimeType: stored.mimeType } }); + return { ok: true, id, kind: "image" }; + } + throw new Error("assets_add 仅支持 kind=text 或 kind=image"); +} + +function paginate(input: SiteToolInput, total: number, defaultSize: number) { + const pageSize = Math.max(1, Math.min(100, Math.floor(Number(input.pageSize)) || defaultSize)); + const maxPage = Math.max(1, Math.ceil(total / pageSize)); + const page = Math.min(maxPage, Math.max(1, Math.floor(Number(input.page)) || 1)); + const start = (page - 1) * pageSize; + return { page, pageSize, start, end: start + pageSize }; +} diff --git a/web/src/pages/image/index.tsx b/web/src/pages/image/index.tsx index ae285b8..9b21ef9 100644 --- a/web/src/pages/image/index.tsx +++ b/web/src/pages/image/index.tsx @@ -17,6 +17,7 @@ import { formatBytes, formatDuration, getDataUrlByteSize, readImageMeta } from " import { requestEdit, requestGeneration } from "@/services/api/image"; import { deleteStoredImages, resolveImageUrl, uploadImage } from "@/services/image-storage"; import { useAssetStore } from "@/stores/use-asset-store"; +import { useWorkbenchAgentStore } from "@/stores/use-workbench-agent-store"; import type { ReferenceImage } from "@/types/image"; type GeneratedImage = { @@ -88,6 +89,10 @@ export default function ImagePage() { const [selectedLogIds, setSelectedLogIds] = useState([]); const [previewLog, setPreviewLog] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [autoRunToken, setAutoRunToken] = useState(0); + const imageCommand = useWorkbenchAgentStore((state) => state.imageCommand); + const clearImageCommand = useWorkbenchAgentStore((state) => state.clearImageCommand); + const processedCommandRef = useRef(0); const model = effectiveConfig.imageModel || effectiveConfig.model; const canGenerate = Boolean(prompt.trim()); @@ -191,6 +196,21 @@ export default function ImagePage() { } }; + // 响应 Agent 面板下发的生图命令:填入提示词,并按需自动触发生成。 + useEffect(() => { + if (!imageCommand || imageCommand.nonce === processedCommandRef.current) return; + processedCommandRef.current = imageCommand.nonce; + clearImageCommand(); + if (typeof imageCommand.prompt === "string") setPrompt(imageCommand.prompt); + if (imageCommand.run && !running) setAutoRunToken((value) => value + 1); + }, [imageCommand, clearImageCommand, running]); + + useEffect(() => { + if (!autoRunToken) return; + void generate(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoRunToken]); + const downloadImage = (image: GeneratedImage, index: number) => { saveAs(image.dataUrl, `image-${index + 1}.png`); }; diff --git a/web/src/pages/video/index.tsx b/web/src/pages/video/index.tsx index f896f27..ffb6bd4 100644 --- a/web/src/pages/video/index.tsx +++ b/web/src/pages/video/index.tsx @@ -16,6 +16,7 @@ import { deleteStoredMedia, resolveMediaUrl, uploadMediaFile } from "@/services/ import { resolveImageUrl, uploadImage } from "@/services/image-storage"; import { createVideoGenerationTask, pollVideoGenerationTask, storeGeneratedVideo, type VideoGenerationTask } from "@/services/api/video"; import { useAssetStore } from "@/stores/use-asset-store"; +import { useWorkbenchAgentStore } from "@/stores/use-workbench-agent-store"; import { modelOptionLabel, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { useThemeStore } from "@/stores/use-theme-store"; import type { ReferenceImage } from "@/types/image"; @@ -93,6 +94,10 @@ export default function VideoPage() { const [selectedLogIds, setSelectedLogIds] = useState([]); const [previewLog, setPreviewLog] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [autoRunToken, setAutoRunToken] = useState(0); + const videoCommand = useWorkbenchAgentStore((state) => state.videoCommand); + const clearVideoCommand = useWorkbenchAgentStore((state) => state.clearVideoCommand); + const processedCommandRef = useRef(0); const model = effectiveConfig.videoModel || effectiveConfig.model; const canGenerate = Boolean(prompt.trim()); @@ -187,6 +192,21 @@ export default function VideoPage() { } }; + // 响应 Agent 面板下发的视频命令:填入提示词,并按需自动触发生成。 + useEffect(() => { + if (!videoCommand || videoCommand.nonce === processedCommandRef.current) return; + processedCommandRef.current = videoCommand.nonce; + clearVideoCommand(); + if (typeof videoCommand.prompt === "string") setPrompt(videoCommand.prompt); + if (videoCommand.run && !running) setAutoRunToken((value) => value + 1); + }, [videoCommand, clearVideoCommand, running]); + + useEffect(() => { + if (!autoRunToken) return; + void generate(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoRunToken]); + const buildRequestSnapshot = () => { const text = prompt.trim(); if (!text) { diff --git a/web/src/stores/use-agent-store.ts b/web/src/stores/use-agent-store.ts index c861a0c..9f4b0f6 100644 --- a/web/src/stores/use-agent-store.ts +++ b/web/src/stores/use-agent-store.ts @@ -6,7 +6,7 @@ export type AgentChatRole = "user" | "assistant" | "system" | "tool" | "error"; export type AgentAttachment = { id: string; name: string; type: string; size: number; url: string; dataUrl: string }; export type AgentChatItem = { id: string; role: AgentChatRole; title?: string; text: string; meta?: string; detail?: unknown; attachments?: AgentAttachment[]; streamId?: string }; export type AgentEventLog = { id: string; time: string; title: string; text: string; raw?: unknown }; -export type AgentPendingToolCall = { requestId: string; name: string; input?: { ops?: CanvasAgentOp[]; path?: string } }; +export type AgentPendingToolCall = { requestId: string; name: string; input?: { ops?: CanvasAgentOp[]; path?: string } & Record }; export type AgentCanvasContext = { snapshot: CanvasAgentSnapshot; applyOps: (ops?: CanvasAgentOp[]) => CanvasAgentSnapshot; undoOps: () => CanvasAgentSnapshot | null; canUndo: boolean }; export type AgentThreadSummary = { id: string; preview: string; name?: string | null; cwd?: string; status?: string; source?: unknown; createdAt?: number; updatedAt?: number }; export type AgentPanelTab = "chat" | "setup" | "history" | "log"; diff --git a/web/src/stores/use-workbench-agent-store.ts b/web/src/stores/use-workbench-agent-store.ts new file mode 100644 index 0000000..02d4c43 --- /dev/null +++ b/web/src/stores/use-workbench-agent-store.ts @@ -0,0 +1,32 @@ +import { create } from "zustand"; + +// Agent 面板通过该 store 向生图/视频工作台派发命令:设置提示词、并可选自动点击生成。 +// 参数(模型/质量/尺寸/张数等)由 Agent 面板直接写入 use-config-store,工作台页面从 config 读取; +// prompt 与 run 通过这里下发,页面用 nonce 判断是否为新命令,消费后调用 clear 清空。 + +export type WorkbenchCommand = { + nonce: number; + prompt?: string; + run: boolean; +}; + +type WorkbenchAgentStore = { + imageCommand: WorkbenchCommand | null; + videoCommand: WorkbenchCommand | null; + dispatchImage: (command: Omit) => void; + dispatchVideo: (command: Omit) => void; + clearImageCommand: () => void; + clearVideoCommand: () => void; +}; + +let nonce = 0; +const nextNonce = () => (nonce += 1); + +export const useWorkbenchAgentStore = create((set) => ({ + imageCommand: null, + videoCommand: null, + dispatchImage: (command) => set({ imageCommand: { ...command, nonce: nextNonce() } }), + dispatchVideo: (command) => set({ videoCommand: { ...command, nonce: nextNonce() } }), + clearImageCommand: () => set({ imageCommand: null }), + clearVideoCommand: () => set({ videoCommand: null }), +}));