chore(repo): preserve production-48 source snapshot
Recover the existing 19 modified tracked files and 6 untracked source files from the production working tree without changing their contents. Production source HEAD: 439fd56fe120fdb9f855b50fa4df9ce2e71bb72a Production build ID: QhoygcZ4-x42PwCgCEj1d Captured: 2026-10-30; all 25 source file SHA256 values verified. This is a source recovery checkpoint; no deployment was performed.
This commit is contained in:
parent
439fd56fe1
commit
03b8a0deb0
@ -31,6 +31,7 @@ import {
|
||||
saveMessages,
|
||||
} from "@/lib/db/queries";
|
||||
import { runZcodeCanvasTask } from "@/lib/ai/zcode-agent/task";
|
||||
import { canvasAgentReplyText } from "@/lib/ai/zcode-agent/assistant-output";
|
||||
import { createZcodeModelGateway } from "@/lib/ai/zcode-agent/model-gateway";
|
||||
import { newAgentRunSnapshot, publicAgentError } from "@/lib/ai/canvas-agent-runtime/protocol";
|
||||
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
|
||||
@ -288,11 +289,11 @@ export async function POST(request: Request) {
|
||||
const textId = crypto.randomUUID();
|
||||
writer.write({ type: "start", messageId: assistantMessageId });
|
||||
writer.write({ type: "text-start", id: textId });
|
||||
writer.write({
|
||||
type: "text-delta",
|
||||
id: textId,
|
||||
delta: isVideo ? "正在按步骤处理你的视频任务。\n\n" : "正在按步骤处理你的图片任务。\n\n",
|
||||
});
|
||||
let streamedText = "";
|
||||
const emitAssistantText = (delta: string) => {
|
||||
streamedText += delta;
|
||||
writer.write({ type: "text-delta", id: textId, delta });
|
||||
};
|
||||
try {
|
||||
const videoPorts = isVideo
|
||||
? await createVideoRuntimePorts({
|
||||
@ -343,6 +344,7 @@ export async function POST(request: Request) {
|
||||
runId: run.id, snapshot, definitions: tools.definitions,
|
||||
instructions: (isVideo ? videoRuntimeSystemPrompt : canvasRuntimeSystemPrompt)(snapshot.objective, snapshot.summary, snapshot.inputs.map(input => input.text), context),
|
||||
context, gateway, signal: activeModelController.signal,
|
||||
onAssistantText: emitAssistantText,
|
||||
tools: tools.executable,
|
||||
checkpoint: async (state, stateStatus) => {
|
||||
if (leaseLost) throw new AgentRunError("任务执行已中断");
|
||||
@ -406,7 +408,7 @@ export async function POST(request: Request) {
|
||||
signal: activeModelController.signal,
|
||||
}),
|
||||
});
|
||||
const finalText = snapshot.message;
|
||||
const finalText = canvasAgentReplyText(snapshot);
|
||||
const progress = canvasAgentProgress(agentRunView({ ...run, snapshot, status }));
|
||||
writer.write({ type: "data-canvasAgentProgress", id: run.id, data: progress });
|
||||
if (!isVideo)
|
||||
@ -439,19 +441,20 @@ export async function POST(request: Request) {
|
||||
},
|
||||
],
|
||||
});
|
||||
writer.write({ type: "text-delta", id: textId, delta: finalText });
|
||||
emitAssistantText(finalText.startsWith(streamedText) ? finalText.slice(streamedText.length) : `\n\n${snapshot.message}`);
|
||||
} catch (error) {
|
||||
const text = publicAgentError(error);
|
||||
await checkpointAgentRun(run, { ...snapshot, message: text }, "needs_review").catch(
|
||||
const statusText = publicAgentError(error);
|
||||
const text = canvasAgentReplyText({ ...snapshot, message: statusText });
|
||||
await checkpointAgentRun(run, { ...snapshot, message: statusText }, "needs_review").catch(
|
||||
() => undefined,
|
||||
);
|
||||
const progress = canvasAgentProgress(agentRunView({ ...run, snapshot: { ...snapshot, message: text }, status: "needs_review" }));
|
||||
const progress = canvasAgentProgress(agentRunView({ ...run, snapshot: { ...snapshot, message: statusText }, status: "needs_review" }));
|
||||
writer.write({ type: "data-canvasAgentProgress", id: run.id, data: progress });
|
||||
await saveMessages({ messages: [{ id: assistantMessageId, chatId: body.id, role: "assistant",
|
||||
parts: [{ type: "text", text }, { type: "data-canvasAgentProgress", data: progress }],
|
||||
attachments: [], createdAt: new Date(), videoStorageStatus: null, temporaryVideoUrl: null,
|
||||
permanentVideoUrl: null, archivedAt: null, videoMetadata: null }] }).catch(() => undefined);
|
||||
writer.write({ type: "text-delta", id: textId, delta: text });
|
||||
emitAssistantText(text.startsWith(streamedText) ? text.slice(streamedText.length) : `\n\n${statusText}`);
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
writer.write({ type: "text-end", id: textId });
|
||||
|
||||
@ -8932,6 +8932,21 @@ export function CanvasChatPanel({
|
||||
enabled: effectiveAgentEnabled && !isVideoWorkspace && !isWorkspacePresentation && activeComposerMode === "agent" && isValidChatUuid(rawChatId) && isValidChatUuid(canvasId),
|
||||
chatId: rawChatId, projectId: canvasId, busy: status === "submitted" || status === "streaming",
|
||||
onResults: (projectId, nodeIds) => { void refreshCanvasAfterAgentOperation({ projectId, createdNodeIds: nodeIds }); },
|
||||
onRunChange: (run) => {
|
||||
// The live stream owns its message; polling only recovers disconnected replies.
|
||||
if (status === "submitted" || status === "streaming") return;
|
||||
if (!run?.assistantMessageId || !run.assistantText || run.chatId !== activeChatIdRef.current) return;
|
||||
// Recover the same assistant reply after a stream disconnect or refresh.
|
||||
setMessages(current => {
|
||||
const index = current.findIndex(message => message.id === run.assistantMessageId);
|
||||
if (index < 0) return [...current, { id: run.assistantMessageId!, role: "assistant", parts: [{ type: "text", text: run.assistantText! }] }];
|
||||
const oldText = current[index].parts.filter(part => part.type === "text").map(part => part.text).join("");
|
||||
if (oldText.startsWith(run.assistantText!)) return current;
|
||||
return current.map((message, i) => i === index ? { ...message, parts: [
|
||||
{ type: "text" as const, text: run.assistantText! }, ...message.parts.filter(part => part.type !== "text"),
|
||||
] } : message);
|
||||
});
|
||||
},
|
||||
});
|
||||
const activeCanvasAgentRun = canvasAgentTask.run;
|
||||
const isCanvasAgentRunning = activeCanvasAgentRun?.status === "running";
|
||||
|
||||
@ -821,28 +821,28 @@ const STARTER_CARDS = [
|
||||
title: "故事脚本生成",
|
||||
subtitle: "脚本到分镜",
|
||||
coverUrl:
|
||||
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-story-script-cover.png",
|
||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-story-script-cover.png",
|
||||
steps: ["故事脚本", "多模态文本模型Pro", "可灵 3.0", "预览"],
|
||||
},
|
||||
{
|
||||
title: "角色三视图",
|
||||
subtitle: "角色资产沉淀",
|
||||
coverUrl:
|
||||
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png",
|
||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-character-three-view-cover.png",
|
||||
steps: ["角色描述", "角色三视图", "全能图片模型V2", "保存"],
|
||||
},
|
||||
{
|
||||
title: "全能参考生视频",
|
||||
subtitle: "参考图到成片",
|
||||
coverUrl:
|
||||
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png",
|
||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-first-frame-video-cover.png",
|
||||
steps: ["上传图片", "首帧图", "可灵 3.0", "预览"],
|
||||
},
|
||||
{
|
||||
title: "音频生视频",
|
||||
subtitle: "音频驱动画面",
|
||||
coverUrl:
|
||||
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-audio-to-video-cover.png",
|
||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-audio-to-video-cover.png",
|
||||
icon: "music",
|
||||
steps: ["上传音频", "音频主视频", "Wan 2.6", "预览"],
|
||||
},
|
||||
@ -865,7 +865,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
|
||||
badge: "预设",
|
||||
category: "周星驰经典名场面",
|
||||
cover:
|
||||
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png",
|
||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-first-frame-video-cover.png",
|
||||
description: "左弧滑行,镜头从人物左侧弧线推进,带轻微喜剧节奏和夸张表情停顿。",
|
||||
duration: "5s",
|
||||
id: "left-arc-slide",
|
||||
@ -880,7 +880,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
|
||||
badge: "商用",
|
||||
category: "电商展示",
|
||||
cover:
|
||||
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png",
|
||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-character-three-view-cover.png",
|
||||
description: "电商手机弹出效果,产品从画面中心弹出并旋转定格,适合主图视频开场。",
|
||||
duration: "5s",
|
||||
id: "phone-pop-product",
|
||||
@ -895,7 +895,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
|
||||
badge: "商用",
|
||||
category: "电商展示",
|
||||
cover:
|
||||
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-story-script-cover.png",
|
||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-story-script-cover.png",
|
||||
description: "咖啡杯出场,杯身从桌面滑入,蒸汽和柔光形成温暖的品牌氛围。",
|
||||
duration: "5s",
|
||||
id: "coffee-cup-entry",
|
||||
@ -910,7 +910,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
|
||||
badge: "热门",
|
||||
category: "镜头运动",
|
||||
cover:
|
||||
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-audio-to-video-cover.png",
|
||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-audio-to-video-cover.png",
|
||||
description: "360旋转展示,围绕主体做一圈稳定环绕,适合产品、人物和空间展示。",
|
||||
duration: "6s",
|
||||
id: "orbit-360-showcase",
|
||||
@ -924,7 +924,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
|
||||
author: "工业镜头",
|
||||
category: "镜头运动",
|
||||
cover:
|
||||
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-image.png",
|
||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/character-image.png",
|
||||
description: "机械臂视角,以第一视角穿过机械臂与产品线,增强科技感和空间纵深。",
|
||||
duration: "5s",
|
||||
id: "robot-arm-pov",
|
||||
@ -939,7 +939,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
|
||||
badge: "NEW",
|
||||
category: "人物动画",
|
||||
cover:
|
||||
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-three-view.png",
|
||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/character-three-view.png",
|
||||
description: "Live 2D,让单张角色图产生眨眼、微笑和轻微身体摆动,适合口播开场。",
|
||||
duration: "4s",
|
||||
id: "live-2d-character",
|
||||
@ -953,7 +953,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
|
||||
author: "电影运镜",
|
||||
category: "镜头运动",
|
||||
cover:
|
||||
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png",
|
||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-first-frame-video-cover.png",
|
||||
description: "瞳孔拉近,镜头快速推向眼部并在反光里展示下一场景,适合悬疑转场。",
|
||||
duration: "5s",
|
||||
id: "pupil-zoom",
|
||||
@ -968,7 +968,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
|
||||
badge: "热门",
|
||||
category: "人物动画",
|
||||
cover:
|
||||
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-story-script-cover.png",
|
||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-story-script-cover.png",
|
||||
description: "飞鸟解体,主体由细碎羽片散开再重组,适合视觉冲击强的短视频片头。",
|
||||
duration: "6s",
|
||||
id: "bird-dissolve",
|
||||
@ -983,7 +983,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
|
||||
badge: "商用",
|
||||
category: "电商展示",
|
||||
cover:
|
||||
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png",
|
||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-character-three-view-cover.png",
|
||||
description: "商品震撼登场,产品带冲击波与局部高光进入画面,适合活动主推款。",
|
||||
duration: "5s",
|
||||
id: "product-hero-entry",
|
||||
@ -997,7 +997,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
|
||||
author: "旅拍模板",
|
||||
category: "镜头运动",
|
||||
cover:
|
||||
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-audio-to-video-cover.png",
|
||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-audio-to-video-cover.png",
|
||||
description: "旅拍转场,前景遮挡切换到新地点,带自然手持感和轻微速度变化。",
|
||||
duration: "5s",
|
||||
id: "travel-whip-transition",
|
||||
@ -1160,9 +1160,9 @@ const NINE_GRID_CELL_VISUALS: GridCellVisual[] = [
|
||||
];
|
||||
|
||||
const TARGET_AVATAR =
|
||||
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/target-avatar.jpg";
|
||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/target-avatar.jpg";
|
||||
const PUBLISH_MODAL_BG_URL =
|
||||
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/publish-modal-bg.png";
|
||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/publish-modal-bg.png";
|
||||
const STORY_SCRIPT_PROMPT = "根据我上传的剧本生成一个完整的故事脚本";
|
||||
const AUDIO_TO_VIDEO_PROMPT =
|
||||
"根据上传的音频生成对应场景画面,镜头语言、节奏、音乐匹配情绪变化,电影级质感。";
|
||||
@ -21834,8 +21834,8 @@ const STYLE_MARKET_CATEGORIES = [
|
||||
"全部",
|
||||
];
|
||||
|
||||
const STYLE_MARKET_THUMBNAIL_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png";
|
||||
const EFFECT_MARKET_THUMBNAIL_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png";
|
||||
const STYLE_MARKET_THUMBNAIL_URL = "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-character-three-view-cover.png";
|
||||
const EFFECT_MARKET_THUMBNAIL_URL = "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-first-frame-video-cover.png";
|
||||
|
||||
const STYLE_MARKET_CARDS: AssetMarketCard[] = [
|
||||
["复古马卡龙", "算力潮汐", 177],
|
||||
|
||||
@ -38,6 +38,8 @@ export type AgentRunSnapshot = {
|
||||
videoRequirements?: import("./video-protocol").VideoTaskRequirements;
|
||||
objective: string;
|
||||
messages: ModelMessage[];
|
||||
/** Public assistant output for this chat reply, retained across disconnects. */
|
||||
assistantText?: string;
|
||||
calls: AgentCallRecord[];
|
||||
inputs: AgentRunInput[];
|
||||
steps: number;
|
||||
@ -59,6 +61,7 @@ export type AgentRunSnapshot = {
|
||||
};
|
||||
export type AgentRunView = {
|
||||
assistantMessageId?: string;
|
||||
assistantText?: string;
|
||||
activities?: Array<{ id: string; title: string; status: AgentCallRecord["status"] }>;
|
||||
videoSettings?: import("./video-protocol").VideoAgentSettings;
|
||||
id: string;
|
||||
|
||||
@ -37,6 +37,7 @@ export function agentRunView(run: Run): AgentRunView {
|
||||
const stale = run.status === "running" && Date.now() - run.updatedAt.getTime() > LEASE_STALE_MS;
|
||||
return {
|
||||
assistantMessageId: run.assistantMessageId,
|
||||
assistantText: run.snapshot.assistantText,
|
||||
activities: canvasAgentActivities(run.snapshot),
|
||||
id: run.id,
|
||||
videoSettings: run.snapshot.videoSettings,
|
||||
@ -151,6 +152,7 @@ export async function acquireAgentRun(params: {
|
||||
if (existing) {
|
||||
if (existing.status === "completed") throw new AgentRunError("该任务已完成,无需重新执行。");
|
||||
snapshot.message = "正在从已完成的步骤继续";
|
||||
snapshot.assistantText = "";
|
||||
if (snapshot.workspace === "video" && params.snapshot.videoSettings) {
|
||||
snapshot.videoSettings = params.snapshot.videoSettings;
|
||||
snapshot.videoRequirements = {
|
||||
|
||||
@ -21,6 +21,7 @@ import {
|
||||
import { getDefaultStorageConfig, getSystemSetting } from "@/lib/db/queries";
|
||||
import {
|
||||
bindVip98kImages,
|
||||
bindVip98kExpansionParams,
|
||||
getVip98kImageBindingKeys,
|
||||
hasVerifiedVip98kOpenAiImageArrayContract,
|
||||
normalizeVip98kInvocationHeaders,
|
||||
@ -1549,6 +1550,9 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation |
|
||||
body.metadata = { ...(toRecord(body.metadata) || {}), mode_image: imageMode };
|
||||
delete body.mode_image;
|
||||
}
|
||||
if (params.modelId.toLowerCase() === "image-expansion") {
|
||||
bindVip98kExpansionParams(body, imageParams);
|
||||
}
|
||||
const imageBindingKeys = bindVip98kImages(body, params.imageUrls);
|
||||
if (params.imageUrls.length > 0 && !invocation && imageBindingKeys.length === 0) {
|
||||
body.images = params.imageUrls;
|
||||
|
||||
@ -10,9 +10,9 @@ export type Vip98kRuntimeConfig = {
|
||||
};
|
||||
|
||||
export const defaultVip98kRuntimeConfig: Vip98kRuntimeConfig = {
|
||||
submissionTimeoutMs: 120_000,
|
||||
submissionTimeoutMs: 300_000,
|
||||
modelSubmissionTimeoutMs: {
|
||||
"nano-banana-pro": 240_000,
|
||||
"nano-banana-pro": 300_000,
|
||||
},
|
||||
maxSubmissionAttempts: 3,
|
||||
submissionRetryBaseDelayMs: 5_000,
|
||||
@ -45,7 +45,7 @@ function normalizeModelTimeouts(value: unknown) {
|
||||
normalized[normalizedModelId] = clampInteger(
|
||||
timeout,
|
||||
defaultVip98kRuntimeConfig.submissionTimeoutMs,
|
||||
30_000,
|
||||
300_000,
|
||||
10 * 60_000,
|
||||
);
|
||||
}
|
||||
@ -61,7 +61,7 @@ export function normalizeVip98kRuntimeConfig(value: unknown): Vip98kRuntimeConfi
|
||||
submissionTimeoutMs: clampInteger(
|
||||
raw.submissionTimeoutMs,
|
||||
defaultVip98kRuntimeConfig.submissionTimeoutMs,
|
||||
30_000,
|
||||
300_000,
|
||||
10 * 60_000,
|
||||
),
|
||||
modelSubmissionTimeoutMs: normalizeModelTimeouts(
|
||||
|
||||
@ -477,3 +477,42 @@ export function resolveVip98kTaskInvocation(
|
||||
|
||||
return createKnownImageToolInvocation(modelId, knownImageInput);
|
||||
}
|
||||
|
||||
/** Convert final canvas multipliers to the provider's per-side expansion ratios. */
|
||||
export function bindVip98kExpansionParams(
|
||||
body: Record<string, unknown>,
|
||||
params: { xScale?: unknown; yScale?: unknown }
|
||||
) {
|
||||
if (params.xScale === undefined && params.yScale === undefined) return;
|
||||
const ratio = (value: unknown) => {
|
||||
const scale = Number(value ?? 1.5);
|
||||
if (!Number.isFinite(scale) || scale <= 1 || scale > 3)
|
||||
throw new Error("VIP98K 扩图倍数必须大于 1 且不超过 3");
|
||||
return Number(((scale - 1) / 2).toFixed(8));
|
||||
};
|
||||
const directions = {
|
||||
left: ratio(params.xScale),
|
||||
right: ratio(params.xScale),
|
||||
top: ratio(params.yScale),
|
||||
bottom: ratio(params.yScale),
|
||||
};
|
||||
const metadata =
|
||||
body.metadata &&
|
||||
typeof body.metadata === "object" &&
|
||||
!Array.isArray(body.metadata)
|
||||
? { ...(body.metadata as Record<string, unknown>) }
|
||||
: {};
|
||||
const rootDirections = Object.keys(directions).some((key) => key in body);
|
||||
for (const key of Object.keys(directions)) {
|
||||
delete body[key];
|
||||
delete metadata[key];
|
||||
}
|
||||
if (rootDirections) {
|
||||
Object.assign(body, directions);
|
||||
if (Object.keys(metadata).length) body.metadata = metadata;
|
||||
else delete body.metadata;
|
||||
} else {
|
||||
body.metadata = { ...metadata, ...directions };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
14
lib/ai/zcode-agent/assistant-output.ts
Normal file
14
lib/ai/zcode-agent/assistant-output.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { AgentRunSnapshot } from "../canvas-agent-runtime/protocol";
|
||||
|
||||
/** Only ordinary assistant text belongs in chat; never use reasoning/tool events. */
|
||||
export function publicAssistantText(text: string) {
|
||||
return text.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
||||
.replace(/\bZCode\b/gi, "Agent")
|
||||
.replace(/(?:sk-[\w-]{12,}|Bearer\s+[\w.-]+)/gi, "[已隐藏]");
|
||||
}
|
||||
|
||||
export function canvasAgentReplyText(snapshot: Pick<AgentRunSnapshot, "assistantText" | "message">) {
|
||||
const text = snapshot.assistantText || "";
|
||||
const final = publicAssistantText(snapshot.message || "").trim();
|
||||
return !final || text.trimEnd().endsWith(final) ? text : text ? `${text.trimEnd()}\n\n${final}` : final;
|
||||
}
|
||||
@ -70,12 +70,15 @@ export async function createZcodeModelGateway(configured: ConfiguredModel, onErr
|
||||
attemptSignal.throwIfAborted();
|
||||
const request = target.apiType === "openai-responses" ? toResponsesRequest(body) : { ...body };
|
||||
delete request.stream_options;
|
||||
if (isDeepSeekOfficialUrl(target.endpoint.href)) {
|
||||
// The native core explicitly sends thinking=enabled, overriding
|
||||
// the provider default. Its history omits reasoning_content, so
|
||||
// enforce the compatible tool protocol at this Agent boundary.
|
||||
// Agent runs use ordinary replies, including on fallback channels.
|
||||
delete request.thinking;
|
||||
delete request.enable_thinking;
|
||||
delete request.reasoning_effort;
|
||||
delete request.reasoning;
|
||||
if (isDeepSeekOfficialUrl(target.endpoint.href) || /deepseek|kimi|glm/i.test(target.modelId)) {
|
||||
request.thinking = { type: "disabled" };
|
||||
delete request.reasoning_effort;
|
||||
} else if (/qwen/i.test(target.modelId) || ["alibaba", "qwen", "local-qwen"].includes(entry.provider.type)) {
|
||||
request.enable_thinking = false;
|
||||
}
|
||||
const providerFetch = isDeepSeekOfficialUrl(target.endpoint.href) ? createDeepSeekCompatibleFetch() : fetch;
|
||||
const response = await providerFetch(target.endpoint, { method: "POST", redirect: "error",
|
||||
|
||||
@ -8,6 +8,7 @@ import { AgentToolRejectedError, publicAgentError, type AgentRunSnapshot, type C
|
||||
import { isCompletedSimpleCutout } from "@/lib/ai/canvas-agent-runtime/cutout-completion";
|
||||
import { requiresCanvasInteraction } from "@/lib/ai/canvas-agent-runtime/results";
|
||||
import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
|
||||
import { publicAssistantText } from "./assistant-output";
|
||||
|
||||
type Ports = {
|
||||
native?: typeof runNativeZcode;
|
||||
@ -19,6 +20,7 @@ type Ports = {
|
||||
context: unknown;
|
||||
gateway: { modelId: string; apiType: string; forward: (body: Record<string, unknown>, path: string, signal: AbortSignal) => Promise<Response> };
|
||||
signal: AbortSignal;
|
||||
onAssistantText?: (delta: string) => void;
|
||||
checkpoint: (snapshot: AgentRunSnapshot, status: CanvasAgentRunStatus) => Promise<void>;
|
||||
consumeInputs: (snapshot: AgentRunSnapshot) => Promise<{ cancelled: boolean }>;
|
||||
isCancelled: () => Promise<boolean>;
|
||||
@ -39,6 +41,15 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
||||
let toolFailure = "";
|
||||
let nativeFailed = false;
|
||||
let verifiedCompletion = "";
|
||||
const seenTextEvents = new Set<string>();
|
||||
let lastTextMessage = "";
|
||||
async function appendText(delta: string) {
|
||||
delta = publicAssistantText(delta).slice(0, Math.max(0, 100_000 - (snapshot.assistantText?.length || 0)));
|
||||
if (!delta) return;
|
||||
snapshot.assistantText = (snapshot.assistantText || "") + delta;
|
||||
await ports.checkpoint(structuredClone(snapshot), "running");
|
||||
ports.onAssistantText?.(delta);
|
||||
}
|
||||
async function finishVerifiedCutout() {
|
||||
snapshot.nativeRuntime!.state = "stopped";
|
||||
snapshot.messages.push({ role: "assistant", content: verifiedCompletion });
|
||||
@ -100,6 +111,18 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
||||
await save("正在准备处理任务");
|
||||
}),
|
||||
onEvent: async (event: NativeEvent | NativeResult) => {
|
||||
if (event.type === "model.streaming" && event.payload?.kind === "text_delta" && typeof event.payload.delta === "string") {
|
||||
// Child sessions and reasoning/tool-argument deltas are private.
|
||||
if (snapshot.nativeRuntime?.sessionId && event.sessionId !== snapshot.nativeRuntime.sessionId) return;
|
||||
await exclusive(async () => {
|
||||
if (event.eventId && seenTextEvents.has(event.eventId)) return;
|
||||
if (event.eventId) seenTextEvents.add(event.eventId);
|
||||
const messageId = String(event.payload!.assistantMessageId || "");
|
||||
await appendText((lastTextMessage && lastTextMessage !== messageId ? "\n\n" : "") + event.payload!.delta);
|
||||
lastTextMessage = messageId;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!["turn.started", "result", "session.created", "session.resumed"].includes(event.type)) return;
|
||||
await exclusive(async () => {
|
||||
if (event.sessionId) snapshot.nativeRuntime!.sessionId = event.sessionId;
|
||||
@ -196,6 +219,9 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
||||
snapshot.inputTokens += Number(result.usage?.inputTokens || 0);
|
||||
snapshot.outputTokens += Number(result.usage?.outputTokens || 0);
|
||||
snapshot.messages.push({ role: "assistant", content: result.response || "" });
|
||||
const response = publicAssistantText(result.response || "").trim();
|
||||
if (response && !snapshot.assistantText?.trimEnd().endsWith(response))
|
||||
await appendText((snapshot.assistantText ? "\n\n" : "") + response);
|
||||
await ports.consumeInputs(snapshot);
|
||||
});
|
||||
if (await ports.isCancelled()) return await save("已停止后续处理,完成的结果保留在画布。", "cancelled");
|
||||
|
||||
@ -615,7 +615,7 @@ export async function ensureCanvasImageTaskActive(params: {
|
||||
eq(canvasImageTask.id, task.id),
|
||||
eq(canvasImageTask.status, "running"),
|
||||
...(isDetailReplacementBatchRequest(request)
|
||||
? [sql`${canvasImageTask.updatedAt} <= ${task.updatedAt}`]
|
||||
? [sql`${canvasImageTask.updatedAt} <= ${task.updatedAt.toISOString()}`]
|
||||
: []),
|
||||
)
|
||||
).returning({ id: canvasImageTask.id });
|
||||
|
||||
@ -555,13 +555,13 @@ export const defaultVideoCanvasMotionPresets: VideoCanvasMotionPreset[] = [
|
||||
|
||||
const defaultRoleImages: VideoCanvasRolePreset["images"] = {
|
||||
expression:
|
||||
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-image.png",
|
||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/character-image.png",
|
||||
face:
|
||||
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-image.png",
|
||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/character-image.png",
|
||||
full:
|
||||
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-image.png",
|
||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/character-image.png",
|
||||
threeView:
|
||||
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-three-view.png",
|
||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/character-three-view.png",
|
||||
};
|
||||
|
||||
function createDefaultRolePreset(
|
||||
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next-agent-brain-icon-20261022/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
6
scripts/storage/audit-aliyun-qiniu-references.cjs
Normal file
6
scripts/storage/audit-aliyun-qiniu-references.cjs
Normal file
@ -0,0 +1,6 @@
|
||||
/* Read-only audit of live business references; obsolete admin configuration backups stay intact. */
|
||||
const fs=require('fs'),path=require('path');const root=process.env.MIGRATION_APP_ROOT||process.cwd(),dir=process.env.MIGRATION_RUN_DIR||'/var/lib/ai-chatbot/aliyun-qiniu-20261014';const {urlsIn}=require('./migrate-aliyun-to-qiniu.cjs');require(path.join(root,'node_modules/dotenv')).config({path:path.join(root,'.env.local')});const sql=require(path.join(root,'node_modules/postgres'))(process.env.POSTGRES_URL,{max:1,prepare:false,connection:{statement_timeout:600000}});const host='demo-sparkai.oss-cn-shanghai.aliyuncs.com',quote=s=>'"'+s.replaceAll('"','""')+'"';
|
||||
(async()=>{try{const verified=new Set(fs.readFileSync(path.join(dir,'verified.jsonl'),'utf8').split('\n').filter(Boolean).map(s=>JSON.parse(s).id)),unavailable=new Set(fs.existsSync(path.join(dir,'unavailable-source.json'))?JSON.parse(fs.readFileSync(path.join(dir,'unavailable-source.json'))).map(r=>r.id):[]),remaining=new Map(),tables=new Map(),excluded=new Set(['StorageConfig','StorageMigrationTask','ImageArchiveJob','__drizzle_migrations','SystemSettingHistory','SystemSettingSnapshot']);
|
||||
const columns=await sql`select c.table_name as "tableName",c.column_name as "columnName" from information_schema.columns c join information_schema.tables t on c.table_schema=t.table_schema and c.table_name=t.table_name where c.table_schema='public' and t.table_type='BASE TABLE' and c.data_type in ('text','character varying','json','jsonb') order by c.table_name,c.ordinal_position`;
|
||||
for(const c of columns)if(!excluded.has(c.tableName)){if(!tables.has(c.tableName))tables.set(c.tableName,[]);tables.get(c.tableName).push(c.columnName);}const tableCounts={};for(const [table,cols] of tables){const where=cols.map(c=>'('+quote(c)+'::text like $1'+(table==='ImageArchiveTask'&&c==='sourceUrl'?" and coalesce(metadata->>'legacyOssMigrationRun','') <> '20261014'":'')+')').join(' or ');let matched=0;for await(const rows of sql.unsafe('select '+cols.map(quote).join(',')+' from '+quote(table)+' where '+where,['%'+host+'/%']).cursor(50))for(const row of rows){matched++;for(const [column,value]of Object.entries(row)){if(table==='ImageArchiveTask'&&column==='sourceUrl'&&row.metadata?.legacyOssMigrationRun==='20261014')continue;for(const r of urlsIn(value))if(r.sourceHost===host){if(!remaining.has(r.id))remaining.set(r.id,{...r,verified:verified.has(r.id),knownUnavailable:unavailable.has(r.id),locations:{}});const item=remaining.get(r.id),key=table+'.'+column;item.locations[key]=(item.locations[key]||0)+1;}}}if(matched){tableCounts[table]=matched;console.log('remaining_reference_rows',JSON.stringify({table,rows:matched}));}}
|
||||
const resources=[...remaining.values()],summary={auditedAt:new Date().toISOString(),remainingUniqueResources:resources.length,verifiedPendingRewrite:resources.filter(r=>r.verified).length,knownUnavailable:resources.filter(r=>r.knownUnavailable&&!r.verified).length,unclassified:resources.filter(r=>!r.knownUnavailable&&!r.verified).length,tableCounts};fs.writeFileSync(path.join(dir,'reference-audit-resources.json'),JSON.stringify(resources),{mode:0o600});fs.writeFileSync(path.join(dir,'reference-audit-summary.json'),JSON.stringify(summary,null,2),{mode:0o600});console.log('reference_audit',JSON.stringify(summary));}finally{await sql.end()}})().catch(e=>{console.error('audit_failed',e.name,String(e.message).replace(/https?:\/\/\S+/g,'[URL]').slice(0,180));process.exitCode=1});
|
||||
8
scripts/storage/inventory-aliyun-qiniu-source.cjs
Normal file
8
scripts/storage/inventory-aliyun-qiniu-source.cjs
Normal file
@ -0,0 +1,8 @@
|
||||
const fs=require('fs'),path=require('path'),root=process.env.MIGRATION_APP_ROOT||process.cwd(),dir=process.env.MIGRATION_RUN_DIR||'/var/lib/ai-chatbot/aliyun-qiniu-20261014';
|
||||
require(path.join(root,'node_modules/dotenv')).config({path:path.join(root,'.env.local')});const sql=require(path.join(root,'node_modules/postgres'))(process.env.POSTGRES_URL,{max:1,prepare:false});const {S3Client,ListObjectsV2Command}=require(path.join(root,'node_modules/@aws-sdk/client-s3'));
|
||||
(async()=>{let client;try{const complete=path.join(dir,'source-metadata.complete');if(fs.existsSync(complete)){console.log('Source metadata inventory already completed');return;}const plan=JSON.parse(fs.readFileSync(path.join(dir,'plan-all.json'))),wanted=new Set(plan.filter(r=>!r.variant).map(r=>r.sourceKey));
|
||||
const [s]=await sql`select * from "StorageConfig" where bucket='demo-sparkai' limit 1`;client=new S3Client({endpoint:s.endpoint,region:s.region,forcePathStyle:false,credentials:{accessKeyId:s.accessKeyId,secretAccessKey:s.secretAccessKey},maxAttempts:3});let marker,listed=0,matched=0;const checkpointFile=path.join(dir,'source-list-checkpoint.json'),file=path.join(dir,'source-metadata.jsonl');if(fs.existsSync(checkpointFile)){const c=JSON.parse(fs.readFileSync(checkpointFile));marker=c.marker;listed=c.listed;matched=c.matched;}
|
||||
for(;;){const r=await client.send(new ListObjectsV2Command({Bucket:s.bucket,MaxKeys:1000,ContinuationToken:marker}),{abortSignal:AbortSignal.timeout(60000)});const lines=[];for(const o of r.Contents||[]){listed++;if(wanted.has(o.Key)){lines.push(JSON.stringify({key:o.Key,size:o.Size,md5:String(o.ETag||'').replaceAll('"','').toLowerCase()}));matched++;}}
|
||||
if(lines.length)fs.appendFileSync(file,lines.join('\n')+'\n',{mode:0o600});marker=r.NextContinuationToken;fs.writeFileSync(checkpointFile,JSON.stringify({marker,listed,matched}),{mode:0o600});if(listed%25000<1000)console.log(JSON.stringify({listed,matched,wanted:wanted.size}));if(!r.IsTruncated)break;}
|
||||
fs.writeFileSync(complete,JSON.stringify({listed,matched,wanted:wanted.size,completedAt:new Date().toISOString()}),{mode:0o600});console.log('Source inventory complete',JSON.stringify({listed,matched,wanted:wanted.size}));
|
||||
}finally{client?.destroy();await sql.end()}})().catch(e=>{console.error('source_inventory_failed',e.name,String(e.message).replace(/https?:\/\/\S+/g,'[URL]').slice(0,180));process.exitCode=1});
|
||||
1308
scripts/storage/migrate-aliyun-to-minio.ts
Normal file
1308
scripts/storage/migrate-aliyun-to-minio.ts
Normal file
File diff suppressed because it is too large
Load Diff
220
scripts/storage/migrate-aliyun-to-qiniu.cjs
Normal file
220
scripts/storage/migrate-aliyun-to-qiniu.cjs
Normal file
@ -0,0 +1,220 @@
|
||||
/* Resumable, verified migration. No source objects are deleted. */
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { Readable, Transform } = require('node:stream');
|
||||
const SOURCE_HOST = 'demo-sparkai.oss-cn-shanghai.aliyuncs.com';
|
||||
const URL_PATTERN = /https?:\/\/(?:demo-sparkai\.oss-cn-shanghai\.aliyuncs\.com\/|s3\.jiansun\.vip\/gongxiangpan\/)[^\s"'<>`\\)\]}|]+/gi;
|
||||
function resourceFor(url) {
|
||||
const u = new URL(url);
|
||||
const isPrimary=u.hostname===SOURCE_HOST,isPsdBackup=u.hostname==='s3.jiansun.vip'&&u.pathname.startsWith('/gongxiangpan/');
|
||||
if ((!isPrimary&&!isPsdBackup) || u.pathname === '/') return null;
|
||||
for (const name of ['Signature', 'OSSAccessKeyId', 'Expires', 'security-token']) u.searchParams.delete(name);
|
||||
u.protocol = 'https:'; u.hash = ''; u.searchParams.sort();
|
||||
const identity = u.toString(), sourceKey = decodeURIComponent(u.pathname.slice(isPsdBackup?'/gongxiangpan/'.length:1));
|
||||
if (!sourceKey || sourceKey.split('/').some(p => p === '..')) throw Error('Invalid object key');
|
||||
const id = crypto.createHash('sha256').update(identity).digest('hex');
|
||||
const namespace=isPrimary?'legacy-oss/demo-sparkai/':'legacy-s3/gongxiangpan/';
|
||||
const targetKey = u.search ? namespace+'variants/' + id + path.extname(sourceKey) : namespace + sourceKey;
|
||||
return { id, identity, sourceHost:u.hostname, sourceKey, targetKey, variant: Boolean(u.search) };
|
||||
}
|
||||
function urlsIn(value) {
|
||||
const found = new Map();
|
||||
function walk(v) {
|
||||
if (typeof v === 'string') for (const url of v.match(URL_PATTERN) || []) { const r = resourceFor(url); if (r) found.set(r.id, r); }
|
||||
else if (Array.isArray(v)) v.forEach(walk);
|
||||
else if (v && typeof v === 'object') Object.entries(v).forEach(([k,val])=>{walk(k);walk(val);});
|
||||
}
|
||||
walk(value); return [...found.values()];
|
||||
}
|
||||
function replaceVerified(value, verified) {
|
||||
if (typeof value === 'string') return value.replace(URL_PATTERN, url => { const r = resourceFor(url); return r && verified.get(r.id)?.targetUrl || url; });
|
||||
if (Array.isArray(value)) return value.map(v => replaceVerified(v, verified));
|
||||
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [replaceVerified(k, verified), replaceVerified(v, verified)]));
|
||||
return value;
|
||||
}
|
||||
function quote(s) { return '"' + s.replaceAll('"', '""') + '"'; }
|
||||
async function rewriteBatch(sql, column, pk, pkType, rows, verified, backup) {
|
||||
const isJson=['json','jsonb'].includes(column.dataType);
|
||||
const changes=[],originals=[];
|
||||
for(const row of rows){
|
||||
const next=replaceVerified(row.value,verified);
|
||||
if(JSON.stringify(next)===JSON.stringify(row.value))continue;
|
||||
originals.push({table:column.tableName,column:column.columnName,pkColumn:pk,pk:row.pk,value:row.value});
|
||||
changes.push({pk:String(row.pk),oldvalue:isJson?JSON.stringify(row.value):row.value,newvalue:isJson?JSON.stringify(next):next});
|
||||
}
|
||||
if(!changes.length)return 0;
|
||||
backup(originals);
|
||||
const cast=isJson?'::'+column.dataType:'';
|
||||
const updated=await sql.unsafe('update '+quote(column.tableName)+' as t set '+quote(column.columnName)+'=v.newvalue'+cast+' from jsonb_to_recordset($1::jsonb) as v(pk text,oldvalue text,newvalue text) where t.'+quote(pk)+'=v.pk::'+pkType+' and t.'+quote(column.columnName)+(isJson?'::jsonb=v.oldvalue::jsonb':'=v.oldvalue')+' returning t.'+quote(pk),[sql.json(changes)]);
|
||||
return updated.length;
|
||||
}
|
||||
module.exports = { resourceFor, urlsIn, replaceVerified, rewriteBatch };
|
||||
if (require.main === module) main().catch(e => { console.error('migration_failed', e.name, String(e.message).replace(/https?:\/\/\S+/g, '[URL]').slice(0,240)); process.exitCode = 1; });
|
||||
async function main() {
|
||||
const root = process.env.MIGRATION_APP_ROOT || process.cwd();
|
||||
require(path.join(root, 'node_modules/dotenv')).config({ path: path.join(root, '.env.local') });
|
||||
const postgres = require(path.join(root, 'node_modules/postgres'));
|
||||
const { S3Client, PutObjectCommand, GetObjectCommand, HeadObjectCommand } = require(path.join(root, 'node_modules/@aws-sdk/client-s3'));
|
||||
const sql = postgres(process.env.POSTGRES_URL, { max: 2, prepare: false, connection: { statement_timeout: 600000 } });
|
||||
const dir = process.env.MIGRATION_RUN_DIR || '/var/lib/ai-chatbot/aliyun-qiniu-20261014';
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const journal = path.join(dir, 'verified.jsonl'), verified = new Map();
|
||||
if (fs.existsSync(journal)) for (const line of fs.readFileSync(journal,'utf8').split('\n').filter(Boolean)) { const r = JSON.parse(line); verified.set(r.id,r); }
|
||||
let sourceClient, targetClient;
|
||||
try {
|
||||
const configs = await sql`select * from "StorageConfig"`;
|
||||
const source = configs.find(c => c.bucket === 'demo-sparkai' && String(c.endpoint).includes('aliyuncs.com'));
|
||||
const target = configs.find(c => c.type === 'qiniu-kodo' && c.isEnabled && c.isDefault);
|
||||
if (!source || !target || !target.publicUrl) throw Error('Expected source and default Qiniu configuration unavailable');
|
||||
function client(c) { return new S3Client({ endpoint: c.endpoint, region: c.region || 'auto', forcePathStyle: true, credentials: { accessKeyId:c.accessKeyId,secretAccessKey:c.secretAccessKey }, requestChecksumCalculation:'WHEN_REQUIRED',responseChecksumValidation:'WHEN_REQUIRED', maxAttempts: 2 }); }
|
||||
sourceClient = client(source); targetClient = client(target);
|
||||
const publicUrl = key => target.publicUrl.replace(/\/$/,'') + '/' + key.split('/').map(encodeURIComponent).join('/');
|
||||
const command = process.argv[2] || 'audit';
|
||||
const settings = command==='migrate-psd-extra'?await sql`select id,key,value from "SystemSettings" where key in ('agent_psd_templates','agent_psd_fonts')`:await sql`select id,key,value from "SystemSettings" where value::text like ${'%'+SOURCE_HOST+'/%'}`;
|
||||
const settingsResources = urlsIn(settings.map(s => s.value));
|
||||
console.log('settings_inventory', JSON.stringify({ rows: settings.length, keys: settings.map(s => s.key), uniqueObjects:settingsResources.length, alreadyVerified:verified.size }));
|
||||
if (command === 'audit') return;
|
||||
const resources = new Map(settingsResources.map(r => [r.id,r]));
|
||||
const scope = command.endsWith('-all') ? 'all' : command==='migrate-psd-extra'?'psd-extra':command==='migrate-recovery'?'recovery':command==='migrate-literals'?'literals':'settings';
|
||||
if(command==='migrate-recovery'){
|
||||
resources.clear();
|
||||
for(const candidate of JSON.parse(fs.readFileSync(process.env.MIGRATION_RESOURCE_FILE,'utf8'))){
|
||||
const canonical=resourceFor(candidate.identity);
|
||||
if(!canonical||canonical.id!==candidate.id)throw Error('Invalid recovery identity');
|
||||
const backup=new URL(candidate.fetchUrl);
|
||||
if(!configs.some(c=>c.publicUrl&&backup.origin===new URL(c.publicUrl).origin&&backup.pathname.startsWith(new URL(c.publicUrl).pathname.replace(/\/$/,'')+'/')))throw Error('Recovery source is not a configured storage origin');
|
||||
resources.set(canonical.id,{...canonical,fetchUrl:candidate.fetchUrl});
|
||||
}
|
||||
}
|
||||
let columns = [];
|
||||
if (scope === 'all') {
|
||||
columns = await sql`select c.table_name as "tableName",c.column_name as "columnName",c.data_type as "dataType" from information_schema.columns c join information_schema.tables t on c.table_schema=t.table_schema and c.table_name=t.table_name where c.table_schema='public' and t.table_type='BASE TABLE' and c.is_generated='NEVER' and c.is_updatable='YES' and c.data_type in ('text','character varying','json','jsonb') order by c.table_name,c.ordinal_position`;
|
||||
const excluded = new Set(['StorageConfig','StorageMigrationTask','ImageArchiveJob','__drizzle_migrations','SystemSettings','SystemSettingHistory','SystemSettingSnapshot']);
|
||||
const tables = new Map();
|
||||
const inventoryJournal=path.join(dir,'inventory.jsonl'), doneFile=path.join(dir,'inventory-completed-tables.json');
|
||||
const completedTables=new Set(fs.existsSync(doneFile)?JSON.parse(fs.readFileSync(doneFile,'utf8')):[]);
|
||||
if(fs.existsSync(inventoryJournal))for(const line of fs.readFileSync(inventoryJournal,'utf8').split('\n').filter(Boolean)){const r=JSON.parse(line);for(const canonical of urlsIn(r.identity))resources.set(canonical.id,canonical);}
|
||||
for (const c of columns) if (!excluded.has(c.tableName)) { if (!tables.has(c.tableName)) tables.set(c.tableName,[]); tables.get(c.tableName).push(c); }
|
||||
for (const [table, cols] of tables) {
|
||||
if(completedTables.has(table)&&process.env.MIGRATION_RESCAN!=='1')continue;
|
||||
console.log('scan_start',table);
|
||||
const where = cols.map(c => quote(c.columnName)+'::text like $1').join(' or ');
|
||||
let matched=0;
|
||||
for await (const rows of sql.unsafe('select '+cols.map(c=>quote(c.columnName)).join(',')+' from '+quote(table)+' where '+where,['%'+SOURCE_HOST+'/%']).cursor(50)){const added=[];for (const row of rows) { matched++; for (const r of urlsIn(row)) if(!resources.has(r.id)){resources.set(r.id,r);added.push(r);} }if(added.length)fs.appendFileSync(inventoryJournal,added.map(r=>JSON.stringify(r)).join('\n')+'\n',{mode:0o600});}
|
||||
completedTables.add(table);fs.writeFileSync(doneFile,JSON.stringify([...completedTables]),{mode:0o600});
|
||||
if (matched) console.log('database_inventory',JSON.stringify({table,rows:matched,uniqueObjects:resources.size}));
|
||||
}
|
||||
}
|
||||
if(scope==='all'||scope==='literals'){
|
||||
if(scope==='literals')resources.clear();
|
||||
for (const folder of ['app','components','lib']) {
|
||||
function walk(d) { for (const e of fs.readdirSync(d,{withFileTypes:true})) { const f=path.join(d,e.name); if(e.isDirectory())walk(f); else if(/\.(tsx?|jsx?)$/.test(f)) for(const r of urlsIn(fs.readFileSync(f,'utf8')))resources.set(r.id,r); } }
|
||||
walk(path.join(root,folder));
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(path.join(dir,'plan-'+scope+'.json'),JSON.stringify([...resources.values()]),{mode:0o600});
|
||||
if (command === 'inventory-all') return;
|
||||
if (!['migrate-settings','migrate-all','migrate-psd-extra','migrate-recovery','migrate-literals','rewrite-all'].includes(command)) throw Error('Unknown command');
|
||||
if (scope === 'settings' && !fs.existsSync(path.join(dir,'settings-before.json'))) fs.writeFileSync(path.join(dir,'settings-before.json'),JSON.stringify(settings),{mode:0o600,flag:'wx'});
|
||||
const sourceMetadata=new Map(); const metadataFile=path.join(dir,'source-metadata.jsonl');
|
||||
if(fs.existsSync(metadataFile))for(const line of fs.readFileSync(metadataFile,'utf8').split('\n').filter(Boolean)){const r=JSON.parse(line);sourceMetadata.set(r.key,r);}
|
||||
const base64url = value => Buffer.from(value).toString('base64').replace(/\+/g,'-').replace(/\//g,'_');
|
||||
async function cloudCopy(r) {
|
||||
const fetchUrl=r.fetchUrl||r.identity;
|
||||
const cached=r.fetchUrl||r.variant||new URL(r.identity).hostname!==SOURCE_HOST?null:sourceMetadata.get(r.sourceKey);
|
||||
let size=cached?.size,md5=cached?.md5;
|
||||
if(!r.variant&&!cached){const head=await fetch(fetchUrl,{method:'HEAD',headers:{'Accept-Encoding':'identity'},signal:AbortSignal.timeout(45000)});if(!head.ok)throw Error('Source HTTP '+head.status);size=Number(head.headers.get('content-length'));md5=String(head.headers.get('etag')||'').replaceAll('"','').toLowerCase();}
|
||||
if(r.variant || !size || !/^[a-f0-9]{32}$/.test(md5||'')) {
|
||||
const download=await fetch(fetchUrl,{headers:{'Accept-Encoding':'identity'},signal:AbortSignal.timeout(15*60*1000)});
|
||||
if(!download.ok)throw Error('Source HTTP '+download.status);
|
||||
const hash=crypto.createHash('md5');size=0;for await(const chunk of Readable.fromWeb(download.body)){hash.update(chunk);size+=chunk.length;}md5=hash.digest('hex');
|
||||
}
|
||||
if(!size)throw Error('Empty source object');
|
||||
const requestPath='/fetch/'+base64url(fetchUrl)+'/to/'+base64url(target.bucket+':'+r.targetKey);
|
||||
const signature=base64url(crypto.createHmac('sha1',target.secretAccessKey).update(requestPath+'\n').digest());
|
||||
const fetchResult=await fetch('https://iovip.qbox.me'+requestPath,{method:'POST',headers:{Authorization:'QBox '+target.accessKeyId+':'+signature,'Content-Type':'application/x-www-form-urlencoded'},signal:AbortSignal.timeout(15*60*1000)});
|
||||
if(!fetchResult.ok)throw Error('Qiniu cloud fetch HTTP '+fetchResult.status);
|
||||
const info=await fetchResult.json();if(Number(info.fsize)!==size)throw Error('Cloud target size mismatch');
|
||||
const targetUrl=publicUrl(r.targetKey);
|
||||
const hashResponse=await fetch(targetUrl+'?qhash/md5',{signal:AbortSignal.timeout(45000)});
|
||||
if(!hashResponse.ok)throw Error('CDN hash verification HTTP '+hashResponse.status);
|
||||
const targetDigest=await hashResponse.json();
|
||||
if(targetDigest.hash!==md5 || Number(targetDigest.fsize)!==size)throw Error('Cloud target checksum mismatch');
|
||||
const record={...r,targetUrl,size,md5,transport:'qiniu-cloud-fetch',verifiedAt:new Date().toISOString()};fs.appendFileSync(journal,JSON.stringify(record)+'\n',{mode:0o600});verified.set(r.id,record);
|
||||
}
|
||||
async function copy(r) {
|
||||
if (verified.has(r.id)) return;
|
||||
let last;
|
||||
for (let attempt=1;attempt<=3;attempt++) {
|
||||
let input;
|
||||
try {
|
||||
if(process.env.MIGRATION_TRANSPORT!=='stream'){await cloudCopy(r);return;}
|
||||
const timeout=AbortSignal.timeout(15*60*1000);
|
||||
const response=await fetch(r.identity,{signal:timeout,headers:{'Accept-Encoding':'identity'}});
|
||||
let size,type,disposition;
|
||||
if (response.ok) { size=Number(response.headers.get('content-length'));type=response.headers.get('content-type');disposition=response.headers.get('content-disposition'); input=Readable.fromWeb(response.body); }
|
||||
else {
|
||||
await response.body?.cancel();
|
||||
if(r.variant)throw Error('Source variant HTTP '+response.status);
|
||||
const get=await sourceClient.send(new GetObjectCommand({Bucket:source.bucket,Key:r.sourceKey}),{abortSignal:timeout});input=get.Body;size=get.ContentLength;type=get.ContentType;disposition=get.ContentDisposition;
|
||||
}
|
||||
if (!size || size>5*1024**3)throw Error('Unsupported source size '+size);
|
||||
const hash=crypto.createHash('sha256');let readBytes=0;
|
||||
const hashStream=new Transform({transform(chunk,enc,cb){hash.update(chunk);readBytes+=chunk.length;cb(null,chunk)}});
|
||||
input.on('error',e=>hashStream.destroy(e));
|
||||
await targetClient.send(new PutObjectCommand({Bucket:target.bucket,Key:r.targetKey,Body:input.pipe(hashStream),ContentLength:size,ContentType:type||'application/octet-stream',ContentDisposition:disposition||undefined,CacheControl:'public, max-age=31536000, immutable',Metadata:{'migration-source-id':r.id}}),{abortSignal:timeout});
|
||||
if(readBytes!==size)throw Error('Source size mismatch'); const sha256=hash.digest('hex');
|
||||
const get=await targetClient.send(new GetObjectCommand({Bucket:target.bucket,Key:r.targetKey}),{abortSignal:timeout});const targetHash=crypto.createHash('sha256');let storedBytes=0;for await(const chunk of get.Body){targetHash.update(chunk);storedBytes+=chunk.length;}
|
||||
if(storedBytes!==size||targetHash.digest('hex')!==sha256)throw Error('Target checksum mismatch');
|
||||
const targetUrl=publicUrl(r.targetKey);const head=await fetch(targetUrl,{method:'HEAD',signal:AbortSignal.timeout(30000)});
|
||||
if(!head.ok||Number(head.headers.get('content-length'))!==size)throw Error('CDN verification HTTP '+head.status);
|
||||
const record={...r,targetUrl,size,sha256,verifiedAt:new Date().toISOString()};fs.appendFileSync(journal,JSON.stringify(record)+'\n',{mode:0o600});verified.set(r.id,record);return;
|
||||
} catch(e) { last=e;input?.destroy();if(attempt<3)await new Promise(resolve=>setTimeout(resolve,attempt*1000)); }
|
||||
}
|
||||
fs.appendFileSync(path.join(dir,'failures-'+scope+'.jsonl'),JSON.stringify({id:r.id,sourceKey:r.sourceKey,name:last.name,message:String(last.message).replace(/https?:\/\/\S+/g,'[URL]').slice(0,240)})+'\n',{mode:0o600});
|
||||
}
|
||||
const psdIds=new Set(urlsIn(settings.find(s=>s.key==='agent_psd_templates')?.value).map(r=>r.id));
|
||||
const list=[...resources.values()].sort((a,b)=>(psdIds.has(a.id)?0:2)-(psdIds.has(b.id)?0:2)||Number(/\.(psd|psb)$/i.test(a.sourceKey))-Number(/\.(psd|psb)$/i.test(b.sourceKey)));
|
||||
const pending=list.filter(r=>!verified.has(r.id));
|
||||
const concurrency=Math.max(1,Math.min(96,Number(process.env.MIGRATION_CONCURRENCY||64)));
|
||||
let cursor=0,done=0;const start=Date.now();
|
||||
function progress(){const p={scope,total:list.length,processed:done,verified:verified.size,remaining:list.filter(r=>!verified.has(r.id)).length,elapsedSeconds:Math.round((Date.now()-start)/1000),verifiedBytes:[...verified.values()].reduce((n,r)=>n+r.size,0)};fs.writeFileSync(path.join(dir,'progress-'+scope+'.json'),JSON.stringify(p),{mode:0o600});console.log('copy_progress',JSON.stringify(p));}
|
||||
const interval=setInterval(progress,30000);
|
||||
try { if(command!=='rewrite-all')await Promise.all(Array.from({length:concurrency},async()=>{while(cursor<pending.length){await copy(pending[cursor++]);done++;if(done%1000===0)progress();}})); } finally {clearInterval(interval);progress();}
|
||||
// Recovery copies may finish in a separate process while the primary transfer runs.
|
||||
if(fs.existsSync(journal))for(const line of fs.readFileSync(journal,'utf8').split('\n').filter(Boolean)){const r=JSON.parse(line);verified.set(r.id,r);}
|
||||
const missing=list.filter(r=>!verified.has(r.id));
|
||||
// Only verified addresses are changed; unresolved references keep their original source.
|
||||
let changedSettings=0;
|
||||
for(const setting of settings){
|
||||
for(let attempt=0;attempt<4;attempt++){
|
||||
const [current]=await sql`select value from "SystemSettings" where id=${setting.id}`;const next=replaceVerified(current.value,verified);if(JSON.stringify(next)===JSON.stringify(current.value))break;
|
||||
const updated=await sql`update "SystemSettings" set value=${sql.json(next)},"updatedAt"=now() where id=${setting.id} and value=${sql.json(current.value)} returning id`;
|
||||
if(updated.length){changedSettings++;break;}if(attempt===3)throw Error('Concurrent setting update prevented replacement');
|
||||
}
|
||||
}
|
||||
if(scope==='all'){
|
||||
const backupFile=path.join(dir,'database-before.jsonl.gz');
|
||||
for(const c of columns){
|
||||
// Administrative audit snapshots preserve their original configuration values.
|
||||
// User canvas/chat history remains in scope; obsolete configuration backups do not.
|
||||
if(['StorageConfig','StorageMigrationTask','ImageArchiveJob','__drizzle_migrations','SystemSettings','SystemSettingHistory','SystemSettingSnapshot'].includes(c.tableName))continue;
|
||||
const pks=await sql`select a.attname,a.atttypid::regtype::text as "pkType" from pg_index i join pg_attribute a on a.attrelid=i.indrelid and a.attnum=any(i.indkey) where i.indrelid=${'"'+c.tableName+'"'}::regclass and i.indisprimary`;
|
||||
if(pks.length!==1){continue;} const pk=pks[0].attname;
|
||||
let count=0;
|
||||
const aliasGuard=c.tableName==='ImageArchiveTask'&&c.columnName==='sourceUrl'?" and coalesce(metadata->>'legacyOssMigrationRun','') <> '20261014'":'';
|
||||
for await(const rows of sql.unsafe('select '+quote(pk)+' as pk,'+quote(c.columnName)+' as value from '+quote(c.tableName)+' where '+quote(c.columnName)+'::text like $1'+aliasGuard,['%'+SOURCE_HOST+'/%']).cursor(50)){
|
||||
// Flush before updating to retain the original data even on interruption.
|
||||
count+=await rewriteBatch(sql,c,pk,pks[0].pkType,rows,verified,backups=>fs.appendFileSync(backupFile,require('node:zlib').gzipSync(backups.map(r=>JSON.stringify(r)).join('\n')+'\n',{level:6}),{mode:0o600}));
|
||||
}
|
||||
if(count)console.log('references_replaced',JSON.stringify({table:c.tableName,column:c.columnName,rows:count}));
|
||||
}
|
||||
}
|
||||
const [migration]=await sql`select id,value from "SystemSettings" where key='oss_image_migration_config'`;
|
||||
if(migration){const next={...migration.value,enabled:false,preferAliyunForUploads:false,targetStorageConfigId:target.id};await sql`update "SystemSettings" set value=${sql.json(next)},"updatedAt"=now() where id=${migration.id}`;}
|
||||
await sql`update "StorageConfig" set "isEnabled"=false,"isDefault"=false where id=${source.id}`;
|
||||
const result={scope,objects:list.length,verified:list.length-missing.length,missing:missing.length,changedSettings,defaultStorage:'qiniu-kodo',sourceRetained:true,completedAt:new Date().toISOString()};fs.writeFileSync(path.join(dir,'result-'+scope+'.json'),JSON.stringify(result,null,2));console.log('migration_result',JSON.stringify(result));
|
||||
if(missing.length)process.exitCode=2;
|
||||
} finally {sourceClient?.destroy();targetClient?.destroy();await sql.end();}
|
||||
}
|
||||
6
scripts/storage/register-aliyun-qiniu-aliases.cjs
Normal file
6
scripts/storage/register-aliyun-qiniu-aliases.cjs
Normal file
@ -0,0 +1,6 @@
|
||||
/* Publish verified legacy aliases through the application's existing completed-archive resolver.
|
||||
* No source objects are deleted, and no pending archive jobs are created or overwritten. */
|
||||
const fs=require('fs'),path=require('path'),crypto=require('crypto');const root=process.env.MIGRATION_APP_ROOT||process.cwd(),dir=process.env.MIGRATION_RUN_DIR||'/var/lib/ai-chatbot/aliyun-qiniu-20261014';const {resourceFor}=require('./migrate-aliyun-to-qiniu.cjs');require(path.join(root,'node_modules/dotenv')).config({path:path.join(root,'.env.local')});const sql=require(path.join(root,'node_modules/postgres'))(process.env.POSTGRES_URL,{max:1,prepare:false});
|
||||
(async()=>{try{const [target]=await sql`select id,bucket,"publicUrl" from "StorageConfig" where type='qiniu-kodo' and "isDefault" and "isEnabled" limit 1`;if(!target?.publicUrl)throw Error('No default Qiniu target');const map=new Map();for(const line of fs.readFileSync(path.join(dir,'verified.jsonl'),'utf8').split('\n').filter(Boolean)){const r=JSON.parse(line),canonical=resourceFor(r.identity),expectedUrl=target.publicUrl.replace(/\/$/,'')+'/'+r.targetKey.split('/').map(encodeURIComponent).join('/');if(!canonical||canonical.id!==r.id||r.targetUrl!==expectedUrl||r.size<=0||(!r.md5&&!r.sha256))throw Error('Unverified storage alias');map.set(r.identity,r);}let inserted=0,processed=0,batch=[];
|
||||
async function flush(){if(!batch.length)return;const added=await sql.unsafe('insert into "ImageArchiveTask" (id,"sourceUrl","targetUrl","targetStorageConfigId",status,"attemptCount","archiveAfterAt","replaceAfterAt","archivedAt","replacedAt",metadata,"createdAt","updatedAt") select gen_random_uuid(),r.source,r.target,$2::uuid,\'replaced\',0,now(),now(),now(),now(),r.metadata,now(),now() from jsonb_to_recordset($1::jsonb) as r(source text,target text,metadata jsonb) on conflict ("sourceUrl") do nothing returning id',[sql.json(batch),target.id]);inserted+=added.length;processed+=batch.length;batch=[];if(processed%25000===0)console.log(JSON.stringify({processed,total:map.size,inserted}));}
|
||||
for(const r of map.values()){batch.push({source:r.identity,target:r.targetUrl,metadata:{legacyOssMigrationRun:'20261014',migrationResourceId:r.id,migrationVerifiedAt:r.verifiedAt,verifiedBytes:r.size,verifiedChecksum:r.md5||r.sha256}});if(batch.length>=500)await flush();}await flush();const result={aliasResources:map.size,inserted,existingPreserved:map.size-inserted,pendingJobsCreated:0,completedAt:new Date().toISOString()};fs.writeFileSync(path.join(dir,'alias-registration.json'),JSON.stringify(result,null,2),{mode:0o600});console.log('alias_registration',JSON.stringify(result));}finally{await sql.end()}})().catch(e=>{console.error('alias_registration_failed',e.name,String(e.message).replace(/https?:\/\/\S+/g,'[URL]').slice(0,180));process.exitCode=1});
|
||||
@ -31,6 +31,8 @@ try {
|
||||
onEvent: async event => { events.push(event.type); },
|
||||
onSandbox: async sandbox => console.log('SANDBOX', JSON.stringify(sandbox)),
|
||||
model: async body => {
|
||||
assert.notEqual(body.thinking?.type, 'enabled');
|
||||
assert(!body.reasoning_effort || ['none', 'disabled'].includes(body.reasoning_effort));
|
||||
requests++; if (requests > 12) return Response.json({ error: { message: 'fixture budget exceeded' } }, { status: 429 });
|
||||
const names = (body.tools || []).map(tool => tool.function?.name || tool.name || '');
|
||||
console.log('MODEL', requests, 'tools', names.filter(name => /readCanvas|renameNode/.test(name)));
|
||||
|
||||
@ -5,6 +5,7 @@ import { runZcodeCanvasTask } from '../../lib/ai/zcode-agent/task';
|
||||
import { AgentToolRejectedError, newAgentRunSnapshot, type AgentRunSnapshot } from '../../lib/ai/canvas-agent-runtime/protocol';
|
||||
import type { runNativeZcode } from '../../services/zcode-agent/runner.mjs';
|
||||
import { AgentImageGenerationError } from '../../lib/ai/runtime/image-generation-error';
|
||||
import { canvasAgentReplyText } from '../../lib/ai/zcode-agent/assistant-output';
|
||||
type NativeOptions = Parameters<typeof runNativeZcode>[0];
|
||||
function fixture(objective = '修改图片') {
|
||||
const snapshot = newAgentRunSnapshot(objective, []); let writes = 0; const saved: string[] = [];
|
||||
@ -24,6 +25,31 @@ function fixture(objective = '修改图片') {
|
||||
const result = { type: 'result', sessionId: 'sess_fixture', response: '已完成' };
|
||||
async function main() {
|
||||
let checks = 0;
|
||||
{
|
||||
const h=fixture('画布里有什么'); const emitted:string[]=[];
|
||||
const textPorts={...h.ports,onAssistantText:(delta:string)=>{emitted.push(delta);}};
|
||||
textPorts.native=async options=>{
|
||||
await options.onEvent?.({type:'turn.started',sessionId:'public-session'});
|
||||
await options.onEvent?.({type:'model.streaming',sessionId:'public-session',payload:{kind:'reasoning_delta',delta:'PRIVATE_THOUGHT'}});
|
||||
await options.onEvent?.({type:'model.streaming',sessionId:'child-session',payload:{kind:'text_delta',delta:'PRIVATE_CHILD'}});
|
||||
const event={type:'model.streaming',eventId:'text-1',sessionId:'public-session',payload:{kind:'text_delta',assistantMessageId:'a',delta:'画布里有一只小鸡。'}};
|
||||
await options.onEvent?.(event);await options.onEvent?.(event);
|
||||
assert.equal(emitted.join(''),'画布里有一只小鸡。','ordinary reply must arrive before native completion');
|
||||
return {...result,response:'画布里有一只小鸡。'};
|
||||
};
|
||||
assert.equal(await runZcodeCanvasTask(textPorts),'completed');
|
||||
assert.equal(canvasAgentReplyText(h.snapshot),'画布里有一只小鸡。');
|
||||
assert.equal(emitted.length,1);checks++;
|
||||
}
|
||||
{
|
||||
const h=fixture('分析图片');h.ports.native=async options=>{
|
||||
await options.onEvent?.({type:'turn.started',sessionId:'session'});
|
||||
await options.onEvent?.({type:'model.streaming',sessionId:'session',payload:{kind:'text_delta',assistantMessageId:'a',delta:'已读取图片,正在核对。'}});
|
||||
throw new Error('连接中断');
|
||||
};
|
||||
assert.equal(await runZcodeCanvasTask(h.ports),'needs_review');
|
||||
assert.match(canvasAgentReplyText(h.snapshot),/^已读取图片,正在核对。\n\n连接中断$/);checks++;
|
||||
}
|
||||
for (const passed of [true,false]) {
|
||||
const h=fixture();let paid=0,reviews=0;
|
||||
h.ports.tools.edit.execute=async()=>{paid++;return {updatedNodeIds:['a'],createdNodeIds:['real-result']};};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
export const UPSTREAM_COMMIT: string;
|
||||
export type NativeTool = { name: string; description: string; inputSchema: object; readOnly: boolean };
|
||||
export type NativeEvent = { type: string; sessionId?: string; traceId?: string; payload?: Record<string, unknown> };
|
||||
export type NativeEvent = { type: string; eventId?: string; sessionId?: string; traceId?: string; payload?: Record<string, unknown> };
|
||||
export type NativeResult = NativeEvent & { sessionId: string; response: string; usage?: Record<string, number>; projection?: Record<string, unknown> };
|
||||
export function verifyRuntime(runtimeDir: string): Promise<{ commit: string; sha256: string }>;
|
||||
export function runNativeZcode(options: {
|
||||
|
||||
@ -104,8 +104,9 @@ export async function runNativeZcode(options) {
|
||||
providerConfigRules: { providerRules: [{ providerId: 'canvas', providerName: 'Canvas Task Gateway', config: {
|
||||
group: 'standard-personal', access: { type: 'api-key', apiKey: token },
|
||||
api: { type: options.apiType || 'openai-chat-completions', baseUrl }, personalModelIds: [options.modelId],
|
||||
} }] }, modelConfigRules: { providerModelRules: [], manualProviderModelRules: [] },
|
||||
defaultModelSelection: { providerId: 'canvas', modelId: options.modelId },
|
||||
} }] }, modelConfigRules: { providerModelRules: [{ providerId: 'canvas', modelId: options.modelId,
|
||||
config: { optionSpecs: { reasoningLevel: { values: ['disabled'], map: '{}' } } } }], manualProviderModelRules: [] },
|
||||
defaultModelSelection: { providerId: 'canvas', modelId: options.modelId, options: { reasoningLevel: 'disabled' } },
|
||||
} };
|
||||
await writeFile(join(home, '.zcode/v2/provider_config.json'), JSON.stringify(personal), { mode: 0o600 });
|
||||
const bridge = linux ? '/runtime/bridge.mjs' : join(options.runtimeDir, 'bridge.mjs');
|
||||
|
||||
@ -50,6 +50,21 @@ function fixture() {
|
||||
return { paused, calls, circuit };
|
||||
}
|
||||
|
||||
test("Agent fallback strips native deep thinking and disables Qwen thinking", async () => {
|
||||
const original=global.fetch;const requests:any[]=[];
|
||||
global.fetch=async (_url,init)=>{requests.push(JSON.parse(String(init?.body)));return Response.json({choices:[{finish_reason:'stop',message:{role:'assistant',content:'普通回复'}}]});};
|
||||
try {
|
||||
for (const id of ['qwen3-vl-plus','generic-model']) {
|
||||
const model=entry(id);const gateway=await createZcodeModelGateway(model,undefined,[model],fixture().circuit);
|
||||
const response=await gateway.forward({...body,stream:false,thinking:{type:'enabled'},enable_thinking:true,reasoning_effort:'high',reasoning:{effort:'high'}},'/v1/chat/completions',new AbortController().signal);
|
||||
assert.equal(response.status,200);
|
||||
}
|
||||
assert.equal(requests[0].enable_thinking,false);
|
||||
for (const request of requests) {assert.equal(request.thinking,undefined);assert.equal(request.reasoning,undefined);assert.equal(request.reasoning_effort,undefined);}
|
||||
assert.equal(requests[1].enable_thinking,undefined);
|
||||
} finally {global.fetch=original;}
|
||||
});
|
||||
|
||||
test("a broken primary and a Responses-only backup work through one stable native protocol", async () => {
|
||||
const s = fixture(); const primary = entry("primary"); const backup = entry("backup", true);
|
||||
const original = global.fetch;
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@ -19,7 +23,9 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"strictNullChecks": true
|
||||
},
|
||||
@ -39,7 +45,15 @@
|
||||
".next-build-hicad-20260728-140320/types/**/*.ts",
|
||||
".next-build-hicad-20260728-140320/dev/types/**/*.ts",
|
||||
".next-candidate-home-banner-20260804-bg/types/**/*.ts",
|
||||
".next-candidate-home-banner-20260804-bg/dev/types/**/*.ts"
|
||||
".next-candidate-home-banner-20260804-bg/dev/types/**/*.ts",
|
||||
".next-extend-vip98k-20261012-1791792190533/types/**/*.ts",
|
||||
".next-extend-vip98k-20261012-1791792190533/dev/types/**/*.ts",
|
||||
".next-psd-dimensions-e6dbb612-20261015-1792035218252/types/**/*.ts",
|
||||
".next-psd-dimensions-e6dbb612-20261015-1792035218252/dev/types/**/*.ts",
|
||||
],
|
||||
"exclude": ["node_modules", "services/pascal-editor", "backups"]
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"services/pascal-editor",
|
||||
"backups"
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user