diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index ff4d32a99..d5e561e61 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -198,9 +198,11 @@ import { normalizeCanvasImageTaskQueueConfig, } from "@/lib/canvas/image-task-config"; import { + type CanvasSkillCategory, type CanvasSkillTag, DEFAULT_CANVAS_SKILL_TAGS, DEFAULT_CANVAS_SKILL_TAGS_TITLE, + normalizeCanvasSkillCategories, normalizeCanvasSkillTags, } from "@/lib/canvas-skill-tags"; import { @@ -418,6 +420,13 @@ function createEmptyCanvasSkillTag(): CanvasSkillTag { }; } +function createEmptyCanvasSkillCategory(): CanvasSkillCategory { + return { + id: `category-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + name: "新分类", + }; +} + function createEmptyEnhanceProPreset(): EnhanceProPreset { const timestamp = Date.now(); return { @@ -3404,6 +3413,9 @@ export function AdminSettingsClient() { DEFAULT_CANVAS_SKILL_TAGS_TITLE ); const [canvasSkillTagsEnabled, setCanvasSkillTagsEnabled] = useState(true); + const [canvasSkillCategories, setCanvasSkillCategories] = useState< + CanvasSkillCategory[] + >([]); const [canvasSkillTags, setCanvasSkillTags] = useState( DEFAULT_CANVAS_SKILL_TAGS ); @@ -3421,6 +3433,7 @@ export function AdminSettingsClient() { const { data: canvasSkillTagsData, mutate: mutateCanvasSkillTags } = useSWR<{ enabled?: boolean; title?: string; + categories?: CanvasSkillCategory[]; tags?: CanvasSkillTag[]; }>("/api/canvas-skill-tags", fetcher); @@ -3647,6 +3660,9 @@ export function AdminSettingsClient() { String(canvasSkillTagsData.title || "").trim() || DEFAULT_CANVAS_SKILL_TAGS_TITLE ); + setCanvasSkillCategories( + normalizeCanvasSkillCategories(canvasSkillTagsData.categories) + ); setCanvasSkillTags(normalizeCanvasSkillTags(canvasSkillTagsData.tags)); } }, [canvasSkillTagsData]); @@ -3683,7 +3699,13 @@ export function AdminSettingsClient() { const handleCanvasSkillTagChange = ( index: number, - field: "name" | "prompt" | "placeholder" | "iconName" | "iconUrl", + field: + | "name" + | "prompt" + | "placeholder" + | "iconName" + | "iconUrl" + | "categoryId", value: string ) => { setCanvasSkillTags((prev) => @@ -3936,6 +3958,36 @@ export function AdminSettingsClient() { setCanvasSkillTags((prev) => [...prev, createEmptyCanvasSkillTag()]); }; + const handleAddCanvasSkillCategory = () => { + setCanvasSkillCategories((prev) => [ + ...prev, + createEmptyCanvasSkillCategory(), + ]); + }; + + const handleCanvasSkillCategoryNameChange = (index: number, name: string) => { + setCanvasSkillCategories((prev) => + prev.map((category, categoryIndex) => + categoryIndex === index ? { ...category, name } : category + ) + ); + }; + + const handleRemoveCanvasSkillCategory = (index: number) => { + const categoryId = canvasSkillCategories[index]?.id; + setCanvasSkillCategories((prev) => + prev.filter((_, categoryIndex) => categoryIndex !== index) + ); + if (!categoryId) return; + setCanvasSkillTags((prev) => + prev.map((tag) => { + if (tag.categoryId !== categoryId) return tag; + const { categoryId: _categoryId, ...rest } = tag; + return rest; + }) + ); + }; + const handleRemoveCanvasSkillTag = (index: number) => { setCanvasSkillTags((prev) => prev.filter((_, itemIndex) => itemIndex !== index) @@ -3944,6 +3996,7 @@ export function AdminSettingsClient() { const handleSaveCanvasSkillTags = async () => { try { + const categories = normalizeCanvasSkillCategories(canvasSkillCategories); const payload = normalizeCanvasSkillTags(canvasSkillTags); const res = await fetch("/api/canvas-skill-tags", { method: "PUT", @@ -3951,6 +4004,7 @@ export function AdminSettingsClient() { body: JSON.stringify({ enabled: canvasSkillTagsEnabled, title: canvasSkillTagsTitle, + categories, tags: payload, }), }); @@ -3962,6 +4016,9 @@ export function AdminSettingsClient() { String(data?.title || canvasSkillTagsTitle || "").trim() || DEFAULT_CANVAS_SKILL_TAGS_TITLE ); + setCanvasSkillCategories( + normalizeCanvasSkillCategories(data?.categories ?? categories) + ); setCanvasSkillTags(normalizeCanvasSkillTags(data?.tags ?? payload)); toast.success("快捷标签已更新"); mutateCanvasSkillTags(); @@ -14782,6 +14839,61 @@ export function AdminSettingsClient() { value={canvasSkillTagsTitle} /> +
+
+
+ +

+ 分类会显示在 Claw 快捷技能区,用户可按分类筛选技能。 +

+
+ +
+ {canvasSkillCategories.length > 0 ? ( +
+ {canvasSkillCategories.map((category, categoryIndex) => ( +
+ + handleCanvasSkillCategoryNameChange( + categoryIndex, + event.target.value + ) + } + placeholder={`分类名称 ${categoryIndex + 1}`} + value={category.name} + /> + +
+ ))} +
+ ) : ( +

+ 暂未配置分类,所有技能会显示在“全部”中。 +

+ )} +
{canvasSkillTags.map((tag, index) => { const TagIcon = getCanvasSkillIconComponent(tag.iconName); return ( @@ -14789,7 +14901,7 @@ export function AdminSettingsClient() { className="space-y-2 rounded-lg border p-3" key={tag.id || index} > -
+
handleCanvasSkillTagChange( @@ -14801,7 +14913,32 @@ export function AdminSettingsClient() { placeholder={`标签名称 ${index + 1}`} value={tag.name} /> + + {canvasSkillCategories.map((category) => ( + + ))} + {hasUncategorizedCanvasSkillTags ? ( + + ) : null} +
+ ) : null} {canvasSkillTags.length > 4 ? ( -
+
4 || hasCanvasSkillCategoryFilters + ? "mt-4" + : "mt-6", isCanvasSkillTagSearching || areCanvasSkillTagsExpanded ? "max-h-none overflow-visible" : "max-h-[76px] overflow-hidden" @@ -15037,6 +15152,12 @@ export function CanvasChatPanel({ 未找到匹配的快捷技能

) : null} + {!isCanvasSkillTagSearching && + filteredCanvasSkillTags.length === 0 ? ( +

+ 该分类暂未配置快捷技能 +

+ ) : null}
{shouldShowCanvasSkillTagsToggle ? (
)} {visibleVideoUrls.length > 0 && ( -
0 ? "mt-3" : "mt-0")}> +
0 || visibleAudioUrls.length > 0 ? "mt-3" : "mt-0")}> {visibleVideoUrls.map((url, idx) => (
@@ -15617,6 +15748,42 @@ export function CanvasChatPanel({ ))}
)} + {visibleAudioUrls.length > 0 && ( +
0 + ? "mt-3" + : "mt-0" + )} + > + {visibleAudioUrls.map((url, idx) => ( +
+
+ + 音频 +
+
+ ))} +
+ )} {openClawActionBlocks.length > 0 ? ( (); + const urls = new Map(); + const candidates: Array<{ + forceKind?: ChatMediaKind; + index: number; + value: string; + }> = []; + const addUrl = (value: string, forceKind?: ChatMediaKind) => { + const url = sanitizeExtractedMediaUrl(value); + if (!url || (forceKind !== kind && !isChatMediaUrl(url, kind))) return; + try { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return; + } catch { + return; + } + if (!urls.has(url)) urls.set(url, url); + }; - const videoTagPattern = /]+src="([^"]+)"/gi; + const tagPattern = /<(audio|video)\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi; let match: RegExpExecArray | null = null; - while ((match = videoTagPattern.exec(text)) !== null) { - if (match[1]) urls.add(match[1]); + while ((match = tagPattern.exec(text)) !== null) { + candidates.push({ + forceKind: match[1]?.toLowerCase() as ChatMediaKind, + index: match.index, + value: match[2] || "", + }); } - const markdownVideoPattern = /\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/gi; - while ((match = markdownVideoPattern.exec(text)) !== null) { - if (match[1]) urls.add(match[1]); + const containerPattern = /<(audio|video)\b[^>]*>([\s\S]*?)<\/\1>/gi; + while ((match = containerPattern.exec(text)) !== null) { + const containerKind = match[1]?.toLowerCase() as ChatMediaKind; + const sourcePattern = /]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi; + let sourceMatch: RegExpExecArray | null = null; + while ((sourceMatch = sourcePattern.exec(match[2] || "")) !== null) { + candidates.push({ + forceKind: containerKind, + index: match.index + sourceMatch.index, + value: sourceMatch[1] || "", + }); + } } - const plainVideoPattern = /(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/gi; - while ((match = plainVideoPattern.exec(text)) !== null) { - if (match[1]) urls.add(match[1]); + const markdownLinkPattern = /\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi; + while ((match = markdownLinkPattern.exec(text)) !== null) { + const label = String(match[1] || ""); + const hintedKind = /音频|语音|声音|audio|mp3/i.test(label) + ? "audio" + : /视频|影片|播放|video/i.test(label) + ? "video" + : undefined; + candidates.push({ + forceKind: hintedKind, + index: match.index, + value: match[2] || "", + }); } - return Array.from(urls); + const plainUrlPattern = /https?:\/\/[^\s"'<>\[\]]+/gi; + while ((match = plainUrlPattern.exec(text)) !== null) { + candidates.push({ index: match.index, value: match[0] || "" }); + } + + candidates + .sort((left, right) => left.index - right.index) + .forEach((candidate) => addUrl(candidate.value, candidate.forceKind)); + + return Array.from(urls.values()); +} + +export function extractAudioUrls(text: string): string[] { + return extractChatMediaUrls(text, "audio"); +} + +export function extractVideoUrls(text: string): string[] { + return extractChatMediaUrls(text, "video"); } export function stripMediaMarkup(text: string): string { return String(text || "") .replace(/!\[(?:thumb)?\|\|\|[^|]+\|\|\|[^\]]*\]\([^)]+\)/g, "") .replace(//gi, "") + .replace(/]*\/?\s*>/gi, "") + .replace(//gi, "") + .replace(/]*\/?\s*>/gi, "") .replace(//gi, "") - .replace(/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/gi, "") - .replace(/https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?/gi, "") + .replace(/\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi, (match, label, url) => { + const hintedMedia = /音频|语音|声音|audio|mp3|视频|影片|播放|video/i.test( + String(label || "") + ); + return hintedMedia || isChatMediaUrl(url, "audio") || isChatMediaUrl(url, "video") + ? "" + : match; + }) + .replace(/https?:\/\/[^\s"'<>\[\]]+/gi, (match) => { + const url = sanitizeExtractedMediaUrl(match); + if (!isChatMediaUrl(url, "audio") && !isChatMediaUrl(url, "video")) { + return match; + } + return match.slice(url.length); + }) .trim(); } @@ -391,6 +520,7 @@ export function messageHasAssistantMedia( const text = String(part.text || ""); return ( extractAssistantImageUrls(text, referenceUrlKeys).length > 0 || + extractAudioUrls(text).length > 0 || extractVideoUrls(text).length > 0 ); }); diff --git a/docs/第三方OpenClaw-MCP接入开发文档.md b/docs/第三方OpenClaw-MCP接入开发文档.md new file mode 100644 index 000000000..919bf9445 --- /dev/null +++ b/docs/第三方OpenClaw-MCP接入开发文档.md @@ -0,0 +1,370 @@ +# 第三方 OpenClaw / MCP 接入开发文档 + +版本:1.0 +更新日期:2026-08-07 + +## 1. 接入信息 + +| 项目 | 地址或说明 | +| --- | --- | +| 生产站点 | `https://ai.yydd.ai` | +| OpenAPI Base URL | `https://ai.yydd.ai/api/openapi/v1` | +| 用户 Key 签发接口 | `POST https://ai.yydd.ai/api/openapi/v1/access-keys/provision` | +| MCP 传输方式 | 本地 `stdio` | +| MCP Server 入口 | `integrations/openclaw/mcp-server.mjs` | +| Node.js 要求 | Node.js 20 或更高版本 | + +> 重要:当前 MCP Server 使用本地 `stdio`,没有可以直接填入客户端的远程 Streamable HTTP MCP URL。只接受 URL 的第三方平台应直接调用本文 OpenAPI。支持 `command + args + env` 的 OpenClaw、Claude Desktop 等 MCP 客户端,可以使用本文的本地 MCP 配置。 + +## 2. 两类凭证 + +接入过程中会用到两种不同凭证,禁止混用。 + +### 2.1 第三方签发凭证 + +环境变量名称:`MCP_KEY_PROVISIONING_SECRET` + +- 只提供给受信任的第三方服务端。 +- 用于根据钉钉身份签发用户级 Access Key。 +- 权限很高,不能写入浏览器、App、公开仓库或前端 JavaScript。 +- 请通过密码管理器或其他安全通道单独交付,不要放进本接入文档。 + +### 2.2 用户 MCP Access Key + +格式示例: + +```text +ydr_live_xxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +- 与一个 Yedir 用户绑定,只能访问该用户自己的画布。 +- 默认有效期为 90 天,最长 365 天。 +- 支持 `canvas:read`、`canvas:write` 权限。 +- 明文仅在签发成功时返回一次。 +- 用户或管理员撤销后立即失效。 + +## 3. 按钉钉用户获取 Access Key + +### 3.1 请求 + +```http +POST /api/openapi/v1/access-keys/provision HTTP/1.1 +Host: ai.yydd.ai +Authorization: Bearer YOUR_PROVISIONING_SECRET +Content-Type: application/json +``` + +请求体: + +```json +{ + "dingTalkUsername": "张三", + "keyName": "OpenClaw 办公电脑", + "scopes": ["canvas:read", "canvas:write"], + "expiresInDays": 90 +} +``` + +字段说明: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `dingTalkUsername` | 是 | 钉钉真实姓名,精确匹配,最长 100 字符 | +| `dingTalkUserId` | 否 | 钉钉用户 ID;遇到同名用户时必须提供 | +| `keyName` | 否 | Key 的用途说明,最长 100 字符 | +| `scopes` | 否 | 默认包含 `canvas:read` 和 `canvas:write` | +| `expiresInDays` | 否 | 1–365,默认 90 | + +接口只会匹配同时满足以下条件的账号: + +- 用户状态为启用; +- 用户已经通过钉钉登录或同步,数据库中存在钉钉用户 ID; +- 姓名与 `dingTalkUsername` 完全一致; +- 如果传入 `dingTalkUserId`,姓名和用户 ID 必须同时匹配。 + +### 3.2 cURL 示例 + +```bash +export YEDIR_PROVISIONING_SECRET="由 Yedir 通过安全渠道提供的签发凭证" + +curl -X POST \ + "https://ai.yydd.ai/api/openapi/v1/access-keys/provision" \ + -H "Authorization: Bearer ${YEDIR_PROVISIONING_SECRET}" \ + -H "Content-Type: application/json" \ + --data '{ + "dingTalkUsername": "张三", + "keyName": "OpenClaw 办公电脑", + "scopes": ["canvas:read", "canvas:write"], + "expiresInDays": 90 + }' +``` + +### 3.3 成功响应 + +HTTP 状态码:`201 Created` + +```json +{ + "success": true, + "data": { + "user": { + "id": "用户 UUID", + "name": "张三", + "dingtalkUserId": "钉钉用户 ID" + }, + "accessKey": { + "id": "Access Key UUID", + "name": "OpenClaw 办公电脑", + "tokenPrefix": "ydr_live_xxxxxxxxxxxx", + "token": "ydr_live_xxxxxxxxxxxx_完整密钥,仅返回一次", + "scopes": ["canvas:read", "canvas:write"], + "lastUsedAt": null, + "expiresAt": "2026-11-05T03:00:00.000Z", + "revokedAt": null, + "createdAt": "2026-08-07T03:00:00.000Z" + } + } +} +``` + +第三方必须立即读取并安全保存 `data.accessKey.token`。后续接口使用这个完整 `token`,不能使用 `tokenPrefix`。 + +### 3.4 Node.js 示例 + +```js +const baseUrl = "https://ai.yydd.ai"; +const provisioningSecret = process.env.YEDIR_PROVISIONING_SECRET; + +const response = await fetch( + `${baseUrl}/api/openapi/v1/access-keys/provision`, + { + method: "POST", + headers: { + Authorization: `Bearer ${provisioningSecret}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + dingTalkUsername: "张三", + keyName: "OpenClaw 办公电脑", + scopes: ["canvas:read", "canvas:write"], + expiresInDays: 90, + }), + } +); + +const payload = await response.json(); +if (!response.ok) { + throw new Error(`${response.status}: ${payload.code} - ${payload.error}`); +} + +const userAccessToken = payload.data.accessKey.token; +// 立即写入安全凭证存储,不要打印到日志。 +``` + +### 3.5 常见错误 + +| HTTP | `code` | 处理方式 | +| --- | --- | --- | +| 400 | `PROVISIONING_REQUEST_INVALID` | 检查字段名称、类型和有效期范围 | +| 401 | `PROVISIONING_UNAUTHORIZED` | 检查签发凭证,不要自动无限重试 | +| 404 | `DINGTALK_USER_NOT_FOUND` | 确认用户已登录 Yedir、账号已启用且姓名一致 | +| 409 | `DINGTALK_USERNAME_AMBIGUOUS` | 同名用户,补充 `dingTalkUserId` | +| 409 | `ACCESS_KEY_LIMIT_REACHED` | 用户已有 10 个有效 Key,先在前端撤销旧 Key | +| 500 | `ACCESS_KEY_PROVISION_FAILED` | 保存失败,记录请求时间并联系 Yedir 管理员 | +| 503 | `PROVISIONING_NOT_CONFIGURED` | 生产环境尚未配置签发服务密钥 | + +> 签发接口不是幂等接口。每次成功请求都会创建一个新的 Key,不要在收到 `201` 后自动重试。 + +## 4. 使用用户 Key 调用 OpenAPI + +后续所有画布接口使用用户级 Access Key: + +```http +Authorization: Bearer ydr_live_xxx_完整用户Key +``` + +### 4.1 验证用户和权限 + +```bash +curl "https://ai.yydd.ai/api/openapi/v1/me" \ + -H "Authorization: Bearer ${YEDIR_USER_ACCESS_TOKEN}" +``` + +### 4.2 获取画布列表 + +```bash +curl "https://ai.yydd.ai/api/openapi/v1/canvases?workspaceType=canvas&limit=24" \ + -H "Authorization: Bearer ${YEDIR_USER_ACCESS_TOKEN}" +``` + +参数: + +- `workspaceType`:`canvas`、`agent` 或 `video`,默认 `canvas`。 +- `limit`:1–50,默认 24。 + +### 4.3 获取单个画布 + +```bash +curl "https://ai.yydd.ai/api/openapi/v1/canvases/CANVAS_UUID" \ + -H "Authorization: Bearer ${YEDIR_USER_ACCESS_TOKEN}" +``` + +写操作前必须重新读取画布,并记录响应中的 `updatedAt`。该值是防止覆盖用户最新修改的 revision。 + +### 4.4 执行画布操作 + +```bash +curl -X POST \ + "https://ai.yydd.ai/api/openapi/v1/canvases/CANVAS_UUID/operations" \ + -H "Authorization: Bearer ${YEDIR_USER_ACCESS_TOKEN}" \ + -H "Content-Type: application/json" \ + --data '{ + "operationPlan": { + "version": 1, + "operationId": "third-party-create-text-20260807-001", + "operation": "create_text_node", + "projectId": "CANVAS_UUID", + "baseCanvasRevision": "UPDATED_AT_FROM_GET_CANVAS", + "baseCanvasUpdatedAt": "UPDATED_AT_FROM_GET_CANVAS", + "textContent": "由第三方 MCP 创建的文本节点" + } + }' +``` + +`operationId` 是幂等键。同一个逻辑操作发生网络超时时,应使用原来的 `operationId` 重试,不能生成新 ID。 + +支持的操作: + +- `create_agent_slot` +- `create_text_node` +- `fill_slot` +- `insert_beside` +- `append_revision` +- `replace_node_media` +- `add_annotation` +- `link_nodes` +- `move_nodes` +- `delete_nodes` +- `rename_node` +- `update_note` +- `update_text` + +其中 `replace_node_media`、`move_nodes`、`delete_nodes` 属于高风险操作,必须先取得最终用户对本次具体操作的明确确认,然后在操作计划中传入: + +```json +{ + "confirmed": true +} +``` + +遇到 `STALE_CANVAS_REVISION` 时必须重新读取画布。高风险操作 revision 变化后,需要重新向用户确认。 + +## 5. 本地 MCP 接入 + +### 5.1 安装 MCP Server + +从 Yedir 交付的代码包中进入: + +```bash +cd integrations/openclaw +npm ci +``` + +先验证用户 Key: + +```bash +YEDIR_BASE_URL="https://ai.yydd.ai" \ +YEDIR_ACCESS_TOKEN="ydr_live_xxx_完整用户Key" \ +node ./cli.mjs me +``` + +### 5.2 OpenClaw 注册命令 + +```bash +openclaw mcp add yedir-canvas \ + --command node \ + --arg "/绝对路径/integrations/openclaw/mcp-server.mjs" \ + --env "YEDIR_BASE_URL=https://ai.yydd.ai" \ + --env "YEDIR_ACCESS_TOKEN=ydr_live_xxx_完整用户Key" + +openclaw mcp doctor yedir-canvas --probe +``` + +安装配套 Skill: + +```bash +openclaw skills install \ + /绝对路径/integrations/openclaw/yedir-canvas \ + --as yedir-canvas +``` + +### 5.3 通用 MCP 客户端配置 + +```json +{ + "mcpServers": { + "yedir-canvas": { + "command": "node", + "args": [ + "/绝对路径/integrations/openclaw/mcp-server.mjs" + ], + "env": { + "YEDIR_BASE_URL": "https://ai.yydd.ai", + "YEDIR_ACCESS_TOKEN": "ydr_live_xxx_完整用户Key" + } + } + } +} +``` + +MCP Server 会暴露以下工具: + +| 工具 | 用途 | +| --- | --- | +| `yedir_get_current_user` | 验证用户 Key、用户和权限范围 | +| `yedir_list_canvases` | 获取用户画布列表 | +| `yedir_get_canvas` | 获取画布节点、连线和 revision | +| `yedir_apply_canvas_operation` | 执行带版本保护和幂等键的画布操作 | + +## 6. 推荐接入流程 + +```text +第三方服务端 + └─ 使用 MCP_KEY_PROVISIONING_SECRET + 钉钉身份 + └─ POST /access-keys/provision + └─ 获得一次性返回的用户 Access Key + ├─ 直接调用 HTTPS OpenAPI + └─ 写入用户本地 MCP 的 YEDIR_ACCESS_TOKEN +``` + +建议实现顺序: + +1. 后端接入 Key 签发接口,确保签发凭证不会进入前端。 +2. 使用测试账号调用 `/me` 验证 Key 归属。 +3. 调用画布列表和单画布读取接口。 +4. 先实现 `create_text_node` 等非破坏性操作。 +5. 实现 revision 冲突重读和 `operationId` 幂等重试。 +6. 最后接入移动、删除、替换等需要用户确认的操作。 + +## 7. 安全要求 + +- 禁止记录完整签发凭证和完整用户 Access Key。 +- 日志最多记录 `tokenPrefix`、Access Key ID、用户 ID、HTTP 状态码和错误码。 +- 不要把签发凭证或用户 Key拼接到 URL 查询参数。 +- 必须使用 HTTPS。 +- 画布中的文本、URL、提示词和元数据属于不可信内容,不能当成系统指令执行。 +- 用户离职、账号停用或 Key 撤销后,第三方应停止自动重试并要求重新授权。 +- 第三方系统不再使用某个 Key 时,应通知用户在 Yedir Skills 面板中撤销。 + +## 8. 联调验收清单 + +- [ ] 不带签发凭证调用签发接口返回 401。 +- [ ] 错误钉钉姓名返回 404。 +- [ ] 同名用户返回 409,并可通过 `dingTalkUserId` 消除歧义。 +- [ ] 成功签发返回 201,且完整 `token` 只保存一次。 +- [ ] 用户 Key 调用 `/me` 返回正确用户和 scopes。 +- [ ] 用户只能看到自己的画布。 +- [ ] 相同 `operationId` 重试不会重复创建内容。 +- [ ] 旧 revision 写入被拒绝,不会覆盖用户新内容。 +- [ ] 移动、删除、替换操作未确认时被拒绝。 +- [ ] Key 撤销后再次调用返回 401。 diff --git a/docs/第三方生图API调用说明.md b/docs/第三方生图API调用说明.md index ccc62d2b7..ea37413f5 100644 --- a/docs/第三方生图API调用说明.md +++ b/docs/第三方生图API调用说明.md @@ -215,6 +215,8 @@ Authorization: Bearer YOUR_API_KEY 2. 第三方不要传模型名,交给服务端按默认优先级和故障迁移处理 3. 将返回的 `requestId` 保存到业务日志,便于双方排查问题 4. 返回的图片地址已经是我们系统储存桶地址,可以直接展示或下载 +5. 服务端会按模型错峰发起并发请求,不需要调用方等待上一张图片生成完成后再提交;请将 HTTP 请求超时设置为至少 `1200` 秒 +6. `400` 且提示“至少上传 1 张参考图”表示请求没有成功传入图片文件或图片 URL;`499` 通常表示调用方在结果返回前主动断开了连接 ## 8. 后台能力 diff --git a/lib/canvas-skill-tags.ts b/lib/canvas-skill-tags.ts index f9283f630..e64b5f4ab 100644 --- a/lib/canvas-skill-tags.ts +++ b/lib/canvas-skill-tags.ts @@ -3,9 +3,15 @@ export const CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX = "canvas_skill_tag_order export const CANVAS_SKILL_TAGS_CONFIG_VERSION = 6; export const DEFAULT_CANVAS_SKILL_TAGS_TITLE = "Claw 快捷技能"; +export type CanvasSkillCategory = { + id: string; + name: string; +}; + export type CanvasSkillTagSettings = { enabled: boolean; title: string; + categories: CanvasSkillCategory[]; tags: CanvasSkillTag[]; version?: number; }; @@ -17,6 +23,7 @@ export type CanvasSkillTag = { placeholder?: string; iconName?: string; iconUrl?: string; + categoryId?: string; }; export const CANVAS_SKILL_TAGS_V6_ADDITIONS: CanvasSkillTag[] = [ @@ -228,6 +235,37 @@ function appendMissingCanvasSkillTags( ].slice(0, 50); } +function normalizeOptionalId(value: unknown) { + return normalizeString(value) + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +export function normalizeCanvasSkillCategories( + value: unknown +): CanvasSkillCategory[] { + if (!Array.isArray(value)) return []; + + const usedIds = new Set(); + const normalized: CanvasSkillCategory[] = []; + value.forEach((item, index) => { + const row = item && typeof item === "object" ? (item as Record) : {}; + const name = normalizeString(row.name); + if (!name) return; + + let id = normalizeOptionalId(row.id) || `category-${index + 1}`; + while (usedIds.has(id)) { + id = `${id}-${index + 1}`; + } + usedIds.add(id); + normalized.push({ id, name }); + }); + + return normalized.slice(0, 20); +} + export function normalizeCanvasSkillTags(value: unknown): CanvasSkillTag[] { const source = Array.isArray(value) ? value : DEFAULT_CANVAS_SKILL_TAGS; const usedIds = new Set(); @@ -240,6 +278,7 @@ export function normalizeCanvasSkillTags(value: unknown): CanvasSkillTag[] { const placeholder = normalizeString(row.placeholder); const iconName = normalizeString(row.iconName); const iconUrl = normalizeString(row.iconUrl); + const categoryId = normalizeOptionalId(row.categoryId); if (!name || !prompt) return; let id = normalizeId(row.id, index); @@ -254,6 +293,7 @@ export function normalizeCanvasSkillTags(value: unknown): CanvasSkillTag[] { placeholder: placeholder || undefined, iconName: iconName || undefined, iconUrl: iconUrl || undefined, + categoryId: categoryId || undefined, }); }); @@ -276,6 +316,7 @@ export function normalizeCanvasSkillTagSettings( return { enabled: true, title: DEFAULT_CANVAS_SKILL_TAGS_TITLE, + categories: [], tags, version: CANVAS_SKILL_TAGS_CONFIG_VERSION, }; @@ -284,6 +325,13 @@ export function normalizeCanvasSkillTagSettings( if (value && typeof value === "object") { const row = value as Record; const version = Number(row.version || 0); + const categories = normalizeCanvasSkillCategories(row.categories); + const categoryIds = new Set(categories.map((category) => category.id)); + const tags = normalizeCanvasSkillTags(row.tags).map((tag) => { + if (!tag.categoryId || categoryIds.has(tag.categoryId)) return tag; + const { categoryId: _categoryId, ...rest } = tag; + return rest; + }); if (options.requireCurrentVersion && version < CANVAS_SKILL_TAGS_CONFIG_VERSION) { const title = normalizeString(row.title) || DEFAULT_CANVAS_SKILL_TAGS_TITLE; const tags = appendMissingCanvasSkillTags( @@ -293,6 +341,7 @@ export function normalizeCanvasSkillTagSettings( return { enabled: row.enabled !== false, title, + categories, tags, version: CANVAS_SKILL_TAGS_CONFIG_VERSION, }; @@ -302,7 +351,8 @@ export function normalizeCanvasSkillTagSettings( return { enabled: row.enabled !== false, title, - tags: normalizeCanvasSkillTags(row.tags), + categories, + tags, version: CANVAS_SKILL_TAGS_CONFIG_VERSION, }; } @@ -310,6 +360,7 @@ export function normalizeCanvasSkillTagSettings( return { enabled: true, title: DEFAULT_CANVAS_SKILL_TAGS_TITLE, + categories: [], tags: normalizeCanvasSkillTags(null), version: CANVAS_SKILL_TAGS_CONFIG_VERSION, }; diff --git a/lib/external-image-api/service.ts b/lib/external-image-api/service.ts index 38dff9ba6..ee6baeff9 100644 --- a/lib/external-image-api/service.ts +++ b/lib/external-image-api/service.ts @@ -10,6 +10,11 @@ import { db } from "@/lib/db"; import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries"; import { user } from "@/lib/db/schema"; import { generateDummyPassword } from "@/lib/db/utils"; +import { + acquireCanvasImageModelSlot, + normalizeCanvasImageModelKey, + recordCanvasImageModelExecutionDuration, +} from "@/lib/canvas/image-concurrency"; import { createLogger } from "@/lib/observability/logger"; import { DEFAULT_EXTERNAL_IMAGE_API_OWNER_EMAIL, @@ -1013,6 +1018,7 @@ export async function generateImageWithModel(params: { export async function generateImagesWithFailover(params: { candidates: ImageModelEntry[]; + ownerKey?: string; prompt: string; negativePrompt?: string | null; imageCount: number; @@ -1028,16 +1034,35 @@ export async function generateImagesWithFailover(params: { const attempts: ExternalImageApiGenerateAttempt[] = []; for (const candidate of params.candidates) { + const modelKey = normalizeCanvasImageModelKey( + candidate.model.modelId || candidate.model.id, + ); + const executionStartedAt = Date.now(); try { - const markdown = await generateImageWithModel({ - modelDbId: candidate.model.id, - prompt: params.prompt, - negativePrompt: params.negativePrompt || null, - imageCount: params.imageCount, - aspectRatio: params.aspectRatio || null, - imageSize: params.imageSize || null, - referenceImageUrls: params.referenceImageUrls || [], + const slot = await acquireCanvasImageModelSlot({ + modelKey, + ownerKey: params.ownerKey || "external-image-api", + debugLabel: "external-image-api", + debugMeta: { + modelDbId: candidate.model.id, + modelRuntimeId: candidate.model.modelId, + }, }); + let generation: Promise; + try { + generation = generateImageWithModel({ + modelDbId: candidate.model.id, + prompt: params.prompt, + negativePrompt: params.negativePrompt || null, + imageCount: params.imageCount, + aspectRatio: params.aspectRatio || null, + imageSize: params.imageSize || null, + referenceImageUrls: params.referenceImageUrls || [], + }); + } finally { + await slot.release(); + } + const markdown = await generation; const hostedMarkdown = await ensureHostedImageMarkdown( markdown, @@ -1075,6 +1100,11 @@ export async function generateImagesWithFailover(params: { success: false, error: error instanceof Error ? error.message : "图片生成失败", }); + } finally { + recordCanvasImageModelExecutionDuration( + modelKey, + Date.now() - executionStartedAt, + ); } } diff --git a/lib/openclaw-chat/config.ts b/lib/openclaw-chat/config.ts index 80dc078c8..1b9d6604f 100644 --- a/lib/openclaw-chat/config.ts +++ b/lib/openclaw-chat/config.ts @@ -20,9 +20,11 @@ export type AdminOpenClawChatConfig = Omit & { tokenPreview: string; }; -const DEFAULT_TIMEOUT_MS = 600_000; +const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000; const MIN_TIMEOUT_MS = 5_000; -const MAX_TIMEOUT_MS = 600_000; +// Complex OpenClaw skills can legitimately run for up to thirty minutes. +// Keep this aligned with the route maxDuration and the integration contract. +const MAX_TIMEOUT_MS = 30 * 60 * 1000; function readRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) diff --git a/lib/openclaw-chat/errors.ts b/lib/openclaw-chat/errors.ts index ddc3fa0c2..bebce60b6 100644 --- a/lib/openclaw-chat/errors.ts +++ b/lib/openclaw-chat/errors.ts @@ -1,5 +1,7 @@ export const OPENCLAW_UPGRADE_RETRY_MESSAGE = "claw正在升级更新稍等5分钟再试"; +export const OPENCLAW_TIMEOUT_MESSAGE = + "任务处理时间较长,本次等待已超时。任务可能仍在后台执行,请稍后在 Claw 历史对话中查看,或重新发送。"; export const OPENCLAW_AUTH_REQUIRED_CODE = "AUTH_REQUIRED"; export const OPENCLAW_AUTH_REQUIRED_MESSAGE = "登录状态已失效,请重新登录后使用 Claw"; @@ -43,3 +45,18 @@ export function isOpenClawAuthRequiredError( (code === OPENCLAW_AUTH_REQUIRED_CODE || /^Unauthorized$/i.test(error)) ); } + +export function isOpenClawTimeoutError(error: unknown) { + if (!error || typeof error !== "object") return false; + const candidate = error as { name?: unknown; message?: unknown }; + return ( + candidate.name === "AbortError" || + /\b(?:aborted|timeout|timed out)\b/i.test(String(candidate.message || "")) + ); +} + +export function getOpenClawTaskFailureMessage(error: unknown) { + return isOpenClawTimeoutError(error) + ? OPENCLAW_TIMEOUT_MESSAGE + : OPENCLAW_UPGRADE_RETRY_MESSAGE; +} diff --git a/tests/routes/admin-settings.test.ts b/tests/routes/admin-settings.test.ts index 31ea54778..98cda1960 100644 --- a/tests/routes/admin-settings.test.ts +++ b/tests/routes/admin-settings.test.ts @@ -243,4 +243,37 @@ test.describe.serial("/api/admin/settings", () => { expect.arrayContaining(["产品生命周期总览", "新品进度"]) ); }); + + test("canvas skill categories keep valid assignments and clear removed categories", () => { + const normalized = normalizeCanvasSkillTagSettings({ + enabled: true, + title: "分类技能", + categories: [ + { id: "market", name: "市场分析" }, + { id: "operations", name: "运营工具" }, + ], + tags: [ + { + id: "market-overview", + name: "市场大盘", + prompt: "查询市场大盘", + categoryId: "market", + }, + { + id: "legacy-skill", + name: "旧技能", + prompt: "旧技能提示词", + categoryId: "removed-category", + }, + ], + version: 6, + }); + + expect(normalized.categories).toEqual([ + { id: "market", name: "市场分析" }, + { id: "operations", name: "运营工具" }, + ]); + expect(normalized.tags[0]?.categoryId).toBe("market"); + expect(normalized.tags[1]?.categoryId).toBeUndefined(); + }); }); diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index e25dcdff5..5c7144e44 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test"; import { collectMessageImageUrls, extractAssistantImageUrls, + extractAudioUrls, extractVideoUrls, filterStandaloneAssistantImageUrls, getMarkdownTableImageUrlKeys, @@ -5215,6 +5216,31 @@ test.describe("canvas chat pure helpers", () => { expect(stripMediaMarkup(text)).toBe("生成完成\n\n\n继续优化"); }); + test("extracts playable audio and common video formats from Claw markdown", () => { + const text = [ + "任务完成", + "[播放音频](https://cdn.example.com/result?id=1&format=mp3)", + '', + "[播放视频](https://cdn.example.com/result-video?filename=demo.mp4)", + "https://cdn.example.com/preview.webm?token=3", + "查看说明:https://cdn.example.com/readme.html", + ].join("\n"); + + expect(extractAudioUrls(text)).toEqual([ + "https://cdn.example.com/result?id=1&format=mp3", + "https://cdn.example.com/voice.m4a?sig=2", + ]); + expect(extractVideoUrls(text)).toEqual([ + "https://cdn.example.com/result-video?filename=demo.mp4", + "https://cdn.example.com/preview.webm?token=3", + ]); + expect(stripMediaMarkup(text)).toContain( + "查看说明:https://cdn.example.com/readme.html" + ); + expect(stripMediaMarkup(text)).not.toContain("播放音频"); + expect(stripMediaMarkup(text)).not.toContain("preview.webm"); + }); + test("builds detail review plans with clamped count and localized fallbacks", () => { const plans = buildDetailReviewPlans({ language: "zh", diff --git a/tests/routes/openclaw-client-errors.test.ts b/tests/routes/openclaw-client-errors.test.ts index c5e3f83f0..10fae44dc 100644 --- a/tests/routes/openclaw-client-errors.test.ts +++ b/tests/routes/openclaw-client-errors.test.ts @@ -1,10 +1,12 @@ import { expect, test } from "@playwright/test"; import { getOpenClawClientErrorMessage, + getOpenClawTaskFailureMessage, isOpenClawAuthRequiredError, OPENCLAW_AUTH_REQUIRED_CODE, OPENCLAW_AUTH_REQUIRED_MESSAGE, OPENCLAW_FORBIDDEN_MESSAGE, + OPENCLAW_TIMEOUT_MESSAGE, OPENCLAW_UPGRADE_RETRY_MESSAGE, } from "@/lib/openclaw-chat/errors"; @@ -54,4 +56,20 @@ test.describe("OpenClaw client error messages", () => { OPENCLAW_UPGRADE_RETRY_MESSAGE ); }); + + test("reports durable task timeouts without claiming the service is upgrading", () => { + expect( + getOpenClawTaskFailureMessage( + Object.assign(new Error("This operation was aborted"), { + name: "AbortError", + }) + ) + ).toBe(OPENCLAW_TIMEOUT_MESSAGE); + }); + + test("keeps the service fallback for non-timeout durable task failures", () => { + expect(getOpenClawTaskFailureMessage(new Error("upstream unavailable"))).toBe( + OPENCLAW_UPGRADE_RETRY_MESSAGE + ); + }); }); diff --git a/tests/routes/openclaw-config.test.ts b/tests/routes/openclaw-config.test.ts index 5d87bfa53..39e3466e5 100644 --- a/tests/routes/openclaw-config.test.ts +++ b/tests/routes/openclaw-config.test.ts @@ -1,14 +1,27 @@ import { expect, test } from "@playwright/test"; import { normalizeOpenClawChatConfig } from "@/lib/openclaw-chat/config"; -test.describe("OpenClaw chat timeout config", () => { - test("defaults long-running tasks to ten minutes", () => { - expect(normalizeOpenClawChatConfig({}).timeoutMs).toBe(600_000); +test.describe("OpenClaw chat configuration", () => { + test("defaults long-running tasks to thirty minutes", () => { + expect(normalizeOpenClawChatConfig({}).timeoutMs).toBe(30 * 60 * 1000); }); - test("caps configured timeouts at ten minutes", () => { + test("allows a thirty-minute timeout for complex skills", () => { expect( - normalizeOpenClawChatConfig({ timeoutMs: 900_000 }).timeoutMs - ).toBe(600_000); + normalizeOpenClawChatConfig({ + enabled: true, + baseUrl: "http://openclaw.test", + token: "secret", + timeoutMs: 30 * 60 * 1000, + }).timeoutMs + ).toBe(30 * 60 * 1000); + }); + + test("caps excessive timeout values at thirty minutes", () => { + expect( + normalizeOpenClawChatConfig({ + timeoutMs: 60 * 60 * 1000, + }).timeoutMs + ).toBe(30 * 60 * 1000); }); });