feat: improve Claw workflows and reliability
This commit is contained in:
parent
f1b769490a
commit
54cd02d09d
@ -198,9 +198,11 @@ import {
|
|||||||
normalizeCanvasImageTaskQueueConfig,
|
normalizeCanvasImageTaskQueueConfig,
|
||||||
} from "@/lib/canvas/image-task-config";
|
} from "@/lib/canvas/image-task-config";
|
||||||
import {
|
import {
|
||||||
|
type CanvasSkillCategory,
|
||||||
type CanvasSkillTag,
|
type CanvasSkillTag,
|
||||||
DEFAULT_CANVAS_SKILL_TAGS,
|
DEFAULT_CANVAS_SKILL_TAGS,
|
||||||
DEFAULT_CANVAS_SKILL_TAGS_TITLE,
|
DEFAULT_CANVAS_SKILL_TAGS_TITLE,
|
||||||
|
normalizeCanvasSkillCategories,
|
||||||
normalizeCanvasSkillTags,
|
normalizeCanvasSkillTags,
|
||||||
} from "@/lib/canvas-skill-tags";
|
} from "@/lib/canvas-skill-tags";
|
||||||
import {
|
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 {
|
function createEmptyEnhanceProPreset(): EnhanceProPreset {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
return {
|
return {
|
||||||
@ -3404,6 +3413,9 @@ export function AdminSettingsClient() {
|
|||||||
DEFAULT_CANVAS_SKILL_TAGS_TITLE
|
DEFAULT_CANVAS_SKILL_TAGS_TITLE
|
||||||
);
|
);
|
||||||
const [canvasSkillTagsEnabled, setCanvasSkillTagsEnabled] = useState(true);
|
const [canvasSkillTagsEnabled, setCanvasSkillTagsEnabled] = useState(true);
|
||||||
|
const [canvasSkillCategories, setCanvasSkillCategories] = useState<
|
||||||
|
CanvasSkillCategory[]
|
||||||
|
>([]);
|
||||||
const [canvasSkillTags, setCanvasSkillTags] = useState<CanvasSkillTag[]>(
|
const [canvasSkillTags, setCanvasSkillTags] = useState<CanvasSkillTag[]>(
|
||||||
DEFAULT_CANVAS_SKILL_TAGS
|
DEFAULT_CANVAS_SKILL_TAGS
|
||||||
);
|
);
|
||||||
@ -3421,6 +3433,7 @@ export function AdminSettingsClient() {
|
|||||||
const { data: canvasSkillTagsData, mutate: mutateCanvasSkillTags } = useSWR<{
|
const { data: canvasSkillTagsData, mutate: mutateCanvasSkillTags } = useSWR<{
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
categories?: CanvasSkillCategory[];
|
||||||
tags?: CanvasSkillTag[];
|
tags?: CanvasSkillTag[];
|
||||||
}>("/api/canvas-skill-tags", fetcher);
|
}>("/api/canvas-skill-tags", fetcher);
|
||||||
|
|
||||||
@ -3647,6 +3660,9 @@ export function AdminSettingsClient() {
|
|||||||
String(canvasSkillTagsData.title || "").trim() ||
|
String(canvasSkillTagsData.title || "").trim() ||
|
||||||
DEFAULT_CANVAS_SKILL_TAGS_TITLE
|
DEFAULT_CANVAS_SKILL_TAGS_TITLE
|
||||||
);
|
);
|
||||||
|
setCanvasSkillCategories(
|
||||||
|
normalizeCanvasSkillCategories(canvasSkillTagsData.categories)
|
||||||
|
);
|
||||||
setCanvasSkillTags(normalizeCanvasSkillTags(canvasSkillTagsData.tags));
|
setCanvasSkillTags(normalizeCanvasSkillTags(canvasSkillTagsData.tags));
|
||||||
}
|
}
|
||||||
}, [canvasSkillTagsData]);
|
}, [canvasSkillTagsData]);
|
||||||
@ -3683,7 +3699,13 @@ export function AdminSettingsClient() {
|
|||||||
|
|
||||||
const handleCanvasSkillTagChange = (
|
const handleCanvasSkillTagChange = (
|
||||||
index: number,
|
index: number,
|
||||||
field: "name" | "prompt" | "placeholder" | "iconName" | "iconUrl",
|
field:
|
||||||
|
| "name"
|
||||||
|
| "prompt"
|
||||||
|
| "placeholder"
|
||||||
|
| "iconName"
|
||||||
|
| "iconUrl"
|
||||||
|
| "categoryId",
|
||||||
value: string
|
value: string
|
||||||
) => {
|
) => {
|
||||||
setCanvasSkillTags((prev) =>
|
setCanvasSkillTags((prev) =>
|
||||||
@ -3936,6 +3958,36 @@ export function AdminSettingsClient() {
|
|||||||
setCanvasSkillTags((prev) => [...prev, createEmptyCanvasSkillTag()]);
|
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) => {
|
const handleRemoveCanvasSkillTag = (index: number) => {
|
||||||
setCanvasSkillTags((prev) =>
|
setCanvasSkillTags((prev) =>
|
||||||
prev.filter((_, itemIndex) => itemIndex !== index)
|
prev.filter((_, itemIndex) => itemIndex !== index)
|
||||||
@ -3944,6 +3996,7 @@ export function AdminSettingsClient() {
|
|||||||
|
|
||||||
const handleSaveCanvasSkillTags = async () => {
|
const handleSaveCanvasSkillTags = async () => {
|
||||||
try {
|
try {
|
||||||
|
const categories = normalizeCanvasSkillCategories(canvasSkillCategories);
|
||||||
const payload = normalizeCanvasSkillTags(canvasSkillTags);
|
const payload = normalizeCanvasSkillTags(canvasSkillTags);
|
||||||
const res = await fetch("/api/canvas-skill-tags", {
|
const res = await fetch("/api/canvas-skill-tags", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@ -3951,6 +4004,7 @@ export function AdminSettingsClient() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
enabled: canvasSkillTagsEnabled,
|
enabled: canvasSkillTagsEnabled,
|
||||||
title: canvasSkillTagsTitle,
|
title: canvasSkillTagsTitle,
|
||||||
|
categories,
|
||||||
tags: payload,
|
tags: payload,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@ -3962,6 +4016,9 @@ export function AdminSettingsClient() {
|
|||||||
String(data?.title || canvasSkillTagsTitle || "").trim() ||
|
String(data?.title || canvasSkillTagsTitle || "").trim() ||
|
||||||
DEFAULT_CANVAS_SKILL_TAGS_TITLE
|
DEFAULT_CANVAS_SKILL_TAGS_TITLE
|
||||||
);
|
);
|
||||||
|
setCanvasSkillCategories(
|
||||||
|
normalizeCanvasSkillCategories(data?.categories ?? categories)
|
||||||
|
);
|
||||||
setCanvasSkillTags(normalizeCanvasSkillTags(data?.tags ?? payload));
|
setCanvasSkillTags(normalizeCanvasSkillTags(data?.tags ?? payload));
|
||||||
toast.success("快捷标签已更新");
|
toast.success("快捷标签已更新");
|
||||||
mutateCanvasSkillTags();
|
mutateCanvasSkillTags();
|
||||||
@ -14782,6 +14839,61 @@ export function AdminSettingsClient() {
|
|||||||
value={canvasSkillTagsTitle}
|
value={canvasSkillTagsTitle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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) => {
|
{canvasSkillTags.map((tag, index) => {
|
||||||
const TagIcon = getCanvasSkillIconComponent(tag.iconName);
|
const TagIcon = getCanvasSkillIconComponent(tag.iconName);
|
||||||
return (
|
return (
|
||||||
@ -14789,7 +14901,7 @@ export function AdminSettingsClient() {
|
|||||||
className="space-y-2 rounded-lg border p-3"
|
className="space-y-2 rounded-lg border p-3"
|
||||||
key={tag.id || index}
|
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
|
<Input
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
handleCanvasSkillTagChange(
|
handleCanvasSkillTagChange(
|
||||||
@ -14801,7 +14913,32 @@ export function AdminSettingsClient() {
|
|||||||
placeholder={`标签名称 ${index + 1}`}
|
placeholder={`标签名称 ${index + 1}`}
|
||||||
value={tag.name}
|
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
|
<Button
|
||||||
|
className="self-end md:self-auto"
|
||||||
disabled={canvasSkillTags.length <= 1}
|
disabled={canvasSkillTags.length <= 1}
|
||||||
onClick={() => handleRemoveCanvasSkillTag(index)}
|
onClick={() => handleRemoveCanvasSkillTag(index)}
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@ -140,10 +140,12 @@ export async function PUT(request: NextRequest) {
|
|||||||
const normalized = normalizeCanvasSkillTagSettings(body);
|
const normalized = normalizeCanvasSkillTagSettings(body);
|
||||||
const enabled = normalized.enabled;
|
const enabled = normalized.enabled;
|
||||||
const title = normalized.title;
|
const title = normalized.title;
|
||||||
|
const categories = normalized.categories;
|
||||||
const tags = normalized.tags;
|
const tags = normalized.tags;
|
||||||
const nextValue = {
|
const nextValue = {
|
||||||
enabled,
|
enabled,
|
||||||
title,
|
title,
|
||||||
|
categories,
|
||||||
tags,
|
tags,
|
||||||
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
|
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) {
|
} catch (error) {
|
||||||
console.error("[CanvasSkillTags API] Update error:", error);
|
console.error("[CanvasSkillTags API] Update error:", error);
|
||||||
return NextResponse.json({ error: "Failed to update" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to update" }, { status: 500 });
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
OPENCLAW_CHAT_CONFIG_KEY,
|
OPENCLAW_CHAT_CONFIG_KEY,
|
||||||
} from "@/lib/openclaw-chat/config";
|
} from "@/lib/openclaw-chat/config";
|
||||||
import {
|
import {
|
||||||
|
getOpenClawTaskFailureMessage,
|
||||||
OPENCLAW_AUTH_REQUIRED_CODE,
|
OPENCLAW_AUTH_REQUIRED_CODE,
|
||||||
OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||||
OPENCLAW_UPGRADE_RETRY_MESSAGE,
|
OPENCLAW_UPGRADE_RETRY_MESSAGE,
|
||||||
@ -46,7 +47,7 @@ import { generateUUID } from "@/lib/utils";
|
|||||||
const DEFAULT_PROCESSING_HINT =
|
const DEFAULT_PROCESSING_HINT =
|
||||||
"已收到,这个任务稍微繁重一些,处理时间可能会长一点,请耐心等候。";
|
"已收到,这个任务稍微繁重一些,处理时间可能会长一点,请耐心等候。";
|
||||||
|
|
||||||
export const maxDuration = 600;
|
export const maxDuration = 30 * 60;
|
||||||
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
@ -598,7 +599,7 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) {
|
|||||||
await markClawdTaskFailed({
|
await markClawdTaskFailed({
|
||||||
chatId: params.chatId,
|
chatId: params.chatId,
|
||||||
assistantMessageId: params.assistantMessageId,
|
assistantMessageId: params.assistantMessageId,
|
||||||
error: OPENCLAW_UPGRADE_RETRY_MESSAGE,
|
error: getOpenClawTaskFailureMessage(error),
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|||||||
119
app/api/external/image/generate/route.ts
vendored
119
app/api/external/image/generate/route.ts
vendored
@ -21,103 +21,6 @@ import {
|
|||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 1200;
|
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({
|
const requestSchema = z.object({
|
||||||
prompt: z.string().trim().min(1, "prompt 不能为空"),
|
prompt: z.string().trim().min(1, "prompt 不能为空"),
|
||||||
negativePrompt: z.string().trim().optional(),
|
negativePrompt: z.string().trim().optional(),
|
||||||
@ -294,10 +197,8 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let logId: string | null = null;
|
let logId: string | null = null;
|
||||||
let releaseExternalImageApiSlot: (() => void) | null = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
releaseExternalImageApiSlot = await acquireExternalImageApiSlot();
|
|
||||||
const payload = await parseRequestPayload(request);
|
const payload = await parseRequestPayload(request);
|
||||||
const imageSize = normalizeExternalImageApiImageSize(payload.imageSize);
|
const imageSize = normalizeExternalImageApiImageSize(payload.imageSize);
|
||||||
const candidateModels = await buildExternalImageApiModelCandidates({
|
const candidateModels = await buildExternalImageApiModelCandidates({
|
||||||
@ -344,6 +245,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const generationResult = await generateImagesWithFailover({
|
const generationResult = await generateImagesWithFailover({
|
||||||
candidates: candidateModels,
|
candidates: candidateModels,
|
||||||
|
ownerKey: `external-image-api:${authResult.accessKey.id}`,
|
||||||
prompt: payload.prompt,
|
prompt: payload.prompt,
|
||||||
negativePrompt: payload.negativePrompt || null,
|
negativePrompt: payload.negativePrompt || null,
|
||||||
imageCount: payload.imageCount || 1,
|
imageCount: payload.imageCount || 1,
|
||||||
@ -467,21 +369,6 @@ export async function POST(request: Request) {
|
|||||||
.where(eq(externalImageApiRequestLog.id, logId));
|
.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(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
@ -489,9 +376,5 @@ export async function POST(request: Request) {
|
|||||||
},
|
},
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
if (releaseExternalImageApiSlot) {
|
|
||||||
releaseExternalImageApiSlot();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,7 @@ import {
|
|||||||
type UiSurfaceSnapshot,
|
type UiSurfaceSnapshot,
|
||||||
} from "@/lib/ui-surface-protocol";
|
} from "@/lib/ui-surface-protocol";
|
||||||
import useSWR, { useSWRConfig } from "swr";
|
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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
@ -98,7 +98,9 @@ import type {
|
|||||||
import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch";
|
import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CANVAS_SKILL_TAGS_TITLE,
|
DEFAULT_CANVAS_SKILL_TAGS_TITLE,
|
||||||
|
normalizeCanvasSkillCategories,
|
||||||
normalizeCanvasSkillTags,
|
normalizeCanvasSkillTags,
|
||||||
|
type CanvasSkillCategory,
|
||||||
type CanvasSkillTag,
|
type CanvasSkillTag,
|
||||||
} from "@/lib/canvas-skill-tags";
|
} from "@/lib/canvas-skill-tags";
|
||||||
import {
|
import {
|
||||||
@ -278,6 +280,7 @@ import {
|
|||||||
buildCanvasHistoryMessagesSignature,
|
buildCanvasHistoryMessagesSignature,
|
||||||
collectMessageImageAttachments,
|
collectMessageImageAttachments,
|
||||||
extractAssistantImageUrls,
|
extractAssistantImageUrls,
|
||||||
|
extractAudioUrls,
|
||||||
extractCanvasModelName,
|
extractCanvasModelName,
|
||||||
extractImageUrls,
|
extractImageUrls,
|
||||||
extractVideoUrls,
|
extractVideoUrls,
|
||||||
@ -2883,6 +2886,8 @@ export function CanvasChatPanel({
|
|||||||
const [selectedSkillTagId, setSelectedSkillTagId] = useState<string | null>(null);
|
const [selectedSkillTagId, setSelectedSkillTagId] = useState<string | null>(null);
|
||||||
const [areCanvasSkillTagsExpanded, setAreCanvasSkillTagsExpanded] = useState(false);
|
const [areCanvasSkillTagsExpanded, setAreCanvasSkillTagsExpanded] = useState(false);
|
||||||
const [canvasSkillTagSearchQuery, setCanvasSkillTagSearchQuery] = useState("");
|
const [canvasSkillTagSearchQuery, setCanvasSkillTagSearchQuery] = useState("");
|
||||||
|
const [selectedCanvasSkillCategoryId, setSelectedCanvasSkillCategoryId] =
|
||||||
|
useState("all");
|
||||||
const [draggingSkillTagId, setDraggingSkillTagId] = useState<string | null>(null);
|
const [draggingSkillTagId, setDraggingSkillTagId] = useState<string | null>(null);
|
||||||
const [canvasSkillTagOrderOverride, setCanvasSkillTagOrderOverride] = useState<string[] | null>(
|
const [canvasSkillTagOrderOverride, setCanvasSkillTagOrderOverride] = useState<string[] | null>(
|
||||||
null
|
null
|
||||||
@ -4926,6 +4931,7 @@ export function CanvasChatPanel({
|
|||||||
const { data: canvasSkillTagsData } = useSWR<{
|
const { data: canvasSkillTagsData } = useSWR<{
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
categories?: CanvasSkillCategory[];
|
||||||
tags?: CanvasSkillTag[];
|
tags?: CanvasSkillTag[];
|
||||||
userOrder?: string[];
|
userOrder?: string[];
|
||||||
}>(
|
}>(
|
||||||
@ -5112,6 +5118,10 @@ export function CanvasChatPanel({
|
|||||||
const canvasSkillTagsEnabled = canvasSkillTagsData?.enabled !== false;
|
const canvasSkillTagsEnabled = canvasSkillTagsData?.enabled !== false;
|
||||||
const canvasSkillTagsTitle =
|
const canvasSkillTagsTitle =
|
||||||
String(canvasSkillTagsData?.title || "").trim() || DEFAULT_CANVAS_SKILL_TAGS_TITLE;
|
String(canvasSkillTagsData?.title || "").trim() || DEFAULT_CANVAS_SKILL_TAGS_TITLE;
|
||||||
|
const canvasSkillCategories = useMemo(
|
||||||
|
() => normalizeCanvasSkillCategories(canvasSkillTagsData?.categories),
|
||||||
|
[canvasSkillTagsData?.categories]
|
||||||
|
);
|
||||||
const baseCanvasSkillTags = useMemo(
|
const baseCanvasSkillTags = useMemo(
|
||||||
() => (canvasSkillTagsEnabled ? normalizeCanvasSkillTags(canvasSkillTagsData?.tags) : []),
|
() => (canvasSkillTagsEnabled ? normalizeCanvasSkillTags(canvasSkillTagsData?.tags) : []),
|
||||||
[canvasSkillTagsData?.tags, canvasSkillTagsEnabled]
|
[canvasSkillTagsData?.tags, canvasSkillTagsEnabled]
|
||||||
@ -5126,13 +5136,26 @@ export function CanvasChatPanel({
|
|||||||
const normalizedCanvasSkillTagSearchQuery = canvasSkillTagSearchQuery
|
const normalizedCanvasSkillTagSearchQuery = canvasSkillTagSearchQuery
|
||||||
.trim()
|
.trim()
|
||||||
.toLocaleLowerCase();
|
.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(() => {
|
const filteredCanvasSkillTags = useMemo(() => {
|
||||||
if (!normalizedCanvasSkillTagSearchQuery) {
|
if (!normalizedCanvasSkillTagSearchQuery) {
|
||||||
return canvasSkillTags;
|
return categoryFilteredCanvasSkillTags;
|
||||||
}
|
}
|
||||||
|
|
||||||
const keywords = normalizedCanvasSkillTagSearchQuery.split(/\s+/).filter(Boolean);
|
const keywords = normalizedCanvasSkillTagSearchQuery.split(/\s+/).filter(Boolean);
|
||||||
return canvasSkillTags.filter((tag) => {
|
return categoryFilteredCanvasSkillTags.filter((tag) => {
|
||||||
const searchableText = [tag.name, tag.prompt, tag.placeholder]
|
const searchableText = [tag.name, tag.prompt, tag.placeholder]
|
||||||
.map((value) => String(value || "").trim())
|
.map((value) => String(value || "").trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@ -5140,8 +5163,29 @@ export function CanvasChatPanel({
|
|||||||
.toLocaleLowerCase();
|
.toLocaleLowerCase();
|
||||||
return keywords.every((keyword) => searchableText.includes(keyword));
|
return keywords.every((keyword) => searchableText.includes(keyword));
|
||||||
});
|
});
|
||||||
}, [canvasSkillTags, normalizedCanvasSkillTagSearchQuery]);
|
}, [categoryFilteredCanvasSkillTags, normalizedCanvasSkillTagSearchQuery]);
|
||||||
const isCanvasSkillTagSearching = normalizedCanvasSkillTagSearchQuery.length > 0;
|
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(() => {
|
useEffect(() => {
|
||||||
latestCanvasSkillTagsRef.current = canvasSkillTags;
|
latestCanvasSkillTagsRef.current = canvasSkillTags;
|
||||||
}, [canvasSkillTags]);
|
}, [canvasSkillTags]);
|
||||||
@ -5201,7 +5245,7 @@ export function CanvasChatPanel({
|
|||||||
[canvasSkillTags, selectedSkillTagId]
|
[canvasSkillTags, selectedSkillTagId]
|
||||||
);
|
);
|
||||||
const shouldShowCanvasSkillTagsToggle =
|
const shouldShowCanvasSkillTagsToggle =
|
||||||
!isCanvasSkillTagSearching && canvasSkillTags.length > 4;
|
!isCanvasSkillTagSearching && categoryFilteredCanvasSkillTags.length > 4;
|
||||||
const activeChatSession = useMemo(
|
const activeChatSession = useMemo(
|
||||||
() => chatSessions.find((session) => session.id === activeChatId) || null,
|
() => chatSessions.find((session) => session.id === activeChatId) || null,
|
||||||
[activeChatId, chatSessions]
|
[activeChatId, chatSessions]
|
||||||
@ -14937,8 +14981,76 @@ export function CanvasChatPanel({
|
|||||||
<h3 className="text-[14px] font-semibold leading-none text-[#1f2328] dark:text-zinc-100">
|
<h3 className="text-[14px] font-semibold leading-none text-[#1f2328] dark:text-zinc-100">
|
||||||
{canvasSkillTagsTitle}
|
{canvasSkillTagsTitle}
|
||||||
</h3>
|
</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 ? (
|
{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" />
|
<Search className="h-4 w-4 shrink-0 text-[#9299a6] dark:text-zinc-500" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -14974,6 +15086,9 @@ export function CanvasChatPanel({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-4 flex flex-wrap justify-center gap-1.5 transition-[max-height] duration-150 ease-out",
|
"mt-4 flex flex-wrap justify-center gap-1.5 transition-[max-height] duration-150 ease-out",
|
||||||
|
canvasSkillTags.length > 4 || hasCanvasSkillCategoryFilters
|
||||||
|
? "mt-4"
|
||||||
|
: "mt-6",
|
||||||
isCanvasSkillTagSearching || areCanvasSkillTagsExpanded
|
isCanvasSkillTagSearching || areCanvasSkillTagsExpanded
|
||||||
? "max-h-none overflow-visible"
|
? "max-h-none overflow-visible"
|
||||||
: "max-h-[76px] overflow-hidden"
|
: "max-h-[76px] overflow-hidden"
|
||||||
@ -15037,6 +15152,12 @@ export function CanvasChatPanel({
|
|||||||
未找到匹配的快捷技能
|
未找到匹配的快捷技能
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!isCanvasSkillTagSearching &&
|
||||||
|
filteredCanvasSkillTags.length === 0 ? (
|
||||||
|
<p className="w-full py-2 text-center text-[13px] text-[#9299a6] dark:text-zinc-500">
|
||||||
|
该分类暂未配置快捷技能
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{shouldShowCanvasSkillTagsToggle ? (
|
{shouldShowCanvasSkillTagsToggle ? (
|
||||||
<button
|
<button
|
||||||
@ -15088,7 +15209,11 @@ export function CanvasChatPanel({
|
|||||||
const hasAssistantVideos = assistantTextParts.some((part: any) =>
|
const hasAssistantVideos = assistantTextParts.some((part: any) =>
|
||||||
extractVideoUrls(part.text || "").length > 0
|
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 isAgentAutoMode = selectedModelId === "auto";
|
||||||
const displayAssistantModelName =
|
const displayAssistantModelName =
|
||||||
isAgentAutoMode && hasAssistantMedia ? "agent model" : assistantModelName;
|
isAgentAutoMode && hasAssistantMedia ? "agent model" : assistantModelName;
|
||||||
@ -15351,6 +15476,7 @@ export function CanvasChatPanel({
|
|||||||
rawText,
|
rawText,
|
||||||
nearestUserImageKeys
|
nearestUserImageKeys
|
||||||
);
|
);
|
||||||
|
const audioUrls = extractAudioUrls(rawText);
|
||||||
const videoUrls = extractVideoUrls(rawText);
|
const videoUrls = extractVideoUrls(rawText);
|
||||||
const textWithoutImages = stripCanvasModelRoutingLines(
|
const textWithoutImages = stripCanvasModelRoutingLines(
|
||||||
stripMediaMarkup(rawText)
|
stripMediaMarkup(rawText)
|
||||||
@ -15395,6 +15521,9 @@ export function CanvasChatPanel({
|
|||||||
const visibleVideoUrls = shouldDeferAssistantMedia
|
const visibleVideoUrls = shouldDeferAssistantMedia
|
||||||
? []
|
? []
|
||||||
: videoUrls;
|
: videoUrls;
|
||||||
|
const visibleAudioUrls = shouldDeferAssistantMedia
|
||||||
|
? []
|
||||||
|
: audioUrls;
|
||||||
const shouldShowDoneSummary =
|
const shouldShowDoneSummary =
|
||||||
!isDetailGenerationMode &&
|
!isDetailGenerationMode &&
|
||||||
!isClawAssistantMessage &&
|
!isClawAssistantMessage &&
|
||||||
@ -15593,7 +15722,7 @@ export function CanvasChatPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{visibleVideoUrls.length > 0 && (
|
{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) => (
|
{visibleVideoUrls.map((url, idx) => (
|
||||||
<div
|
<div
|
||||||
key={`video-${idx}`}
|
key={`video-${idx}`}
|
||||||
@ -15602,6 +15731,8 @@ export function CanvasChatPanel({
|
|||||||
<video
|
<video
|
||||||
src={url}
|
src={url}
|
||||||
controls
|
controls
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
className="block w-full rounded-xl bg-black"
|
className="block w-full rounded-xl bg-black"
|
||||||
style={{ maxHeight: "320px" }}
|
style={{ maxHeight: "320px" }}
|
||||||
/>
|
/>
|
||||||
@ -15617,6 +15748,42 @@ export function CanvasChatPanel({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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 ? (
|
{openClawActionBlocks.length > 0 ? (
|
||||||
<OpenClawQuickActions
|
<OpenClawQuickActions
|
||||||
blocks={openClawActionBlocks}
|
blocks={openClawActionBlocks}
|
||||||
|
|||||||
@ -319,36 +319,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(/&/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 [];
|
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;
|
let match: RegExpExecArray | null = null;
|
||||||
while ((match = videoTagPattern.exec(text)) !== null) {
|
while ((match = tagPattern.exec(text)) !== null) {
|
||||||
if (match[1]) urls.add(match[1]);
|
candidates.push({
|
||||||
|
forceKind: match[1]?.toLowerCase() as ChatMediaKind,
|
||||||
|
index: match.index,
|
||||||
|
value: match[2] || "",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const markdownVideoPattern = /\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/gi;
|
const containerPattern = /<(audio|video)\b[^>]*>([\s\S]*?)<\/\1>/gi;
|
||||||
while ((match = markdownVideoPattern.exec(text)) !== null) {
|
while ((match = containerPattern.exec(text)) !== null) {
|
||||||
if (match[1]) urls.add(match[1]);
|
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;
|
const markdownLinkPattern = /\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi;
|
||||||
while ((match = plainVideoPattern.exec(text)) !== null) {
|
while ((match = markdownLinkPattern.exec(text)) !== null) {
|
||||||
if (match[1]) urls.add(match[1]);
|
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 {
|
export function stripMediaMarkup(text: string): string {
|
||||||
return String(text || "")
|
return String(text || "")
|
||||||
.replace(/!\[(?:thumb)?\|\|\|[^|]+\|\|\|[^\]]*\]\([^)]+\)/g, "")
|
.replace(/!\[(?:thumb)?\|\|\|[^|]+\|\|\|[^\]]*\]\([^)]+\)/g, "")
|
||||||
.replace(/<video[\s\S]*?<\/video>/gi, "")
|
.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(/<a[\s\S]*?<\/a>/gi, "")
|
||||||
.replace(/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/gi, "")
|
.replace(/\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi, (match, label, url) => {
|
||||||
.replace(/https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?/gi, "")
|
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();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -391,6 +520,7 @@ export function messageHasAssistantMedia(
|
|||||||
const text = String(part.text || "");
|
const text = String(part.text || "");
|
||||||
return (
|
return (
|
||||||
extractAssistantImageUrls(text, referenceUrlKeys).length > 0 ||
|
extractAssistantImageUrls(text, referenceUrlKeys).length > 0 ||
|
||||||
|
extractAudioUrls(text).length > 0 ||
|
||||||
extractVideoUrls(text).length > 0
|
extractVideoUrls(text).length > 0
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
370
docs/第三方OpenClaw-MCP接入开发文档.md
Normal file
370
docs/第三方OpenClaw-MCP接入开发文档.md
Normal 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。
|
||||||
@ -215,6 +215,8 @@ Authorization: Bearer YOUR_API_KEY
|
|||||||
2. 第三方不要传模型名,交给服务端按默认优先级和故障迁移处理
|
2. 第三方不要传模型名,交给服务端按默认优先级和故障迁移处理
|
||||||
3. 将返回的 `requestId` 保存到业务日志,便于双方排查问题
|
3. 将返回的 `requestId` 保存到业务日志,便于双方排查问题
|
||||||
4. 返回的图片地址已经是我们系统储存桶地址,可以直接展示或下载
|
4. 返回的图片地址已经是我们系统储存桶地址,可以直接展示或下载
|
||||||
|
5. 服务端会按模型错峰发起并发请求,不需要调用方等待上一张图片生成完成后再提交;请将 HTTP 请求超时设置为至少 `1200` 秒
|
||||||
|
6. `400` 且提示“至少上传 1 张参考图”表示请求没有成功传入图片文件或图片 URL;`499` 通常表示调用方在结果返回前主动断开了连接
|
||||||
|
|
||||||
## 8. 后台能力
|
## 8. 后台能力
|
||||||
|
|
||||||
|
|||||||
@ -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 CANVAS_SKILL_TAGS_CONFIG_VERSION = 6;
|
||||||
export const DEFAULT_CANVAS_SKILL_TAGS_TITLE = "Claw 快捷技能";
|
export const DEFAULT_CANVAS_SKILL_TAGS_TITLE = "Claw 快捷技能";
|
||||||
|
|
||||||
|
export type CanvasSkillCategory = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CanvasSkillTagSettings = {
|
export type CanvasSkillTagSettings = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
|
categories: CanvasSkillCategory[];
|
||||||
tags: CanvasSkillTag[];
|
tags: CanvasSkillTag[];
|
||||||
version?: number;
|
version?: number;
|
||||||
};
|
};
|
||||||
@ -17,6 +23,7 @@ export type CanvasSkillTag = {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
iconName?: string;
|
iconName?: string;
|
||||||
iconUrl?: string;
|
iconUrl?: string;
|
||||||
|
categoryId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CANVAS_SKILL_TAGS_V6_ADDITIONS: CanvasSkillTag[] = [
|
export const CANVAS_SKILL_TAGS_V6_ADDITIONS: CanvasSkillTag[] = [
|
||||||
@ -228,6 +235,37 @@ function appendMissingCanvasSkillTags(
|
|||||||
].slice(0, 50);
|
].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<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[] {
|
export function normalizeCanvasSkillTags(value: unknown): CanvasSkillTag[] {
|
||||||
const source = Array.isArray(value) ? value : DEFAULT_CANVAS_SKILL_TAGS;
|
const source = Array.isArray(value) ? value : DEFAULT_CANVAS_SKILL_TAGS;
|
||||||
const usedIds = new Set<string>();
|
const usedIds = new Set<string>();
|
||||||
@ -240,6 +278,7 @@ export function normalizeCanvasSkillTags(value: unknown): CanvasSkillTag[] {
|
|||||||
const placeholder = normalizeString(row.placeholder);
|
const placeholder = normalizeString(row.placeholder);
|
||||||
const iconName = normalizeString(row.iconName);
|
const iconName = normalizeString(row.iconName);
|
||||||
const iconUrl = normalizeString(row.iconUrl);
|
const iconUrl = normalizeString(row.iconUrl);
|
||||||
|
const categoryId = normalizeOptionalId(row.categoryId);
|
||||||
if (!name || !prompt) return;
|
if (!name || !prompt) return;
|
||||||
|
|
||||||
let id = normalizeId(row.id, index);
|
let id = normalizeId(row.id, index);
|
||||||
@ -254,6 +293,7 @@ export function normalizeCanvasSkillTags(value: unknown): CanvasSkillTag[] {
|
|||||||
placeholder: placeholder || undefined,
|
placeholder: placeholder || undefined,
|
||||||
iconName: iconName || undefined,
|
iconName: iconName || undefined,
|
||||||
iconUrl: iconUrl || undefined,
|
iconUrl: iconUrl || undefined,
|
||||||
|
categoryId: categoryId || undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -276,6 +316,7 @@ export function normalizeCanvasSkillTagSettings(
|
|||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
title: DEFAULT_CANVAS_SKILL_TAGS_TITLE,
|
title: DEFAULT_CANVAS_SKILL_TAGS_TITLE,
|
||||||
|
categories: [],
|
||||||
tags,
|
tags,
|
||||||
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
|
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
|
||||||
};
|
};
|
||||||
@ -284,6 +325,13 @@ export function normalizeCanvasSkillTagSettings(
|
|||||||
if (value && typeof value === "object") {
|
if (value && typeof value === "object") {
|
||||||
const row = value as Record<string, unknown>;
|
const row = value as Record<string, unknown>;
|
||||||
const version = Number(row.version || 0);
|
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) {
|
if (options.requireCurrentVersion && version < CANVAS_SKILL_TAGS_CONFIG_VERSION) {
|
||||||
const title = normalizeString(row.title) || DEFAULT_CANVAS_SKILL_TAGS_TITLE;
|
const title = normalizeString(row.title) || DEFAULT_CANVAS_SKILL_TAGS_TITLE;
|
||||||
const tags = appendMissingCanvasSkillTags(
|
const tags = appendMissingCanvasSkillTags(
|
||||||
@ -293,6 +341,7 @@ export function normalizeCanvasSkillTagSettings(
|
|||||||
return {
|
return {
|
||||||
enabled: row.enabled !== false,
|
enabled: row.enabled !== false,
|
||||||
title,
|
title,
|
||||||
|
categories,
|
||||||
tags,
|
tags,
|
||||||
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
|
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
|
||||||
};
|
};
|
||||||
@ -302,7 +351,8 @@ export function normalizeCanvasSkillTagSettings(
|
|||||||
return {
|
return {
|
||||||
enabled: row.enabled !== false,
|
enabled: row.enabled !== false,
|
||||||
title,
|
title,
|
||||||
tags: normalizeCanvasSkillTags(row.tags),
|
categories,
|
||||||
|
tags,
|
||||||
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
|
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -310,6 +360,7 @@ export function normalizeCanvasSkillTagSettings(
|
|||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
title: DEFAULT_CANVAS_SKILL_TAGS_TITLE,
|
title: DEFAULT_CANVAS_SKILL_TAGS_TITLE,
|
||||||
|
categories: [],
|
||||||
tags: normalizeCanvasSkillTags(null),
|
tags: normalizeCanvasSkillTags(null),
|
||||||
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
|
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,6 +10,11 @@ import { db } from "@/lib/db";
|
|||||||
import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries";
|
import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries";
|
||||||
import { user } from "@/lib/db/schema";
|
import { user } from "@/lib/db/schema";
|
||||||
import { generateDummyPassword } from "@/lib/db/utils";
|
import { generateDummyPassword } from "@/lib/db/utils";
|
||||||
|
import {
|
||||||
|
acquireCanvasImageModelSlot,
|
||||||
|
normalizeCanvasImageModelKey,
|
||||||
|
recordCanvasImageModelExecutionDuration,
|
||||||
|
} from "@/lib/canvas/image-concurrency";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import {
|
import {
|
||||||
DEFAULT_EXTERNAL_IMAGE_API_OWNER_EMAIL,
|
DEFAULT_EXTERNAL_IMAGE_API_OWNER_EMAIL,
|
||||||
@ -1013,6 +1018,7 @@ export async function generateImageWithModel(params: {
|
|||||||
|
|
||||||
export async function generateImagesWithFailover(params: {
|
export async function generateImagesWithFailover(params: {
|
||||||
candidates: ImageModelEntry[];
|
candidates: ImageModelEntry[];
|
||||||
|
ownerKey?: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
negativePrompt?: string | null;
|
negativePrompt?: string | null;
|
||||||
imageCount: number;
|
imageCount: number;
|
||||||
@ -1028,8 +1034,23 @@ export async function generateImagesWithFailover(params: {
|
|||||||
const attempts: ExternalImageApiGenerateAttempt[] = [];
|
const attempts: ExternalImageApiGenerateAttempt[] = [];
|
||||||
|
|
||||||
for (const candidate of params.candidates) {
|
for (const candidate of params.candidates) {
|
||||||
|
const modelKey = normalizeCanvasImageModelKey(
|
||||||
|
candidate.model.modelId || candidate.model.id,
|
||||||
|
);
|
||||||
|
const executionStartedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
const markdown = await generateImageWithModel({
|
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,
|
modelDbId: candidate.model.id,
|
||||||
prompt: params.prompt,
|
prompt: params.prompt,
|
||||||
negativePrompt: params.negativePrompt || null,
|
negativePrompt: params.negativePrompt || null,
|
||||||
@ -1038,6 +1059,10 @@ export async function generateImagesWithFailover(params: {
|
|||||||
imageSize: params.imageSize || null,
|
imageSize: params.imageSize || null,
|
||||||
referenceImageUrls: params.referenceImageUrls || [],
|
referenceImageUrls: params.referenceImageUrls || [],
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
await slot.release();
|
||||||
|
}
|
||||||
|
const markdown = await generation;
|
||||||
|
|
||||||
const hostedMarkdown = await ensureHostedImageMarkdown(
|
const hostedMarkdown = await ensureHostedImageMarkdown(
|
||||||
markdown,
|
markdown,
|
||||||
@ -1075,6 +1100,11 @@ export async function generateImagesWithFailover(params: {
|
|||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : "图片生成失败",
|
error: error instanceof Error ? error.message : "图片生成失败",
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
recordCanvasImageModelExecutionDuration(
|
||||||
|
modelKey,
|
||||||
|
Date.now() - executionStartedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,9 +20,11 @@ export type AdminOpenClawChatConfig = Omit<OpenClawChatConfig, "token"> & {
|
|||||||
tokenPreview: string;
|
tokenPreview: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 600_000;
|
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
|
||||||
const MIN_TIMEOUT_MS = 5_000;
|
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<string, unknown> {
|
function readRecord(value: unknown): Record<string, unknown> {
|
||||||
return value && typeof value === "object" && !Array.isArray(value)
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
export const OPENCLAW_UPGRADE_RETRY_MESSAGE =
|
export const OPENCLAW_UPGRADE_RETRY_MESSAGE =
|
||||||
"claw正在升级更新稍等5分钟再试";
|
"claw正在升级更新稍等5分钟再试";
|
||||||
|
export const OPENCLAW_TIMEOUT_MESSAGE =
|
||||||
|
"任务处理时间较长,本次等待已超时。任务可能仍在后台执行,请稍后在 Claw 历史对话中查看,或重新发送。";
|
||||||
export const OPENCLAW_AUTH_REQUIRED_CODE = "AUTH_REQUIRED";
|
export const OPENCLAW_AUTH_REQUIRED_CODE = "AUTH_REQUIRED";
|
||||||
export const OPENCLAW_AUTH_REQUIRED_MESSAGE =
|
export const OPENCLAW_AUTH_REQUIRED_MESSAGE =
|
||||||
"登录状态已失效,请重新登录后使用 Claw";
|
"登录状态已失效,请重新登录后使用 Claw";
|
||||||
@ -43,3 +45,18 @@ export function isOpenClawAuthRequiredError(
|
|||||||
(code === OPENCLAW_AUTH_REQUIRED_CODE || /^Unauthorized$/i.test(error))
|
(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;
|
||||||
|
}
|
||||||
|
|||||||
@ -243,4 +243,37 @@ test.describe.serial("/api/admin/settings", () => {
|
|||||||
expect.arrayContaining(["产品生命周期总览", "新品进度"])
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test";
|
|||||||
import {
|
import {
|
||||||
collectMessageImageUrls,
|
collectMessageImageUrls,
|
||||||
extractAssistantImageUrls,
|
extractAssistantImageUrls,
|
||||||
|
extractAudioUrls,
|
||||||
extractVideoUrls,
|
extractVideoUrls,
|
||||||
filterStandaloneAssistantImageUrls,
|
filterStandaloneAssistantImageUrls,
|
||||||
getMarkdownTableImageUrlKeys,
|
getMarkdownTableImageUrlKeys,
|
||||||
@ -5215,6 +5216,31 @@ test.describe("canvas chat pure helpers", () => {
|
|||||||
expect(stripMediaMarkup(text)).toBe("生成完成\n\n\n继续优化");
|
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", () => {
|
test("builds detail review plans with clamped count and localized fallbacks", () => {
|
||||||
const plans = buildDetailReviewPlans({
|
const plans = buildDetailReviewPlans({
|
||||||
language: "zh",
|
language: "zh",
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import {
|
import {
|
||||||
getOpenClawClientErrorMessage,
|
getOpenClawClientErrorMessage,
|
||||||
|
getOpenClawTaskFailureMessage,
|
||||||
isOpenClawAuthRequiredError,
|
isOpenClawAuthRequiredError,
|
||||||
OPENCLAW_AUTH_REQUIRED_CODE,
|
OPENCLAW_AUTH_REQUIRED_CODE,
|
||||||
OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||||
OPENCLAW_FORBIDDEN_MESSAGE,
|
OPENCLAW_FORBIDDEN_MESSAGE,
|
||||||
|
OPENCLAW_TIMEOUT_MESSAGE,
|
||||||
OPENCLAW_UPGRADE_RETRY_MESSAGE,
|
OPENCLAW_UPGRADE_RETRY_MESSAGE,
|
||||||
} from "@/lib/openclaw-chat/errors";
|
} from "@/lib/openclaw-chat/errors";
|
||||||
|
|
||||||
@ -54,4 +56,20 @@ test.describe("OpenClaw client error messages", () => {
|
|||||||
OPENCLAW_UPGRADE_RETRY_MESSAGE
|
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
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,14 +1,27 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { normalizeOpenClawChatConfig } from "@/lib/openclaw-chat/config";
|
import { normalizeOpenClawChatConfig } from "@/lib/openclaw-chat/config";
|
||||||
|
|
||||||
test.describe("OpenClaw chat timeout config", () => {
|
test.describe("OpenClaw chat configuration", () => {
|
||||||
test("defaults long-running tasks to ten minutes", () => {
|
test("defaults long-running tasks to thirty minutes", () => {
|
||||||
expect(normalizeOpenClawChatConfig({}).timeoutMs).toBe(600_000);
|
expect(normalizeOpenClawChatConfig({}).timeoutMs).toBe(30 * 60 * 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("caps configured timeouts at ten minutes", () => {
|
test("allows a thirty-minute timeout for complex skills", () => {
|
||||||
expect(
|
expect(
|
||||||
normalizeOpenClawChatConfig({ timeoutMs: 900_000 }).timeoutMs
|
normalizeOpenClawChatConfig({
|
||||||
).toBe(600_000);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user