From b4515ed85f06df472da782d08940104a17b91d20 Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Tue, 16 Jun 2026 13:22:15 +0800 Subject: [PATCH] feat(agent): enhance Codex thread management with workspace verification and improved thread loading --- canvas-agent/src/agents.ts | 52 +++- canvas-agent/src/http-server.ts | 10 +- docs/content/docs/canvas/canvas-shortcuts.mdx | 2 +- docs/content/docs/overview/features.mdx | 5 +- docs/content/docs/progress/pending-test.mdx | 58 ---- web/src/app/(user)/asset-library/page.tsx | 288 ------------------ .../(user)/canvas/[id]/canvas-client-page.tsx | 10 +- .../canvas/components/asset-picker-modal.tsx | 135 +------- .../components/canvas-local-agent-panel.tsx | 2 +- .../canvas/components/canvas-toolbar.tsx | 8 +- web/src/services/api/assets.ts | 45 --- 11 files changed, 54 insertions(+), 561 deletions(-) delete mode 100644 web/src/app/(user)/asset-library/page.tsx delete mode 100644 web/src/services/api/assets.ts diff --git a/canvas-agent/src/agents.ts b/canvas-agent/src/agents.ts index b8cc414..be0e18f 100644 --- a/canvas-agent/src/agents.ts +++ b/canvas-agent/src/agents.ts @@ -53,34 +53,39 @@ export async function startCodexThread(emit: AgentEmit, cwd?: string) { export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string) { codexApp ||= await CodexAppClient.start(emit); + await loadCodexThread(emit, threadId, cwd, false); const thread = await codexApp.resumeThread(threadId, cwd); + assertThreadWorkspace(thread, cwd); codexThreadId = String(field(thread, "id") || threadId); return { thread, messages: threadMessages(thread) }; } -export async function listCodexThreads(emit: AgentEmit, options: { cwd?: string; all?: boolean; searchTerm?: string; limit?: number }) { +export async function listCodexThreads(emit: AgentEmit, options: { cwd: string; searchTerm?: string; limit?: number }) { codexApp ||= await CodexAppClient.start(emit); const result = await codexApp.listThreads({ limit: options.limit || 40, sortKey: "updated_at", sortDirection: "desc", sourceKinds: ["cli", "vscode", "appServer", "exec"], - ...(options.all ? {} : { cwd: options.cwd }), + cwd: options.cwd, ...(options.searchTerm ? { searchTerm: options.searchTerm } : {}), }); - const data = Array.isArray(field(result, "data")) ? (field(result, "data") as unknown[]).map(summarizeCodexThread) : []; + const data = Array.isArray(field(result, "data")) ? (field(result, "data") as unknown[]).map(summarizeCodexThread).filter((thread) => threadInWorkspace(thread, options.cwd)) : []; return { data, nextCursor: field(result, "nextCursor") || null, backwardsCursor: field(result, "backwardsCursor") || null }; } -export async function readCodexThread(emit: AgentEmit, threadId: string) { - codexApp ||= await CodexAppClient.start(emit); - const result = await codexApp.readThread(threadId); - const thread = field(result, "thread") || {}; +export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) { + const thread = await loadCodexThread(emit, threadId, cwd, true); return { thread: summarizeCodexThread(thread), messages: threadMessages(thread) }; } -export async function archiveCodexThread(emit: AgentEmit, threadId: string) { +export async function verifyCodexThreadWorkspace(emit: AgentEmit, threadId: string, cwd: string) { + await loadCodexThread(emit, threadId, cwd, false); +} + +export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?: string) { codexApp ||= await CodexAppClient.start(emit); + await loadCodexThread(emit, threadId, cwd, false); await codexApp.archiveThread(threadId); } @@ -93,10 +98,11 @@ export function runClaudeTurn(prompt: string, emit: AgentEmit) { async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions) { if (options.threadId) { - if (codexThreadId !== options.threadId) { - const thread = await app.resumeThread(options.threadId, options.cwd); - codexThreadId = String(field(thread, "id") || options.threadId); - } + const result = await app.readThread(options.threadId, false); + assertThreadWorkspace(field(result, "thread") || {}, options.cwd); + const thread = await app.resumeThread(options.threadId, options.cwd); + assertThreadWorkspace(thread, options.cwd); + codexThreadId = String(field(thread, "id") || options.threadId); return codexThreadId; } if (!codexThreadId) { @@ -155,8 +161,8 @@ class CodexAppClient { return this.request("thread/list", params); } - readThread(threadId: string) { - return this.request("thread/read", { threadId, includeTurns: true }); + readThread(threadId: string, includeTurns = true) { + return this.request("thread/read", { threadId, includeTurns }); } archiveThread(threadId: string) { @@ -291,6 +297,24 @@ function normalizeCodexNotification(method: string, params: Json): AgentEvent | return null; } +async function loadCodexThread(emit: AgentEmit, threadId: string, cwd: string | undefined, includeTurns: boolean) { + codexApp ||= await CodexAppClient.start(emit); + const result = await codexApp.readThread(threadId, includeTurns); + const thread = field(result, "thread") || {}; + assertThreadWorkspace(thread, cwd); + return thread; +} + +function assertThreadWorkspace(thread: unknown, cwd?: string) { + if (!cwd || threadInWorkspace(thread, cwd)) return; + throw new Error("该 Codex 会话不属于当前画布工作空间"); +} + +function threadInWorkspace(thread: unknown, cwd: string) { + const threadCwd = String(field(thread, "cwd") || ""); + return Boolean(threadCwd && path.resolve(threadCwd) === path.resolve(cwd)); +} + function normalizeItem(item: unknown) { const value = item && typeof item === "object" ? { ...(item as Json) } : {}; if (value.type === "agentMessage") value.type = "agent_message"; diff --git a/canvas-agent/src/http-server.ts b/canvas-agent/src/http-server.ts index a5df7ea..49965b2 100644 --- a/canvas-agent/src/http-server.ts +++ b/canvas-agent/src/http-server.ts @@ -2,7 +2,7 @@ import express, { type NextFunction, type Request, type Response } from "express import { DEFAULT_PORT, ensureCanvasWorkspace, loadConfig, saveConfig, updateCanvasWorkspace, type CanvasAgentConfig } from "./config.js"; import { CanvasSession } from "./canvas-session.js"; -import { archiveCodexThread, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, withAgentPrompt } from "./agents.js"; +import { archiveCodexThread, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace, withAgentPrompt } from "./agents.js"; import type { AgentAttachment } from "./types.js"; export function startHttpServer() { @@ -44,7 +44,7 @@ export function startHttpServer() { }); app.get("/agent/codex/threads", route(async (req, res) => { const workspace = ensureCanvasWorkspace(config, String(req.query.canvasId || "")); - const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, all: req.query.all === "1", searchTerm: String(req.query.searchTerm || "") }); + const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, searchTerm: String(req.query.searchTerm || "") }); res.json({ ok: true, workspace, ...result }); })); app.post("/agent/codex/threads/new", route(async (req, res) => { @@ -55,8 +55,9 @@ export function startHttpServer() { res.json({ ok: true, workspace: { ...workspace, activeThreadId }, thread: summarizeCodexThread(thread), messages: [] }); })); app.get("/agent/codex/threads/:threadId", route(async (req, res) => { + const workspace = ensureCanvasWorkspace(config, String(req.query.canvasId || "")); const threadId = routeParam(req.params.threadId); - res.json({ ok: true, ...(await readCodexThread(emit, threadId)) }); + res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) }); })); app.post("/agent/codex/threads/:threadId/resume", route(async (req, res) => { const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || "")); @@ -68,7 +69,7 @@ export function startHttpServer() { app.post("/agent/codex/threads/:threadId/delete", route(async (req, res) => { const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || "")); const threadId = routeParam(req.params.threadId); - await archiveCodexThread(emit, threadId); + await archiveCodexThread(emit, threadId, workspace.workspacePath); if (workspace.activeThreadId === threadId) updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: undefined }); res.json({ ok: true }); })); @@ -81,6 +82,7 @@ export function startHttpServer() { threadId = String((thread as Record).id || ""); updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId }); } else if (threadId !== workspace.activeThreadId) { + await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath); updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId }); } void runCodexTurn(withAgentPrompt(String(req.body?.prompt || "")), emit, attachments, { threadId, cwd: workspace.workspacePath }); diff --git a/docs/content/docs/canvas/canvas-shortcuts.mdx b/docs/content/docs/canvas/canvas-shortcuts.mdx index 81ba6f6..1286517 100644 --- a/docs/content/docs/canvas/canvas-shortcuts.mdx +++ b/docs/content/docs/canvas/canvas-shortcuts.mdx @@ -34,7 +34,7 @@ description: 画布常用鼠标与键盘操作说明 - 拖入图片文件:上传图片到画布。 - 导入图片按钮:从本地选择图片。 -- 素材库或我的素材:选择素材后插入画布。 +- 我的素材:选择素材后插入画布。 ## 撤销和重做范围 diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index 1a2cdf8..e5fcfdc 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -30,7 +30,7 @@ description: 当前项目已实现的主要功能 目前画布中有三类节点: -- 图片节点:展示上传图片、生成图片或素材库图片。 +- 图片节点:展示上传图片、生成图片或我的素材图片。 - 文本节点:保存提示词、说明文案、AI 文字回答等文本内容。 - 生成配置节点:汇总上游文本和图片,统一配置模型、比例、数量后批量生成图片或文本。 @@ -142,8 +142,6 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不 - 从提示词库和画布节点加入素材。 - 在画布中插入素材。 -“素材库”入口目前保留为空的公共素材列表,主要数据仍建议放在“我的素材”中。 - ## 配置和同步 - 当前版本不需要账号登录,也不再提供后台管理页面。 @@ -154,7 +152,6 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不 - 画布项目和“我的素材”目前只保存在浏览器本地,不会随账号同步。 - AI API Key 保存在浏览器本地,并由浏览器直接请求配置的 OpenAI 兼容接口;只适合个人或可信环境使用。 -- 公共素材库暂未接入持久化后端。 - Seedance 本地参考视频/音频更建议使用公网可访问 URL;上游是否接受前端传入的本地数据取决于具体兼容接口。 - Seedance 返回远程视频 URL 时,前端会尽量下载为本地 Blob 持久化;如果因 CORS 或网络限制无法下载,会保留远程 URL,后续是否可播放取决于上游 URL 的有效期。 - 画布更适合桌面端使用,移动端触控体验还未系统完善。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 81569ad..38daa60 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,61 +5,3 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 -- 前端已改为基本纯前端部署形态:删除 Next `/api/*` 到旧服务端的 catch-all 代理,并移除仓库内旧服务端源码、模块文件、后台管理页面、登录页和账号菜单;Docker 运行镜像只启动 Next.js,不再构建或启动旧服务端;配置与用户偏好弹窗改为“渠道 / 模型 / 生成偏好 / WebDAV”tab,支持用户本地配置多个 OpenAI 兼容渠道,每个渠道可单独填写 Base URL/API Key、手动维护或拉取模型列表,模型下拉显示为“模型名(渠道名)”;AI 生图、编辑、文本问答、音频、视频接口都由浏览器按选中的模型渠道直接请求用户配置的 OpenAI 兼容地址,第三方提示词改为 Next.js route 拉取 GitHub raw 并缓存在实例内存中,公共素材库暂为空列表;需要验证 Vercel 以 `web/` 为根部署后首页、配置导入参数、多渠道模型选择、提示词库、画布 Agent、图片/音频/视频生成、WebDAV 直连和 Next.js 转发都不再依赖旧服务端。 -- Agent 对话里的用户消息正文改为左对齐显示,长段中文提示词、编号列表和多段文本仍保持消息整体靠右但内容按正常阅读方向换行;需要验证本机 Agent 和网站 Agent 对话中长提示词不再出现排版散乱。 -- 画布右上角只保留一个 `Agent` 入口,公共顶部固定为 Agent 标题、“网站 / 本机”切换、工具确认和统一收起按钮,切换时只替换面板内部 tab 区且不改变面板宽度:网站 Agent 使用当前文本模型生成画布 ops,不依赖本地 Codex/Claude Code 或 Canvas Agent;本机 Agent 继续连接本地 Codex。两侧对话复用同一套消息、`working...`、工具卡、二级 tab 和底部输入框 UI;网站 Agent 去掉对话/生图/操作模式切换,内部 tab 改为“连接配置 / 对话 / 历史”,对话里去掉重试和插入画布按钮,历史列表改为与本机模式一致的卡片风格,请求时发送压缩后的当前画布节点、连线、选区和视口,模型返回 JSON 后复用现有画布操作协议新增、更新、删除、连接节点、调整视口、选择节点或触发生成,生图需求应通过创建提示词节点、生成配置节点并触发画布生成工具完成;需要验证单入口打开/关闭、网站/本机切换、模型配置缺失提示、网站 Agent 流式回复、工具卡展示、在线操作结果落到画布、图片生成能走画布节点工具、历史记录恢复和错误提示。 -- Agent 执行画布操作时会忽略空 ops、无 type 操作和缺少必要字段的选择/连线/视口/生成操作,避免在线模型返回不完整 JSON 时触发 `filter` 读取错误;需要验证网站 Agent 重新整理画布布局时不会再因异常 ops 崩溃。 -- 网站 Agent 的工具确认开关已接入画布操作执行:开启时模型返回 ops 后先显示确认工具调用卡片,批准后才执行,拒绝会取消;执行后会按画布前后状态判断是否真的生效,未生效时会区分没有连线可删、连线已存在、目标节点不存在、选区/视图已是目标状态等原因并写入日志;删除生成配置节点支持用 `nodeType:"config"` 删除全部配置节点,模型未写对删除目标但用户意图明确时会按当前画布 config 节点补齐删除 ids,需要验证开启确认时删除配置节点必须先确认且实际删除。 -- 网站 Agent 在工具确认前的助手回复会保留模型的计划和过程说明,只把“已完成/已连接/已整理”等完成语气改为“准备执行/准备连接/准备整理”等待确认语气;用户明确要求连线但模型未返回可执行连线时,会按当前画布非配置节点从左到右补齐 `connect_nodes`,需要验证“给他们连线”会先确认再实际连接。 -- 网站 Agent 新增“日志”tab,会记录请求摘要、模型原始回复、解析结果、归一化操作、批准/拒绝和执行前后状态,并支持一键复制日志;画布操作协议新增 `delete_connections`,用户明确要求删除/清空画布连线时会补齐 `delete_connections all:true`,需要验证“把画布里的连线都删除”会先确认再实际删除。 -- 网站 Agent 改为轻量 agent loop:模型返回 ops 后执行工具,执行成功会把最新画布快照和执行结果再次发送给模型,让模型判断任务完成或继续返回下一步 ops;最多连续 4 步,工具确认开启时每一步都需要用户批准,重复返回同一组 ops 时会停止,需要验证多步任务不会停在第一轮。 -- 本地 Agent 对话流里的工具调用卡片改为更宽的状态卡:等待确认时显示橙色提示和“批准执行 / 拒绝执行”按钮,执行成功显示绿色“已批准执行/执行完成”,拒绝或失败显示红色状态,需要验证画布操作确认、拒绝、执行失败和读取类工具结果展示。 -- 本地 Agent 接入 MVP:新增 `canvas-agent/` 本机 Node 服务,源码使用 TypeScript,HTTP 路由使用 Express,MCP 协议层使用官方 `@modelcontextprotocol/sdk`,工具入参使用 `zod`;支持仓库内 `cd canvas-agent && npm install && npm run build && node dist/index.js` 或发布后 `npx -y @basketikun/canvas-agent` 启动并输出本机地址和 token;新增 GitHub Actions 自动发布 `@basketikun/canvas-agent`,发布判断只读取 `canvas-agent/package.json` 里的包版本,不跟根目录 `VERSION` 绑定;根 README 已增加本地 Canvas Agent 入口;Canvas Agent 默认监听 `127.0.0.1`,使用 token 连接并记录允许的网页 Origin;画布右上角新增 `Agent` 面板,默认只展示 Codex,顶部以连接、对话、历史、日志 tab 切换,进入时默认停在连接 tab,点击后直接高亮对应 tab,连接配置页展示 Codex 前置条件、npm 安装、启动和网页连接步骤,地址或 token 为空、地址格式错误、连接失败时会在连接页保留失败状态和原因,可拖拽调整宽度,并与右侧画布助手一致支持打开/收起宽度动画;Agent 面板首次打开后收起不卸载,网页侧对话、日志、连接配置、等待状态和待确认工具调用会放在 Zustand 内存 store 中保留,断开连接会清空当前对话和历史列表;Canvas Agent 会在本机配置中按画布记录 Codex 工作空间和 active thread,默认工作目录为 `~/.infinite-canvas/codex-workspaces/<画布ID>`,前端历史视图直接放在 Agent 面板内,只展示当前画布默认工作目录,可刷新该目录下 Codex thread 列表、新建对话、恢复旧对话、删除记录,并通过 `thread/read includeTurns` 回填消息,删除记录前会二次确认;以对话气泡展示用户消息、Codex 文本、工具调用和错误,前端按 Codex `type/item/usage` 事件格式读取 `agent_message`、工具结果和 `turn.completed` 状态,收到真实 `item.updated` 时用同一个消息 ID 流式更新 assistant 文本,未收到 `item.updated` 时不做伪流式,原始事件 JSON 收进“运行日志”tab,可切换排查日志和原始 JSON,排查日志会合并本地 Agent 地址、连接状态、等待状态、pending tool、发送/接收关键节点,支持复制排查日志和最近错误,日志只通过顶部日志 tab 查看;连接成功、连接失败和本轮结束不再写入对话区,连接成功时会清理之前残留的连接错误消息,状态留在面板头部和连接 tab,运行日志不再记录 item.updated 和 reasoning 噪声事件;对话区去掉单独背景色并融入面板背景,消息内容裸文本展示,不再显示 `Codex`/`You` 名称,Codex 消息保留透明背景 OpenAI 图标,用户消息保留透明背景用户头像或默认用户图标,回复前显示循环打字机式 `working...` 等待状态;工具调用和授权状态改为对话流里的轻量小卡片,`canvas_apply_ops` 在执行前通过内联确认卡片让用户取消或执行,工具卡片会合并执行结果并可展开查看输入、输出和授权详情;底部输入区改为一体式对话框,图片上传入口和发送按钮都在框内,支持选择或粘贴图片、显示附件体积、发送前限制约 30MB,并在用户消息中显示缩略图,图片会通过本机 Canvas Agent 临时写入文件后作为 app-server `localImage` 输入传给 Codex,Canvas Agent 单次请求体限制为 30MB;Canvas Agent 使用官方 `@openai/codex` CLI 的 `codex app-server --stdio` 事件流,按当前画布 active thread 发送消息,未绑定时新建 thread,恢复旧对话后继续复用该 thread,并在 app-server 配置中注入 `infinite-canvas` MCP 和 `default_tools_approval_mode = "approve"`,侧边栏无需用户预先全局 `codex mcp add` 也可调用画布工具;Claude Code Adapter 代码暂保留但前端入口先隐藏;画布可响应 `canvas_apply_ops` 工具调用来新增/更新/删除/连接节点、选中节点和调整视口,执行后可撤销最近一次工具操作;Canvas Agent 内置 MCP stdio 模式并向 Codex 返回画布工具使用说明,Codex 终端仍可通过 `codex mcp add infinite-canvas -- node /path/to/infinite-canvas/canvas-agent/dist/index.js mcp` 或发布后的 `codex mcp add infinite-canvas -- npx -y @basketikun/canvas-agent mcp` 调用 `canvas_get_state`、`canvas_get_selection`、`canvas_export_snapshot`、`canvas_apply_ops`、`canvas_create_node`、`canvas_create_text_node`、`canvas_create_text_nodes`、`canvas_create_config_node`、`canvas_create_image_prompt_flow`、`canvas_create_generation_flow`、`canvas_generate_text`、`canvas_generate_image`、`canvas_generate_video`、`canvas_generate_audio`、`canvas_run_generation`、节点更新、移动、缩放、删除、连线、选择和视口工具,Claude Code 可通过 `claude mcp add --scope user --transport stdio infinite-canvas -- npx -y @basketikun/canvas-agent mcp` 接入同一组工具;需要验证浏览器连接、侧边栏自动注入 MCP 后的 Codex 工具调用、终端 Codex MCP 工具调用、工具确认/取消/撤销、每画布工作空间隔离、历史列表读取、历史消息回填、历史记录删除确认、Codex thread 复用、运行日志 tab 复制、图片附件预览和断开重连提示。 -- 视频创作台创建视频任务成功后会立即写入左侧生成记录,状态显示为“生成中”;刷新页面后会读取本地任务 ID 继续轮询,任务成功或失败后更新同一条记录,需要验证 OpenAI 视频接口和 Seedance 任务接口的刷新恢复。 -- 修复画布文字节点编辑时双击进入编辑、全选文本出现重影和错位的问题;文字节点内容编辑框改为使用原生 textarea 文本渲染,仍保留 `@` 资源候选插入,需要验证编辑态选区、展示态文字换行和右上角“生图”按钮避让都正常。 -- 配置弹窗新增 WebDAV 同步配置,可填写 WebDAV 地址、远程目录、用户名和密码/应用密码,并选择“前端直连”或“Next.js 转发”;远端会按 `canvas/`、`assets/`、`image-workbench/`、`video-workbench/` 四个业务目录分别写入 `manifest.json` 清单和 `files/` 媒体目录,会合并画布项目、我的素材、生图/视频生成记录和引用到的本地媒体文件,四个业务分区会并发同步,并在同步中显示读取远端、检查媒体、上传新增媒体、上传清单等阶段和文件计数进度条;WebDAV 请求会做超时提示,目录已存在但 `MKCOL` 返回 `423 Locked` 时会复查目录存在后继续同步,需要在支持 CORS 的 NAS/WebDAV 服务和不支持 CORS 的 Koofr 转发模式下验证首次上传、第二台设备拉取合并、再次同步和认证失败提示。 -- 图片节点悬浮工具栏新增“切图”入口;点击后可输入行数和列数,预览切分网格,并在确认后把原图切成对应数量的图片子节点,按原图网格排列到画布右侧且自动与原图连线。 -- 画布生成配置节点的“生成配置/组装提示词”改为在节点下方打开独立输入浮层;浮层支持输入 `@` 从已连接图片、文本、视频、音频中选择引用,引用会以图片缩略图或文本标记展示,图片引用可放大预览;生成时再按当前引用解析为实际素材编号,不再提供或读取输入排序。 -- 图片节点悬浮工具栏新增“局部编辑”入口,打开后可在图片上用画笔/擦除工具绘制遮罩区域、填写局部修改要求,并通过图片编辑接口携带同尺寸 PNG mask 生成新图片节点;结果节点会放在原图右侧并自动连线,原图保持不变。 -- 图片节点悬浮工具栏新增“反推提示词”入口;点击后会在原图右侧创建包含反推预设提示词的文本节点,并继续创建一个已切到“文本”模式的生成配置节点,图片节点和文本节点都会自动连到该配置节点,配置节点会预填包含原图和文本节点引用的组装提示词并自动打开,用户可自行点击配置节点开始生成。 -- 图片节点悬浮工具栏新增“复制提示词”、“放大”和“超分”入口,并在末尾增加 `...` 更多按钮;点击“复制提示词”会复制生成该图片的提示词,图片没有提示词时会提示暂无可复制内容;点击 `...` 后打开 Ant Design 风格的“自定义工具栏”弹窗,可在图片节点占位上预览悬浮工具栏,信息、删除、存素材、下载、编辑和图片工具都在同一个快捷工具列表中勾选配置,并可切换是否显示按钮文字,保存后写入本地配置;`...` 配置入口固定显示,预览工具栏下方提供常驻横向滚动控制条。“放大”可在弹窗中选择 1K/2K/4K 目标像素和高清插值、双线性、最近邻算法,按原图比例生成新图片节点,已达到的目标像素会禁用并提示无需放大,最高不超过 4K;“超分”当前只打开暂未实现弹窗。 -- Canvas 资源节点会按当前生成上下文显示 `图片1`、`视频1`、`音频1`、`文本1` 角标;文本节点内容框和节点底部 prompt 面板输入 `@` 时应弹出已连接资源选择器,点击缩略图或文字可插入纯文本编号,并以蓝色 token 视觉高亮。 -- 文本节点连接到生成配置节点时,`@` 候选和实际生成输入应读取该生成配置节点的上游参考资源;生成配置输入统计区域应可拖动整个配置节点,预览按钮和设置控件仍保持可点击。 -- 配置弹窗改为“配置与用户偏好”并放大为可滚动弹窗;前台直连支持配置生图、视频、文本、音频四类可选模型列表和默认模型,新建画布生图和配置节点会读取“画布默认生图张数”,需要验证模型列表拉取、手动模型配置和默认模型都能正确显示。 -- 画布音频节点底部生成面板改为音频提示词、音频模型下拉和 OpenAI Speech 参数设置,支持 `voice`、`response_format`、`speed`、`instructions` 并通过 `/audio/speech` 生成音频节点;需要验证前台直连生成、重试、下载和刷新恢复。 -- 画布左上角菜单和右上角状态栏新增“文档”入口,会使用 `NEXT_PUBLIC_DOC_URL` 配置的地址并在新标签打开文档站;需要验证登录和未登录状态下顶部入口都可见。 -- 文档站搜索改为中英文混合 tokenizer,中文正文、标题和短语会按中文词、单字、二元和三元片段建立索引;需要验证 `/api/search` 和搜索弹窗能命中文档中的中文关键词。 -- 文档站改为 Next.js standalone server 输出,新增 `docs/Dockerfile`、`docs/docker-compose.yml` 和 `docs/docker-compose.local.yml` 独立运行入口;需要验证文档页、搜索接口和 LLM 文本接口在文档站容器中可访问。 -- 画布连线支持右键打开删除菜单;拖拽连线到目标卡片内部、连接点附近或卡片边缘外扩范围内会自动吸附并连接,拖到已有但不可连接的节点附近不会再误弹创建节点菜单。 -- 外部软件可通过 URL 查询参数 `baseUrl`/`baseurl` 和 `apiKey`/`apikey` 跳转到前端;读取后会从地址栏移除这些参数,直接写入浏览器本地直连配置并打开配置弹窗。 -- 生图工作台和画布生图会把参考图按当前顺序显示为 `图片1`、`图片2` 等编号,并在图生图请求的实际提示词中注入编号说明;需要验证 `/image` 参考图排序、画布配置节点输入顺序和画布助手参考图编号一致。 -- GPT Image 生图请求会在前端把 `9:16`、`16:9` 等比例转换成合法 `WIDTHxHEIGHT` 尺寸,并在非法尺寸时直接显示中文错误,避免上游返回 `invalid_value Invalid size`。 -- Docker 部署改为只启动 Next.js,不再构建或运行旧 API;需要验证容器内首页、提示词 route 和 WebDAV 代理可用。 -- Seedance 参考视频被火山判定包含真人或隐私信息时,前端错误摘要会提示改用不含真人的视频、官方允许的模型产物或已授权的 `asset://` 素材。 -- Seedance 参考素材失败原因排查:`/video` 和画布视频生成会按 `图片1/视频1/音频1` 自动编号参考素材,并在实际请求提示词中注入编号说明;参考视频会在请求前校验大小、时长、宽高、宽高比和像素总量。 -- 画布项目导出改为下载 `.zip` 压缩包,包内包含 `projects.json` 和当前画布引用到的本地图片、视频文件,避免只导出 JSON 时丢失媒体内容。 -- 画布库支持多选后一键导出多个画布项目,导出的压缩包可一次恢复多个项目。 -- 画布项目导入改为读取新版 `.zip` 压缩包,会先按 `projects.json` 中的文件映射恢复图片、视频到本地存储,再插入画布项目,导入成功后仍停留在画布库。 -- 修复删除画布图片节点或清空画布后撤销时,节点信息恢复但本地图片数据已被清理导致图片丢失的问题。 -- “我的素材”类型筛选区右侧新增文本样式的导出素材和导入素材入口,可将全部素材导出为包含 `assets.json` 与图片、视频文件的压缩包,并从压缩包恢复素材。 -- 纯前端模式下,画布右上角不再显示用户头像菜单、用户名称、算力点余额、退出登录入口和登录入口;快捷键入口仍可直接打开。 -- 生图工作台的图片参数区复用画布里的紧凑版图像设置面板,尺寸、质量、生成张数的交互保持一致;工作台仍保留独立的模型选择。 -- 生图工作台新增生成记录配置持久化:每次生成会保存提示词、参考图、模型、质量、尺寸和张数,结果图写入本地图片存储后记录只保存 `storageKey`;点击历史记录会回填本次生成配置并预览结果。 -- 生图工作台生成记录会过滤空缩略图地址,避免历史记录卡片渲染 `src=""` 图片。 -- 图像设置面板新增默认开启的“16倍数对齐”尺寸 toggle;开启时手动输入宽高并失焦、回车或点击浮层外关闭后,会把非 16 倍数的宽高向上补齐。 -- 视频设置抽成画布和视频创作台共用的紧凑面板,清晰度、尺寸、秒数按固定网格选择并支持手动输入;尺寸选择 `auto` 时请求不传 `size`。 -- 修复画布和生图工作台选择图片尺寸后,请求图片生成/编辑接口未携带 `size` 参数的问题;`auto` 不传,其余比例或像素尺寸会随请求发送。 -- 修复生图工作台和画布生图请求中 `quality` 参数可能传入上游不支持值导致 400 的问题;请求前会归一化质量枚举,`auto` 或异常值不再发送给上游。 -- 视频生成请求参数对齐 `grok-imagine-video` 接口:使用 `resolution_name`、`preset=normal`、`input_reference[]`,支持清晰度、尺寸、秒数快捷选择和手动输入,并支持最多 7 张参考图。 -- 画布视频设置浮层改为挂载到页面根层级并使用自建浮层交互,避免被节点悬浮工具栏遮挡或点击面板内容时关闭。 -- 画布生成配置节点的生图参数改为复用图像设置浮层,支持在同一个入口里调整质量、尺寸和生成张数。 -- 视频清晰度输入框改为只输入数字,提交请求时再拼接 `p` 单位。 -- 视频生成前端会识别接口 `{ code, msg }` 错误响应,创建失败不再继续轮询 `undefined`。 -- 新增 `/video` 视频创作台页面,参考生图工作台布局,支持提示词、参考图、视频参数、生成结果、保存素材、下载和本地生成记录;清晰度和秒数均支持常用值选择与手动输入,生成记录只保存媒体 `storageKey` 并可回填本次提示词、参考图和参数。 -- 视频创作台生成前会把模型、尺寸、秒数、清晰度归一化为视频接口支持的参数,并展示接口返回的错误信息,避免页面侧残留的生图参数影响视频请求。 -- 火山方舟 Agent Plan / Seedance 2.0 视频生成需要在真实账号下验证:`/contents/generations/tasks` 创建任务、轮询状态、`content.video_url` 回填画布,以及 401/403/429/超时错误提示。 -- `/video` 和画布视频设置已按 Seedance 2.0 增加分辨率、比例、4-15 秒/智能时长、生成声音和水印参数;需要在真实浏览器里验证参数回填、生成记录和画布节点配置都能保持一致。 -- `/video` 支持最多 9 张参考图、3 个参考视频、3 个参考音频;需要验证格式、大小、音频时长提示和生成请求中的 `reference_image`、`reference_video`、`reference_audio` 组装。 -- 画布新增音频节点,支持上传、拖入、播放、移动、缩放、删除,并可作为上游参考音频参与 Seedance 视频生成;需要验证刷新后本地音频 URL 能恢复。 -- Seedance 本地参考视频和参考音频改为由前端读取后传给兼容接口;需要在真实上游验证公网 URL、本地视频、本地音频三类参考素材的可用性和错误提示。 -- Seedance 返回远程视频 URL 但浏览器无法下载为 Blob 时,需要验证视频节点刷新后仍保留远程 URL,并确认上游 URL 有效期限制。 diff --git a/web/src/app/(user)/asset-library/page.tsx b/web/src/app/(user)/asset-library/page.tsx deleted file mode 100644 index 426ad59..0000000 --- a/web/src/app/(user)/asset-library/page.tsx +++ /dev/null @@ -1,288 +0,0 @@ -"use client"; - -import { Copy, FolderPlus, Search } from "lucide-react"; -import { useEffect, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { App, Button, Card, Drawer, Empty, Image, Input, Pagination, Spin, Tag, Typography } from "antd"; -import axios from "axios"; - -import { useCopyText } from "@/hooks/use-copy-text"; -import { cn } from "@/lib/utils"; -import { useAssetStore } from "@/stores/use-asset-store"; -import { fetchAssetLibrary, type AssetLibraryItem } from "@/services/api/assets"; -import { uploadImage } from "@/services/image-storage"; - -const PAGE_SIZE = 12; - -export default function AssetLibraryPage() { - const { message } = App.useApp(); - const copyText = useCopyText(); - const [keyword, setKeyword] = useState(""); - const [selectedType, setSelectedType] = useState(""); - const [selectedTags, setSelectedTags] = useState([]); - const [page, setPage] = useState(1); - const [selectedAsset, setSelectedAsset] = useState(null); - const addAsset = useAssetStore((state) => state.addAsset); - - const query = useQuery({ - queryKey: ["asset-library", keyword, selectedType, selectedTags, page], - queryFn: () => fetchAssetLibrary({ keyword, type: selectedType, tag: selectedTags, page, pageSize: PAGE_SIZE }), - retry: false, - }); - - useEffect(() => { - if (query.isError) { - message.error(query.error instanceof Error ? query.error.message : "获取素材库失败"); - } - }, [message, query.error, query.isError]); - - const isReady = query.isFetched || query.isError; - const items = query.data?.items || []; - const availableTags = query.data?.tags || []; - const total = query.data?.total || 0; - - const toggleTag = (tag: string) => { - setSelectedTags((items) => (items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag])); - }; - - const saveToMyAssets = async (asset: AssetLibraryItem) => { - try { - if (asset.type === "image") { - const dataUrl = await remoteImageToDataUrl(asset.url); - const image = await uploadImage(dataUrl); - addAsset({ - kind: "image", - title: asset.title, - coverUrl: asset.coverUrl, - tags: asset.tags, - source: asset.category, - note: asset.description, - data: { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType }, - metadata: { source: "asset-library", assetId: asset.id }, - }); - } else { - addAsset({ - kind: "text", - title: asset.title, - coverUrl: asset.coverUrl, - tags: asset.tags, - source: asset.category, - note: asset.description, - data: { content: asset.content }, - metadata: { source: "asset-library", assetId: asset.id }, - }); - } - message.success("已加入我的素材"); - } catch { - message.error("加入失败"); - } - }; - - if (!isReady) { - return ( -
- -
- ); - } - - return ( -
-
-
-
-

素材库

-

挑选团队素材,加入我的素材后继续编辑和使用。

-
-
- } - value={keyword} - placeholder="按标题查询" - onChange={(event) => { - setPage(1); - setKeyword(event.target.value); - }} - /> -
-
-
-
类型
-
- {[ - { label: "全部", value: "" }, - { label: "文本", value: "text" }, - { label: "图片", value: "image" }, - ].map((item) => ( - { - setPage(1); - setSelectedType(item.value); - }} - > - {item.label} - - ))} -
-
-
-
标签
-
- { - setPage(1); - setSelectedTags([]); - }} - > - 全部 - - {availableTags.map((tag) => ( - { - setPage(1); - toggleTag(tag); - }} - > - {tag} - - ))} -
-
-
-
- -
-
- {items.map((asset) => ( - setSelectedAsset(asset)} onAdd={() => void saveToMyAssets(asset)} /> - ))} -
- - {!items.length ? : null} - -
- setPage(nextPage)} /> -
-
-
- - setSelectedAsset(null)}> - {selectedAsset ? ( -
- {selectedAsset.coverUrl ? ( - {selectedAsset.title} - ) : ( -
{selectedAsset.content || "暂无封面"}
- )} -
- - {selectedAsset.title} - -
- {selectedAsset.type === "image" ? "图片" : "文本"} - {selectedAsset.tags.map((tag) => ( - {tag} - ))} -
-
-
- - 内容 - - {selectedAsset.type === "text" ? {selectedAsset.content} : {selectedAsset.url}} -
- {selectedAsset.description ? {selectedAsset.description} : null} -
- {selectedAsset.type === "text" ? ( - - ) : null} - {selectedAsset.type === "image" ? ( - - ) : null} - -
-
- ) : null} -
-
- ); -} - -function LibraryCard({ asset, onOpen, onAdd }: { asset: AssetLibraryItem; onOpen: () => void; onAdd: () => void }) { - const cover = asset.coverUrl; - return ( - - {cover ? ( - {asset.title} - ) : ( -
{asset.content || "暂无封面"}
- )} - - } - > - -
- - -
-
- ); -} - -async function remoteImageToDataUrl(url: string) { - const response = await axios.get(url, { responseType: "blob" }); - const blob = response.data as Blob; - return await blobToDataUrl(blob); -} - -function blobToDataUrl(blob: Blob) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(String(reader.result || "")); - reader.onerror = () => reject(new Error("读取图片失败")); - reader.readAsDataURL(blob); - }); -} diff --git a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx index 4891816..3e748e4 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -40,7 +40,7 @@ import { Minimap } from "../components/canvas-mini-map"; import { CanvasNode } from "../components/canvas-node"; import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "../components/canvas-node-prompt-panel"; import { CanvasToolbar } from "../components/canvas-toolbar"; -import { AssetPickerModal, type AssetPickerTab, type InsertAssetPayload } from "../components/asset-picker-modal"; +import { AssetPickerModal, type InsertAssetPayload } from "../components/asset-picker-modal"; import { CanvasZoomControls } from "../components/canvas-zoom-controls"; import { useCanvasStore } from "../stores/use-canvas-store"; import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "../utils/canvas-agent-ops"; @@ -275,7 +275,6 @@ function InfiniteCanvasPage() { const [showImageInfo, setShowImageInfo] = useState(false); const [clearConfirmOpen, setClearConfirmOpen] = useState(false); const [assetPickerOpen, setAssetPickerOpen] = useState(false); - const [assetPickerTab, setAssetPickerTab] = useState("my-assets"); const [projectLoaded, setProjectLoaded] = useState(false); const [toolbarNodeId, setToolbarNodeId] = useState(null); const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false); @@ -2567,12 +2566,7 @@ function InfiniteCanvasPage() { onDeselect={deselectCanvas} onBackgroundModeChange={setBackgroundMode} onShowImageInfoChange={setShowImageInfo} - onOpenAssetLibrary={() => { - setAssetPickerTab("library"); - setAssetPickerOpen(true); - }} onOpenMyAssets={() => { - setAssetPickerTab("my-assets"); setAssetPickerOpen(true); }} /> @@ -2654,7 +2648,7 @@ function InfiniteCanvasPage() {

这会删除当前画布上的所有节点和连线。

- setAssetPickerOpen(false)} /> + setAssetPickerOpen(false)} /> {assistantMounted ? ( void; onClose: () => void; }; -export function AssetPickerModal({ open, defaultTab = "my-assets", onInsert, onClose }: Props) { - const [activeTab, setActiveTab] = useState(defaultTab); - - useEffect(() => { - if (open) setActiveTab(defaultTab); - }, [open, defaultTab]); - +export function AssetPickerModal({ open, onInsert, onClose }: Props) { return ( - setActiveTab(key as AssetPickerTab)} - items={[ - { key: "my-assets", label: "我的素材", children: }, - { key: "library", label: "素材库", children: }, - ]} - /> + ); } @@ -51,104 +32,12 @@ const kindOptions = [ { label: "视频", value: "video" }, ]; -function LibraryTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) { - const { message } = App.useApp(); - const [keyword, setKeyword] = useState(""); - const [kindFilter, setKindFilter] = useState(""); - const [page, setPage] = useState(1); - const [inserting, setInserting] = useState(null); - - const query = useQuery({ - queryKey: ["asset-picker-library", keyword, kindFilter, page], - queryFn: () => fetchAssetLibrary({ keyword, type: kindFilter, page, pageSize: PAGE_SIZE }), - retry: false, - }); - - const items = query.data?.items || []; - const total = query.data?.total || 0; - - const handleInsert = async (asset: AssetLibraryItem) => { - try { - setInserting(asset.id); - if (asset.type === "text") { - onInsert({ kind: "text", content: asset.content, title: asset.title }); - } else { - const dataUrl = await remoteImageToDataUrl(asset.url); - onInsert({ kind: "image", dataUrl, title: asset.title }); - } - } catch { - message.error("插入失败"); - } finally { - setInserting(null); - } - }; - - return ( -
-
- } - placeholder="搜索素材" - value={keyword} - allowClear - onChange={(e) => { - setPage(1); - setKeyword(e.target.value); - }} - /> -
- {[ - { label: "全部", value: "" }, - { label: "文本", value: "text" }, - { label: "图片", value: "image" }, - ].map((opt) => ( - { - setPage(1); - setKindFilter(opt.value); - }} - > - {opt.label} - - ))} -
-
- - {query.isLoading ? ( -
- -
- ) : items.length ? ( -
- {items.map((asset) => ( - void handleInsert(asset)} /> - ))} -
- ) : ( - - )} - - {total > PAGE_SIZE && ( -
- -
- )} -
- ); -} - -function PickerCard({ title, kind, cover, loading, onClick }: { title: string; kind: string; cover: string; loading?: boolean; onClick: () => void }) { +function PickerCard({ title, kind, cover, onClick }: { title: string; kind: string; cover: string; onClick: () => void }) { return ( ); } -async function remoteImageToDataUrl(url: string) { - const response = await axios.get(url, { responseType: "blob" }); - const blob = response.data as Blob; - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(String(reader.result || "")); - reader.onerror = () => reject(new Error("读取图片失败")); - reader.readAsDataURL(blob); - }); -} - function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) { const assets = useAssetStore((state) => state.assets); const [keyword, setKeyword] = useState(""); diff --git a/web/src/app/(user)/canvas/components/canvas-local-agent-panel.tsx b/web/src/app/(user)/canvas/components/canvas-local-agent-panel.tsx index d837f7b..2bd39a8 100644 --- a/web/src/app/(user)/canvas/components/canvas-local-agent-panel.tsx +++ b/web/src/app/(user)/canvas/components/canvas-local-agent-panel.tsx @@ -69,7 +69,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde }); const nextThreadId = data.workspace?.activeThreadId || current.activeThreadId; if (nextThreadId && !current.messages.length) { - const thread = await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(nextThreadId)}`); + const thread = await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(nextThreadId)}?canvasId=${encodeURIComponent(projectId)}`); setAgentState({ messages: normalizeHistoryMessages(thread.messages || []) }); } } catch (error) { diff --git a/web/src/app/(user)/canvas/components/canvas-toolbar.tsx b/web/src/app/(user)/canvas/components/canvas-toolbar.tsx index e7eca92..3a2eeec 100644 --- a/web/src/app/(user)/canvas/components/canvas-toolbar.tsx +++ b/web/src/app/(user)/canvas/components/canvas-toolbar.tsx @@ -1,7 +1,7 @@ import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react"; import { useRef, useState } from "react"; import { Button, Segmented, Switch } from "antd"; -import { CircleDot, Eraser, FolderOpen, Grid2x2, Hand, Image as ImageIcon, Info, Library, Moon, Music2, Palette, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react"; +import { CircleDot, Eraser, FolderOpen, Grid2x2, Hand, Image as ImageIcon, Info, Moon, Music2, Palette, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react"; import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme"; import { useThemeStore } from "@/stores/use-theme-store"; @@ -26,7 +26,6 @@ export function CanvasToolbar({ onDeselect, onBackgroundModeChange, onShowImageInfoChange, - onOpenAssetLibrary, onOpenMyAssets, }: { selectedCount: number; @@ -47,7 +46,6 @@ export function CanvasToolbar({ onDeselect: () => void; onBackgroundModeChange: (mode: CanvasBackgroundMode) => void; onShowImageInfoChange: (show: boolean) => void; - onOpenAssetLibrary: () => void; onOpenMyAssets: () => void; }) { const wrapRef = useRef(null); @@ -96,9 +94,6 @@ export function CanvasToolbar({ - - - @@ -287,7 +282,6 @@ function toolLabel(id: string) { if (id === "tool-audio") return "音频"; if (id === "tool-config") return "生成配置"; if (id === "tool-upload") return "上传素材"; - if (id === "tool-library") return "素材库"; if (id === "tool-assets") return "我的素材"; if (id === "tool-style") return "画布外观"; if (id === "tool-delete") return "删除选中"; diff --git a/web/src/services/api/assets.ts b/web/src/services/api/assets.ts deleted file mode 100644 index 236479f..0000000 --- a/web/src/services/api/assets.ts +++ /dev/null @@ -1,45 +0,0 @@ -export type AssetLibraryItem = { - id: string; - title: string; - type: "text" | "image" | "video"; - coverUrl: string; - tags: string[]; - category: string; - description: string; - content: string; - url: string; - createdAt: string; - updatedAt: string; -}; - -export type AssetLibraryResponse = { - items: AssetLibraryItem[]; - tags: string[]; - total: number; -}; - -export type AssetLibraryQuery = { - keyword?: string; - type?: string; - tag?: string[]; - page?: number; - pageSize?: number; -}; - -export async function fetchAssetLibrary(query: AssetLibraryQuery = {}) { - const items: AssetLibraryItem[] = []; - const filtered = items.filter((item) => { - const keyword = query.keyword?.trim().toLowerCase(); - if (query.type && item.type !== query.type) return false; - if (query.tag?.length && !query.tag.some((tag) => item.tags.includes(tag))) return false; - if (!keyword) return true; - return [item.title, item.description, item.content, item.url, item.category, ...item.tags].join(" ").toLowerCase().includes(keyword); - }); - const page = Math.max(1, Number(query.page) || 1); - const pageSize = Math.max(1, Number(query.pageSize) || filtered.length || 1); - return { - items: filtered.slice((page - 1) * pageSize, page * pageSize), - tags: Array.from(new Set(filtered.flatMap((item) => item.tags).filter(Boolean))), - total: filtered.length, - }; -}