feat: improve Claw workflows and reliability

This commit is contained in:
134 2026-08-07 14:42:07 +08:00
parent 219dc4a4ac
commit 643a0128d3
17 changed files with 1050 additions and 158 deletions

View File

@ -199,9 +199,11 @@ import {
normalizeCanvasLocalEditConfig,
} from "@/lib/canvas/local-edit-config";
import {
type CanvasSkillCategory,
type CanvasSkillTag,
DEFAULT_CANVAS_SKILL_TAGS,
DEFAULT_CANVAS_SKILL_TAGS_TITLE,
normalizeCanvasSkillCategories,
normalizeCanvasSkillTags,
} from "@/lib/canvas-skill-tags";
import {
@ -417,6 +419,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 {
@ -3411,6 +3420,9 @@ export function AdminSettingsClient() {
DEFAULT_CANVAS_SKILL_TAGS_TITLE
);
const [canvasSkillTagsEnabled, setCanvasSkillTagsEnabled] = useState(true);
const [canvasSkillCategories, setCanvasSkillCategories] = useState<
CanvasSkillCategory[]
>([]);
const [canvasSkillTags, setCanvasSkillTags] = useState<CanvasSkillTag[]>(
DEFAULT_CANVAS_SKILL_TAGS
);
@ -3428,6 +3440,7 @@ export function AdminSettingsClient() {
const { data: canvasSkillTagsData, mutate: mutateCanvasSkillTags } = useSWR<{
enabled?: boolean;
title?: string;
categories?: CanvasSkillCategory[];
tags?: CanvasSkillTag[];
}>("/api/canvas-skill-tags", fetcher);
@ -3654,6 +3667,9 @@ export function AdminSettingsClient() {
String(canvasSkillTagsData.title || "").trim() ||
DEFAULT_CANVAS_SKILL_TAGS_TITLE
);
setCanvasSkillCategories(
normalizeCanvasSkillCategories(canvasSkillTagsData.categories)
);
setCanvasSkillTags(normalizeCanvasSkillTags(canvasSkillTagsData.tags));
}
}, [canvasSkillTagsData]);
@ -3690,7 +3706,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) =>
@ -3943,6 +3965,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)
@ -3951,6 +4003,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",
@ -3958,6 +4011,7 @@ export function AdminSettingsClient() {
body: JSON.stringify({
enabled: canvasSkillTagsEnabled,
title: canvasSkillTagsTitle,
categories,
tags: payload,
}),
});
@ -3969,6 +4023,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();
@ -14855,6 +14912,61 @@ export function AdminSettingsClient() {
value={canvasSkillTagsTitle}
/>
</div>
<div className="space-y-3 rounded-lg border p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<Label>技能分类</Label>
<p className="mt-1 text-muted-foreground text-xs">
分类会显示在 Claw 快捷技能区,用户可按分类筛选技能。
</p>
</div>
<Button
onClick={handleAddCanvasSkillCategory}
size="sm"
type="button"
variant="outline"
>
<Plus className="mr-1 h-4 w-4" /> 添加分类
</Button>
</div>
{canvasSkillCategories.length > 0 ? (
<div className="grid gap-2 md:grid-cols-2">
{canvasSkillCategories.map((category, categoryIndex) => (
<div
className="flex items-center gap-2"
key={category.id}
>
<Input
aria-label={`分类名称 ${categoryIndex + 1}`}
onChange={(event) =>
handleCanvasSkillCategoryNameChange(
categoryIndex,
event.target.value
)
}
placeholder={`分类名称 ${categoryIndex + 1}`}
value={category.name}
/>
<Button
aria-label={`删除分类 ${category.name}`}
onClick={() =>
handleRemoveCanvasSkillCategory(categoryIndex)
}
size="icon"
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
))}
</div>
) : (
<p className="rounded-md bg-muted/40 px-3 py-2 text-muted-foreground text-xs">
暂未配置分类,所有技能会显示在“全部”中。
</p>
)}
</div>
{canvasSkillTags.map((tag, index) => {
const TagIcon = getCanvasSkillIconComponent(tag.iconName);
return (
@ -14862,7 +14974,7 @@ export function AdminSettingsClient() {
className="space-y-2 rounded-lg border p-3"
key={tag.id || index}
>
<div className="flex items-center gap-2">
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<Input
onChange={(event) =>
handleCanvasSkillTagChange(
@ -14874,7 +14986,32 @@ export function AdminSettingsClient() {
placeholder={`标签名称 ${index + 1}`}
value={tag.name}
/>
<Select
onValueChange={(value) =>
handleCanvasSkillTagChange(
index,
"categoryId",
value === "__uncategorized__" ? "" : value
)
}
value={tag.categoryId || "__uncategorized__"}
>
<SelectTrigger className="w-full shrink-0 md:w-[180px]">
<SelectValue placeholder="选择分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__uncategorized__">
未分类
</SelectItem>
{canvasSkillCategories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
className="self-end md:self-auto"
disabled={canvasSkillTags.length <= 1}
onClick={() => handleRemoveCanvasSkillTag(index)}
size="icon"

View File

@ -140,10 +140,12 @@ export async function PUT(request: NextRequest) {
const normalized = normalizeCanvasSkillTagSettings(body);
const enabled = normalized.enabled;
const title = normalized.title;
const categories = normalized.categories;
const tags = normalized.tags;
const nextValue = {
enabled,
title,
categories,
tags,
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
};
@ -171,7 +173,7 @@ export async function PUT(request: NextRequest) {
});
}
return NextResponse.json({ success: true, enabled, title, tags });
return NextResponse.json({ success: true, enabled, title, categories, tags });
} catch (error) {
console.error("[CanvasSkillTags API] Update error:", error);
return NextResponse.json({ error: "Failed to update" }, { status: 500 });

View File

@ -19,6 +19,7 @@ import {
OPENCLAW_CHAT_CONFIG_KEY,
} from "@/lib/openclaw-chat/config";
import {
getOpenClawTaskFailureMessage,
OPENCLAW_AUTH_REQUIRED_CODE,
OPENCLAW_AUTH_REQUIRED_MESSAGE,
OPENCLAW_UPGRADE_RETRY_MESSAGE,
@ -46,7 +47,7 @@ import { generateUUID } from "@/lib/utils";
const DEFAULT_PROCESSING_HINT =
"已收到,这个任务稍微繁重一些,处理时间可能会长一点,请耐心等候。";
export const maxDuration = 600;
export const maxDuration = 30 * 60;
const requestSchema = z.object({
id: z.string().uuid(),
@ -598,7 +599,7 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) {
await markClawdTaskFailed({
chatId: params.chatId,
assistantMessageId: params.assistantMessageId,
error: OPENCLAW_UPGRADE_RETRY_MESSAGE,
error: getOpenClawTaskFailureMessage(error),
}).catch(() => null);
} finally {
clearTimeout(timeout);

View File

@ -21,103 +21,6 @@ import {
export const runtime = "nodejs";
export const maxDuration = 1200;
const EXTERNAL_IMAGE_API_LIMITER_KEY = "__external_image_api_limiter__";
const DEFAULT_EXTERNAL_IMAGE_API_MAX_CONCURRENT = 2;
const DEFAULT_EXTERNAL_IMAGE_API_MAX_QUEUE = 8;
const DEFAULT_EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS = 15_000;
class ExternalImageApiBusyError extends Error {
readonly retryAfterSeconds: number;
constructor(message: string, retryAfterSeconds = 15) {
super(message);
this.name = "ExternalImageApiBusyError";
this.retryAfterSeconds = retryAfterSeconds;
}
}
type ExternalImageApiWaiter = {
resolve: (release: () => void) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
type ExternalImageApiLimiterState = {
running: number;
queue: ExternalImageApiWaiter[];
};
function parsePositiveInteger(value: unknown, fallback: number) {
const parsed = Number.parseInt(String(value || ""), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function getExternalImageApiLimiterState(): ExternalImageApiLimiterState {
const globalState = globalThis as typeof globalThis & {
[EXTERNAL_IMAGE_API_LIMITER_KEY]?: ExternalImageApiLimiterState;
};
if (!globalState[EXTERNAL_IMAGE_API_LIMITER_KEY]) {
globalState[EXTERNAL_IMAGE_API_LIMITER_KEY] = {
running: 0,
queue: [],
};
}
return globalState[EXTERNAL_IMAGE_API_LIMITER_KEY]!;
}
function acquireExternalImageApiSlot(): Promise<() => void> {
const state = getExternalImageApiLimiterState();
const maxConcurrent = parsePositiveInteger(
process.env.EXTERNAL_IMAGE_API_MAX_CONCURRENT,
DEFAULT_EXTERNAL_IMAGE_API_MAX_CONCURRENT,
);
const maxQueue = parsePositiveInteger(
process.env.EXTERNAL_IMAGE_API_MAX_QUEUE,
DEFAULT_EXTERNAL_IMAGE_API_MAX_QUEUE,
);
const queueTimeoutMs = parsePositiveInteger(
process.env.EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS,
DEFAULT_EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS,
);
const release = () => {
state.running = Math.max(0, state.running - 1);
const next = state.queue.shift();
if (!next) return;
clearTimeout(next.timer);
state.running += 1;
next.resolve(release);
};
if (state.running < maxConcurrent) {
state.running += 1;
return Promise.resolve(release);
}
if (state.queue.length >= maxQueue) {
return Promise.reject(
new ExternalImageApiBusyError("图片生成服务繁忙,请稍后重试"),
);
}
return new Promise((resolve, reject) => {
const waiter: ExternalImageApiWaiter = {
resolve,
reject,
timer: setTimeout(() => {
const index = state.queue.indexOf(waiter);
if (index >= 0) {
state.queue.splice(index, 1);
}
reject(new ExternalImageApiBusyError("图片生成排队超时,请稍后重试"));
}, queueTimeoutMs),
};
state.queue.push(waiter);
});
}
const requestSchema = z.object({
prompt: z.string().trim().min(1, "prompt 不能为空"),
negativePrompt: z.string().trim().optional(),
@ -294,10 +197,8 @@ export async function POST(request: Request) {
const startedAt = Date.now();
let logId: string | null = null;
let releaseExternalImageApiSlot: (() => void) | null = null;
try {
releaseExternalImageApiSlot = await acquireExternalImageApiSlot();
const payload = await parseRequestPayload(request);
const imageSize = normalizeExternalImageApiImageSize(payload.imageSize);
const candidateModels = await buildExternalImageApiModelCandidates({
@ -344,6 +245,7 @@ export async function POST(request: Request) {
const generationResult = await generateImagesWithFailover({
candidates: candidateModels,
ownerKey: `external-image-api:${authResult.accessKey.id}`,
prompt: payload.prompt,
negativePrompt: payload.negativePrompt || null,
imageCount: payload.imageCount || 1,
@ -467,21 +369,6 @@ export async function POST(request: Request) {
.where(eq(externalImageApiRequestLog.id, logId));
}
if (error instanceof ExternalImageApiBusyError) {
return NextResponse.json(
{
success: false,
error: error.message,
},
{
status: 429,
headers: {
"Retry-After": String(error.retryAfterSeconds),
},
},
);
}
return NextResponse.json(
{
success: false,
@ -489,9 +376,5 @@ export async function POST(request: Request) {
},
{ status: 500 },
);
} finally {
if (releaseExternalImageApiSlot) {
releaseExternalImageApiSlot();
}
}
}

View File

@ -25,7 +25,7 @@ import {
type UiSurfaceSnapshot,
} from "@/lib/ui-surface-protocol";
import useSWR, { useSWRConfig } from "swr";
import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot, FileSpreadsheet, Paperclip, Copy, Square, Search, GripVertical } from "lucide-react";
import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot, FileSpreadsheet, Paperclip, Copy, Square, Search, GripVertical, Volume2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Collapsible,
@ -108,7 +108,9 @@ import type {
import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch";
import {
DEFAULT_CANVAS_SKILL_TAGS_TITLE,
normalizeCanvasSkillCategories,
normalizeCanvasSkillTags,
type CanvasSkillCategory,
type CanvasSkillTag,
} from "@/lib/canvas-skill-tags";
import {
@ -276,6 +278,7 @@ import {
import {
buildCanvasHistoryMessagesSignature,
extractAssistantImageUrls,
extractAudioUrls,
extractCanvasModelName,
extractImageUrls,
extractVideoUrls,
@ -2810,6 +2813,8 @@ export function CanvasChatPanel({
const [selectedSkillTagId, setSelectedSkillTagId] = useState<string | null>(null);
const [areCanvasSkillTagsExpanded, setAreCanvasSkillTagsExpanded] = useState(false);
const [canvasSkillTagSearchQuery, setCanvasSkillTagSearchQuery] = useState("");
const [selectedCanvasSkillCategoryId, setSelectedCanvasSkillCategoryId] =
useState("all");
const [draggingSkillTagId, setDraggingSkillTagId] = useState<string | null>(null);
const [canvasSkillTagOrderOverride, setCanvasSkillTagOrderOverride] = useState<string[] | null>(
null
@ -5017,6 +5022,7 @@ export function CanvasChatPanel({
const { data: canvasSkillTagsData } = useSWR<{
enabled?: boolean;
title?: string;
categories?: CanvasSkillCategory[];
tags?: CanvasSkillTag[];
userOrder?: string[];
}>(
@ -5203,6 +5209,10 @@ export function CanvasChatPanel({
const canvasSkillTagsEnabled = canvasSkillTagsData?.enabled !== false;
const canvasSkillTagsTitle =
String(canvasSkillTagsData?.title || "").trim() || DEFAULT_CANVAS_SKILL_TAGS_TITLE;
const canvasSkillCategories = useMemo(
() => normalizeCanvasSkillCategories(canvasSkillTagsData?.categories),
[canvasSkillTagsData?.categories]
);
const baseCanvasSkillTags = useMemo(
() => (canvasSkillTagsEnabled ? normalizeCanvasSkillTags(canvasSkillTagsData?.tags) : []),
[canvasSkillTagsData?.tags, canvasSkillTagsEnabled]
@ -5217,13 +5227,26 @@ export function CanvasChatPanel({
const normalizedCanvasSkillTagSearchQuery = canvasSkillTagSearchQuery
.trim()
.toLocaleLowerCase();
const hasUncategorizedCanvasSkillTags = canvasSkillTags.some(
(tag) => !tag.categoryId
);
const hasCanvasSkillCategoryFilters = canvasSkillCategories.length > 0;
const categoryFilteredCanvasSkillTags = useMemo(() => {
if (selectedCanvasSkillCategoryId === "all") return canvasSkillTags;
if (selectedCanvasSkillCategoryId === "__uncategorized__") {
return canvasSkillTags.filter((tag) => !tag.categoryId);
}
return canvasSkillTags.filter(
(tag) => tag.categoryId === selectedCanvasSkillCategoryId
);
}, [canvasSkillTags, selectedCanvasSkillCategoryId]);
const filteredCanvasSkillTags = useMemo(() => {
if (!normalizedCanvasSkillTagSearchQuery) {
return canvasSkillTags;
return categoryFilteredCanvasSkillTags;
}
const keywords = normalizedCanvasSkillTagSearchQuery.split(/\s+/).filter(Boolean);
return canvasSkillTags.filter((tag) => {
return categoryFilteredCanvasSkillTags.filter((tag) => {
const searchableText = [tag.name, tag.prompt, tag.placeholder]
.map((value) => String(value || "").trim())
.filter(Boolean)
@ -5231,8 +5254,29 @@ export function CanvasChatPanel({
.toLocaleLowerCase();
return keywords.every((keyword) => searchableText.includes(keyword));
});
}, [canvasSkillTags, normalizedCanvasSkillTagSearchQuery]);
}, [categoryFilteredCanvasSkillTags, normalizedCanvasSkillTagSearchQuery]);
const isCanvasSkillTagSearching = normalizedCanvasSkillTagSearchQuery.length > 0;
useEffect(() => {
if (
selectedCanvasSkillCategoryId !== "all" &&
selectedCanvasSkillCategoryId !== "__uncategorized__" &&
!canvasSkillCategories.some(
(category) => category.id === selectedCanvasSkillCategoryId
)
) {
setSelectedCanvasSkillCategoryId("all");
}
if (
selectedCanvasSkillCategoryId === "__uncategorized__" &&
!hasUncategorizedCanvasSkillTags
) {
setSelectedCanvasSkillCategoryId("all");
}
}, [
canvasSkillCategories,
hasUncategorizedCanvasSkillTags,
selectedCanvasSkillCategoryId,
]);
useEffect(() => {
latestCanvasSkillTagsRef.current = canvasSkillTags;
}, [canvasSkillTags]);
@ -5292,7 +5336,7 @@ export function CanvasChatPanel({
[canvasSkillTags, selectedSkillTagId]
);
const shouldShowCanvasSkillTagsToggle =
!isCanvasSkillTagSearching && canvasSkillTags.length > 4;
!isCanvasSkillTagSearching && categoryFilteredCanvasSkillTags.length > 4;
const activeChatSession = useMemo(
() => chatSessions.find((session) => session.id === activeChatId) || null,
[activeChatId, chatSessions]
@ -15111,8 +15155,76 @@ export function CanvasChatPanel({
<h3 className="text-[16px] font-semibold leading-none text-[#1f2328] dark:text-zinc-100">
{canvasSkillTagsTitle}
</h3>
{hasCanvasSkillCategoryFilters ? (
<div
className="mt-4 flex flex-wrap justify-center gap-2"
aria-label="快捷技能分类"
>
<button
type="button"
onClick={() => {
setSelectedCanvasSkillCategoryId("all");
setAreCanvasSkillTagsExpanded(false);
}}
className={cn(
"inline-flex h-8 items-center justify-center rounded-full border px-3 text-[12px] font-medium transition-colors",
selectedCanvasSkillCategoryId === "all"
? "border-[#1f2328] bg-[#1f2328] text-white dark:border-zinc-100 dark:bg-zinc-100 dark:text-zinc-950"
: "border-[#e5e7eb] bg-[#fafbfc] text-[#667085] hover:border-[#cfd4dc] hover:text-[#1f2328] dark:border-white/10 dark:bg-white/5 dark:text-zinc-400 dark:hover:border-white/20 dark:hover:text-zinc-100"
)}
aria-pressed={selectedCanvasSkillCategoryId === "all"}
>
全部
</button>
{canvasSkillCategories.map((category) => (
<button
type="button"
key={category.id}
onClick={() => {
setSelectedCanvasSkillCategoryId(category.id);
setAreCanvasSkillTagsExpanded(false);
}}
className={cn(
"inline-flex h-8 max-w-full items-center justify-center rounded-full border px-3 text-[12px] font-medium transition-colors",
selectedCanvasSkillCategoryId === category.id
? "border-[#1f2328] bg-[#1f2328] text-white dark:border-zinc-100 dark:bg-zinc-100 dark:text-zinc-950"
: "border-[#e5e7eb] bg-[#fafbfc] text-[#667085] hover:border-[#cfd4dc] hover:text-[#1f2328] dark:border-white/10 dark:bg-white/5 dark:text-zinc-400 dark:hover:border-white/20 dark:hover:text-zinc-100"
)}
aria-pressed={
selectedCanvasSkillCategoryId === category.id
}
>
<span className="truncate">{category.name}</span>
</button>
))}
{hasUncategorizedCanvasSkillTags ? (
<button
type="button"
onClick={() => {
setSelectedCanvasSkillCategoryId(
"__uncategorized__"
);
setAreCanvasSkillTagsExpanded(false);
}}
className={cn(
"inline-flex h-8 items-center justify-center rounded-full border px-3 text-[12px] font-medium transition-colors",
selectedCanvasSkillCategoryId ===
"__uncategorized__"
? "border-[#1f2328] bg-[#1f2328] text-white dark:border-zinc-100 dark:bg-zinc-100 dark:text-zinc-950"
: "border-[#e5e7eb] bg-[#fafbfc] text-[#667085] hover:border-[#cfd4dc] hover:text-[#1f2328] dark:border-white/10 dark:bg-white/5 dark:text-zinc-400 dark:hover:border-white/20 dark:hover:text-zinc-100"
)}
aria-pressed={
selectedCanvasSkillCategoryId ===
"__uncategorized__"
}
>
未分类
</button>
) : null}
</div>
) : null}
{canvasSkillTags.length > 4 ? (
<div className="mx-auto mt-5 flex h-9 w-full max-w-[360px] items-center gap-2 rounded-lg border border-[#e5e7eb] bg-[#fafbfc] px-3 text-left transition-colors focus-within:border-[#aeb6c5] focus-within:bg-white dark:border-white/10 dark:bg-white/5 dark:focus-within:border-white/25 dark:focus-within:bg-white/[0.07]">
<div className="mx-auto mt-4 flex h-9 w-full max-w-[360px] items-center gap-2 rounded-lg border border-[#e5e7eb] bg-[#fafbfc] px-3 text-left transition-colors focus-within:border-[#aeb6c5] focus-within:bg-white dark:border-white/10 dark:bg-white/5 dark:focus-within:border-white/25 dark:focus-within:bg-white/[0.07]">
<Search className="h-4 w-4 shrink-0 text-[#9299a6] dark:text-zinc-500" />
<input
type="text"
@ -15148,7 +15260,9 @@ export function CanvasChatPanel({
<div
className={cn(
"flex flex-wrap justify-center gap-2 transition-[max-height] duration-200 ease-out",
canvasSkillTags.length > 4 ? "mt-4" : "mt-6",
canvasSkillTags.length > 4 || hasCanvasSkillCategoryFilters
? "mt-4"
: "mt-6",
isCanvasSkillTagSearching || areCanvasSkillTagsExpanded
? "max-h-none overflow-visible"
: "max-h-[76px] overflow-hidden"
@ -15212,6 +15326,12 @@ export function CanvasChatPanel({
未找到匹配的快捷技能
</p>
) : null}
{!isCanvasSkillTagSearching &&
filteredCanvasSkillTags.length === 0 ? (
<p className="w-full py-2 text-center text-[13px] text-[#9299a6] dark:text-zinc-500">
该分类暂未配置快捷技能
</p>
) : null}
</div>
{shouldShowCanvasSkillTagsToggle ? (
<button
@ -15255,7 +15375,11 @@ export function CanvasChatPanel({
const hasAssistantVideos = assistantTextParts.some((part: any) =>
extractVideoUrls(part.text || "").length > 0
);
const hasAssistantMedia = hasAssistantImages || hasAssistantVideos;
const hasAssistantAudio = assistantTextParts.some((part: any) =>
extractAudioUrls(part.text || "").length > 0
);
const hasAssistantMedia =
hasAssistantImages || hasAssistantAudio || hasAssistantVideos;
const isAgentAutoMode = selectedModelId === "auto";
const displayAssistantModelName =
isAgentAutoMode && hasAssistantMedia ? "agent model" : assistantModelName;
@ -15544,6 +15668,7 @@ export function CanvasChatPanel({
rawText,
nearestUserImageKeys
);
const audioUrls = extractAudioUrls(rawText);
const videoUrls = extractVideoUrls(rawText);
const textWithoutImages = stripCanvasModelRoutingLines(
stripMediaMarkup(rawText)
@ -15591,6 +15716,9 @@ export function CanvasChatPanel({
const visibleVideoUrls = shouldDeferAssistantMedia
? []
: videoUrls;
const visibleAudioUrls = shouldDeferAssistantMedia
? []
: audioUrls;
const shouldShowDoneSummary =
!isDetailGenerationMode &&
!isClawAssistantMessage &&
@ -15789,7 +15917,7 @@ export function CanvasChatPanel({
</div>
)}
{visibleVideoUrls.length > 0 && (
<div className={cn("space-y-2", textWithoutImages || visibleImageUrls.length > 0 ? "mt-3" : "mt-0")}>
<div className={cn("space-y-2", textWithoutImages || visibleImageUrls.length > 0 || visibleAudioUrls.length > 0 ? "mt-3" : "mt-0")}>
{visibleVideoUrls.map((url, idx) => (
<div
key={`video-${idx}`}
@ -15798,6 +15926,8 @@ export function CanvasChatPanel({
<video
src={url}
controls
playsInline
preload="metadata"
className="block w-full rounded-xl bg-black"
style={{ maxHeight: "320px" }}
/>
@ -15813,6 +15943,42 @@ export function CanvasChatPanel({
))}
</div>
)}
{visibleAudioUrls.length > 0 && (
<div
className={cn(
"space-y-2",
textWithoutImages || visibleImageUrls.length > 0
? "mt-3"
: "mt-0"
)}
>
{visibleAudioUrls.map((url, idx) => (
<div
key={`audio-${idx}`}
className="rounded-xl border border-black/10 bg-black/[0.025] px-3 py-2.5 dark:border-white/10 dark:bg-white/5"
>
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-[#596273] dark:text-zinc-300">
<Volume2 className="h-4 w-4" />
<span>音频</span>
</div>
<audio
src={url}
controls
preload="metadata"
className="block h-10 w-full"
/>
<a
href={url}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex text-xs text-blue-600 hover:underline dark:text-blue-300"
>
打开音频
</a>
</div>
))}
</div>
)}
{openClawActionBlocks.length > 0 ? (
<OpenClawQuickActions
blocks={openClawActionBlocks}

View File

@ -327,36 +327,165 @@ export function filterStandaloneAssistantImageUrls(
});
}
export function extractVideoUrls(text: string): string[] {
const AUDIO_MEDIA_EXTENSIONS = new Set([
"aac",
"flac",
"m4a",
"mp3",
"oga",
"ogg",
"opus",
"wav",
]);
const VIDEO_MEDIA_EXTENSIONS = new Set([
"m3u8",
"m4v",
"mov",
"mp4",
"ogv",
"webm",
]);
type ChatMediaKind = "audio" | "video";
function sanitizeExtractedMediaUrl(value: string): string {
return String(value || "")
.trim()
.replace(/&amp;/gi, "&")
.replace(/[),.;!?,。;!?]+$/u, "");
}
function getChatMediaUrlExtension(value: string): string {
const raw = sanitizeExtractedMediaUrl(value);
if (!raw) return "";
try {
const parsed = new URL(raw);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
const pathnameExtension = parsed.pathname.match(/\.([a-z0-9]+)$/i)?.[1];
if (pathnameExtension) return pathnameExtension.toLowerCase();
for (const key of ["format", "ext", "type", "filename", "file", "name"]) {
const candidate = parsed.searchParams.get(key) || "";
const queryExtension = candidate.match(/(?:^|\.)([a-z0-9]+)$/i)?.[1];
if (queryExtension) return queryExtension.toLowerCase();
}
} catch {
return "";
}
return "";
}
function isChatMediaUrl(value: string, kind: ChatMediaKind): boolean {
const extension = getChatMediaUrlExtension(value);
return kind === "audio"
? AUDIO_MEDIA_EXTENSIONS.has(extension)
: VIDEO_MEDIA_EXTENSIONS.has(extension);
}
function extractChatMediaUrls(text: string, kind: ChatMediaKind): string[] {
if (!text) return [];
const urls = new Set<string>();
const urls = new Map<string, string>();
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 = /<video[^>]+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 = /<source\b[^>]*\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(/<video[\s\S]*?<\/video>/gi, "")
.replace(/<video\b[^>]*\/?\s*>/gi, "")
.replace(/<audio[\s\S]*?<\/audio>/gi, "")
.replace(/<audio\b[^>]*\/?\s*>/gi, "")
.replace(/<a[\s\S]*?<\/a>/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();
}
@ -399,6 +528,7 @@ export function messageHasAssistantMedia(
const text = String(part.text || "");
return (
extractAssistantImageUrls(text, referenceUrlKeys).length > 0 ||
extractAudioUrls(text).length > 0 ||
extractVideoUrls(text).length > 0
);
});

View File

@ -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。

View File

@ -215,6 +215,8 @@ Authorization: Bearer YOUR_API_KEY
2. 第三方不要传模型名,交给服务端按默认优先级和故障迁移处理
3. 将返回的 `requestId` 保存到业务日志,便于双方排查问题
4. 返回的图片地址已经是我们系统储存桶地址,可以直接展示或下载
5. 服务端会按模型错峰发起并发请求,不需要调用方等待上一张图片生成完成后再提交;请将 HTTP 请求超时设置为至少 `1200` 秒
6. `400` 且提示“至少上传 1 张参考图”表示请求没有成功传入图片文件或图片 URL;`499` 通常表示调用方在结果返回前主动断开了连接
## 8. 后台能力

View File

@ -1,11 +1,17 @@
export const CANVAS_SKILL_TAGS_SETTING_KEY = "canvas_skill_tags";
export const CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX = "canvas_skill_tag_order:";
export const CANVAS_SKILL_TAGS_CONFIG_VERSION = 5;
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 DEFAULT_CANVAS_SKILL_TAGS: CanvasSkillTag[] = [
@ -195,6 +202,37 @@ function normalizeId(value: unknown, fallbackIndex: number) {
return id || `tag-${fallbackIndex + 1}`;
}
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<string>();
const normalized: CanvasSkillCategory[] = [];
value.forEach((item, index) => {
const row = item && typeof item === "object" ? (item as Record<string, unknown>) : {};
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<string>();
@ -207,6 +245,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);
@ -221,6 +260,7 @@ export function normalizeCanvasSkillTags(value: unknown): CanvasSkillTag[] {
placeholder: placeholder || undefined,
iconName: iconName || undefined,
iconUrl: iconUrl || undefined,
categoryId: categoryId || undefined,
});
});
@ -239,6 +279,7 @@ export function normalizeCanvasSkillTagSettings(
return {
enabled: true,
title: DEFAULT_CANVAS_SKILL_TAGS_TITLE,
categories: [],
tags: normalizeCanvasSkillTags(value),
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
};
@ -247,12 +288,20 @@ export function normalizeCanvasSkillTagSettings(
if (value && typeof value === "object") {
const row = value as Record<string, unknown>;
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;
return {
enabled: row.enabled !== false,
title,
tags: normalizeCanvasSkillTags(row.tags),
categories,
tags,
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
};
}
@ -261,7 +310,8 @@ export function normalizeCanvasSkillTagSettings(
return {
enabled: row.enabled !== false,
title,
tags: normalizeCanvasSkillTags(row.tags),
categories,
tags,
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
};
}
@ -269,6 +319,7 @@ export function normalizeCanvasSkillTagSettings(
return {
enabled: true,
title: DEFAULT_CANVAS_SKILL_TAGS_TITLE,
categories: [],
tags: normalizeCanvasSkillTags(null),
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
};

View File

@ -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,
@ -1014,6 +1019,7 @@ export async function generateImageWithModel(params: {
export async function generateImagesWithFailover(params: {
candidates: ImageModelEntry[];
ownerKey?: string;
prompt: string;
negativePrompt?: string | null;
imageCount: number;
@ -1029,16 +1035,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<string>;
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,
@ -1076,6 +1101,11 @@ export async function generateImagesWithFailover(params: {
success: false,
error: error instanceof Error ? error.message : "图片生成失败",
});
} finally {
recordCanvasImageModelExecutionDuration(
modelKey,
Date.now() - executionStartedAt,
);
}
}

View File

@ -22,7 +22,9 @@ export type AdminOpenClawChatConfig = Omit<OpenClawChatConfig, "token"> & {
const DEFAULT_TIMEOUT_MS = 180_000;
const MIN_TIMEOUT_MS = 5_000;
const MAX_TIMEOUT_MS = 300_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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)

View File

@ -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";
@ -47,3 +49,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;
}

View File

@ -47,6 +47,7 @@ export default defineConfig({
/routes\/video-subtitle-erase-tools\.test\.ts/,
/routes\/home-sidebar-auth\.test\.ts/,
/routes\/openclaw-client-errors\.test\.ts/,
/routes\/openclaw-config\.test\.ts/,
/routes\/openclaw-durable-task-state\.test\.ts/,
/routes\/openclaw-request-state\.test\.ts/,
/routes\/openclaw-video-attachments\.test\.ts/,

View File

@ -196,4 +196,37 @@ test.describe.serial("/api/admin/settings", () => {
expect(normalized.tags).toEqual(DEFAULT_CANVAS_SKILL_TAGS);
});
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();
});
});

View File

@ -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)",
'<audio controls><source src="https://cdn.example.com/voice.m4a?sig=2"></audio>',
"[播放视频](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",

View File

@ -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";
@ -56,4 +58,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
);
});
});

View File

@ -0,0 +1,23 @@
import { expect, test } from "@playwright/test";
import { normalizeOpenClawChatConfig } from "@/lib/openclaw-chat/config";
test.describe("OpenClaw chat configuration", () => {
test("allows a thirty-minute timeout for complex skills", () => {
expect(
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);
});
});