feat: add doubao video provider and persist video task recovery
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-18 21:00:07 +08:00
parent 0ac60e3c4e
commit 23521d8362
13 changed files with 1200 additions and 371 deletions

View File

@ -121,7 +121,7 @@ type ToolConfig = {
type AiProvider = {
id: string;
name: string;
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu" | "volcano-multimodal";
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu" | "volcano-multimodal";
baseUrl: string;
apiKey: string;
isEnabled: boolean;
@ -373,6 +373,7 @@ const PROVIDER_TYPES = [
{ value: "gemini", label: "Gemini中转" },
{ value: "huanke-gemini", label: "环科Gemini生图" }, // 🆕 环科 Gemini 原生格式 API
{ value: "qianwen-video", label: "千问视频生成" },
{ value: "doubao-video", label: "豆包AI视频" },
{ value: "volcano-vision", label: "火山引擎视觉" },
{ value: "volcano-video", label: "火山引擎视频" },
{ value: "fal-video", label: "FAL 视频生成" },
@ -952,7 +953,7 @@ export function AdminSettingsClient() {
const [providerForm, setProviderForm] = useState({
id: "",
name: "",
type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu" | "volcano-multimodal",
type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu" | "volcano-multimodal",
baseUrl: "",
apiKey: "",
isEnabled: true,
@ -2978,6 +2979,7 @@ export function AdminSettingsClient() {
"gemini": "bg-purple-500",
"volcano-vision": "bg-orange-500",
"volcano-video": "bg-red-500",
"doubao-video": "bg-red-400",
"fal-video": "bg-indigo-500",
"alibaba": "bg-blue-500",
"alibaba-image": "bg-amber-500",
@ -5442,6 +5444,8 @@ export function AdminSettingsClient() {
? "https://fal.run"
: !prev.baseUrl && value === "fal-video"
? "https://fal.run"
: !prev.baseUrl && value === "doubao-video"
? "https://ark.cn-beijing.volces.com/api/v3"
: !prev.baseUrl && value === "zhipu"
? "https://open.bigmodel.cn/api/paas/v4"
: !prev.baseUrl && value === "volcano-multimodal"
@ -5473,6 +5477,8 @@ export function AdminSettingsClient() {
? "https://fal.run"
: providerForm.type === "fal-video"
? "https://fal.run"
: providerForm.type === "doubao-video"
? "https://ark.cn-beijing.volces.com/api/v3"
: providerForm.type === "zhipu"
? "https://open.bigmodel.cn/api/paas/v4"
: providerForm.type === "volcano-multimodal"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,72 @@
import { auth } from "@/app/(auth)/auth";
import { getChatById, getPendingVideoMessagesByChatId } from "@/lib/db/queries";
export const dynamic = "force-dynamic";
export const revalidate = 0;
export async function GET(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const { searchParams } = new URL(request.url);
const chatId = searchParams.get("chatId");
if (!chatId) {
return new Response(JSON.stringify({ error: "Missing chatId" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const selectedChat = await getChatById({ id: chatId });
if (!selectedChat || selectedChat.userId !== session.user.id) {
return new Response(JSON.stringify({ error: "Forbidden" }), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
const pendingMessages = await getPendingVideoMessagesByChatId({ chatId });
const tasks = pendingMessages.map((msg) => {
const metadata = (msg.videoMetadata as Record<string, any>) || {};
return {
messageId: msg.id,
taskId: typeof metadata.taskId === "string" ? metadata.taskId : null,
provider:
typeof metadata.provider === "string" ? metadata.provider : null,
model: typeof metadata.model === "string" ? metadata.model : null,
mode: typeof metadata.mode === "string" ? metadata.mode : null,
status:
typeof metadata.status === "string" ? metadata.status : "queued",
startedAt: metadata.startedAt ?? null,
updatedAt: metadata.updatedAt ?? null,
};
});
return new Response(
JSON.stringify({
chatId,
count: tasks.length,
tasks,
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store, max-age=0",
},
}
);
} catch (error) {
console.error("[Video Tasks API] GET error:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}

View File

@ -17,6 +17,7 @@ const providerTypes = [
"gemini",
"huanke-gemini",
"qianwen-video",
"doubao-video",
"volcano-vision",
"volcano-video",
"alibaba-image",
@ -48,6 +49,7 @@ const updateProviderSchema = z.object({
const ZHIPU_BASE_URL = "https://open.bigmodel.cn/api/paas/v4";
const VOLCANO_MULTIMODAL_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
const DOUBAO_VIDEO_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
function normalizeProviderTypeForStorage(
type?: ProviderType
@ -152,6 +154,8 @@ export async function POST(request: Request) {
? data.baseUrl || ZHIPU_BASE_URL
: data.type === "volcano-multimodal"
? data.baseUrl || VOLCANO_MULTIMODAL_BASE_URL
: data.type === "doubao-video"
? data.baseUrl || DOUBAO_VIDEO_BASE_URL
: data.baseUrl,
updatedBy: session.user.id,
});

View File

@ -1131,7 +1131,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}>("/api/site-settings", fetcher);
const aiDetailGenerationEnabled = siteSettings?.aiDetailGenerationEnabled === true;
const VIDEO_PROVIDER_TYPES = new Set(["volcano-video", "qianwen-video", "fal-video"]);
const VIDEO_PROVIDER_TYPES = new Set(["volcano-video", "doubao-video", "qianwen-video", "fal-video"]);
const visualModels =
modelsData?.models?.filter(
(model) => model.supportsImageGen || VIDEO_PROVIDER_TYPES.has(model.providerType)

View File

@ -159,6 +159,44 @@ function getMessageTextLength(message?: ChatMessage | null) {
.length;
}
function hasLikelyGenerationErrorMessage(message?: ChatMessage | null) {
if (!message?.parts || message.parts.length === 0) return false;
const text = message.parts
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
.map((part: any) => part.text)
.join("\n")
.trim();
if (!text) return false;
return /失败|超时|重试|error|failed/i.test(text);
}
function getAssistantMessagesAfterTargetUser(
dbMessages: ChatMessage[],
targetUserMessageId?: string | null,
) {
if (!Array.isArray(dbMessages) || dbMessages.length === 0) return [] as ChatMessage[];
let targetUserIndex = -1;
if (targetUserMessageId) {
targetUserIndex = dbMessages.findIndex(
(message) => message.role === "user" && message.id === targetUserMessageId,
);
}
if (targetUserIndex < 0) {
targetUserIndex = dbMessages.findLastIndex((message) => message.role === "user");
}
if (targetUserIndex < 0) {
return dbMessages.filter((message) => message.role === "assistant");
}
return dbMessages
.slice(targetUserIndex + 1)
.filter((message) => message.role === "assistant");
}
export function Chat({
id,
initialMessages,
@ -187,6 +225,7 @@ export function Chat({
// 🆕 持久化生成任务状态(解决用户离开再返回时加载状态丢失)
const { pendingGeneration, startGeneration, completeGeneration } = usePendingGeneration(id);
const pendingGenerationRef = useRef<PendingGeneration | null>(pendingGeneration);
// 🆕 标记是否已经尝试刷新过(避免无限刷新)
const hasTriedRefreshRef = useRef(false);
@ -198,6 +237,7 @@ export function Chat({
const [imageGenerationStartedAt, setImageGenerationStartedAt] = useState<number | null>(null);
const imageGenerationPhaseRef = useRef<ImageGenerationPhase>("idle");
const imagePlaceholderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startedGenerationMessageIdRef = useRef<string | null>(null);
// 🆕 存储通过 data-appendMessage 添加的消息,避免与 useChat 内部状态冲突
const [appendedMessages, setAppendedMessages] = useState<ChatMessage[]>([]);
@ -226,6 +266,10 @@ export function Chat({
}
}, [pendingGeneration, initialMessages, completeGeneration, router]);
useEffect(() => {
pendingGenerationRef.current = pendingGeneration;
}, [pendingGeneration]);
const updateImageGenerationPhase = useCallback((nextPhase: ImageGenerationPhase) => {
imageGenerationPhaseRef.current = nextPhase;
setImageGenerationPhase(nextPhase);
@ -331,6 +375,7 @@ export function Chat({
const currentModel = modelsData?.models?.find(m => m.id === currentModelId);
const isVideoModel =
currentModel?.providerType === "volcano-video" ||
currentModel?.providerType === "doubao-video" ||
currentModel?.providerType === "qianwen-video" ||
currentModel?.providerType === "fal-video";
const isImageModel = currentModel?.supportsImageGen === true;
@ -526,6 +571,7 @@ export function Chat({
imagePlaceholderTimerRef.current = null;
}
updateImageGenerationPhase("idle");
completeGeneration();
}
// 🆕 将消息添加到 appendedMessages 状态,而不是直接修改 useChat 的 messages
@ -562,10 +608,8 @@ export function Chat({
if (isLikelyGenerationError) {
console.log("[Chat] ❌ Generation error detected, reset loading state");
updateImageGenerationPhase("failed");
completeGeneration();
}
// 🆕 收到消息,清除持久化状态
completeGeneration();
} catch (e) {
console.error("[Chat] Failed to parse data-appendMessage:", e);
}
@ -573,9 +617,15 @@ export function Chat({
},
onFinish: async () => {
mutate(unstable_serialize(getChatHistoryPaginationKey));
// 🆕 生成完成,清除持久化状态
completeGeneration();
console.log("[Chat] onFinish called");
const pendingSnapshot = pendingGenerationRef.current;
const keepPendingUntilDbSync =
pendingSnapshot?.modelType === "gemini" ||
pendingSnapshot?.modelType === "volcano-vision" ||
pendingSnapshot?.modelType === "alibaba-image";
if (!keepPendingUntilDbSync) {
completeGeneration();
}
// 🆕 从数据库重新加载消息,确保 data-appendMessage 的消息正确显示
// 这是根治方案 B:后端已保存消息到数据库,前端从数据库重新加载
@ -596,40 +646,65 @@ export function Chat({
const response = await fetch(`/api/messages?chatId=${id}&_t=${Date.now()}`, {
cache: "no-store",
});
if (!response.ok) return { hasImage: false };
if (!response.ok) return { hasImage: false, hasError: false };
const data = await response.json();
if (!data.messages || !Array.isArray(data.messages)) {
return { hasImage: false };
return { hasImage: false, hasError: false };
}
const dbMessages = data.messages as ChatMessage[];
console.log("[Chat] Reloading messages from database:", dbMessages.length);
mergeMessagesFromDatabase(dbMessages);
const hasPersistedImage = dbMessages.some((message) => hasImageMarkdown((message as any).parts));
return { hasImage: hasPersistedImage };
const targetUserMessageId =
pendingSnapshot?.userMessageId || startedGenerationMessageIdRef.current;
const assistantAfterTargetUser = getAssistantMessagesAfterTargetUser(
dbMessages,
targetUserMessageId,
);
const hasPersistedImage = assistantAfterTargetUser.some((message) =>
hasImageMarkdown((message as any).parts),
);
const hasPersistedError = assistantAfterTargetUser.some((message) =>
hasLikelyGenerationErrorMessage(message),
);
return { hasImage: hasPersistedImage, hasError: hasPersistedError };
};
// 延迟一小段时间确保数据库写入完成
await new Promise((resolve) => setTimeout(resolve, 500));
let { hasImage } = await reloadMessagesOnce();
if (!hasImage && imageGenerationPhaseRef.current === "finalizing") {
let { hasImage, hasError } = await reloadMessagesOnce();
if (
!hasImage &&
!hasError &&
(imageGenerationPhaseRef.current === "queued" ||
imageGenerationPhaseRef.current === "processing" ||
imageGenerationPhaseRef.current === "finalizing" ||
keepPendingUntilDbSync)
) {
for (let attempt = 1; attempt <= 6; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 600));
const result = await reloadMessagesOnce();
hasImage = result.hasImage;
if (hasImage) break;
hasError = result.hasError;
if (hasImage || hasError) break;
}
}
if (
hasImage ||
(imageGenerationPhaseRef.current !== "idle" &&
imageGenerationPhaseRef.current !== "failed")
) {
if (hasImage) {
updateImageGenerationPhase("idle");
completeGeneration();
} else if (hasError) {
updateImageGenerationPhase("failed");
completeGeneration();
} else if (
imageGenerationPhaseRef.current !== "idle" &&
imageGenerationPhaseRef.current !== "failed"
) {
// 保持 pending,让轮询继续从数据库拉取结果,避免前端“空白直到刷新”
console.log("[Chat] Waiting for persisted result, keep pending state");
}
} catch (error) {
console.error("[Chat] Failed to reload messages:", error);
@ -640,7 +715,9 @@ export function Chat({
imageGenerationPhaseRef.current === "processing" ||
imageGenerationPhaseRef.current === "finalizing"
) {
updateImageGenerationPhase("idle");
if (!keepPendingUntilDbSync) {
updateImageGenerationPhase("idle");
}
}
}
},
@ -650,6 +727,7 @@ export function Chat({
imagePlaceholderTimerRef.current = null;
}
updateImageGenerationPhase("failed");
completeGeneration();
if (error instanceof ChatSDKError) {
// Check if it's a credit card error
@ -664,6 +742,94 @@ export function Chat({
},
});
useEffect(() => {
const isImagePending =
pendingGeneration?.modelType === "gemini" ||
pendingGeneration?.modelType === "volcano-vision" ||
pendingGeneration?.modelType === "alibaba-image";
if (!pendingGeneration || !isImagePending) return;
let cancelled = false;
const maxDurationMs = 10 * 60 * 1000;
const mergeMessagesFromDatabase = (dbMessages: ChatMessage[]) => {
setMessages((prev) => {
const mergedMap = new Map<string, ChatMessage>();
prev.forEach((message) => mergedMap.set(message.id, message));
dbMessages.forEach((message) => mergedMap.set(message.id, message));
return sortMessagesByCreatedAt(Array.from(mergedMap.values()));
});
const persistedIds = new Set<string>(dbMessages.map((message) => message.id));
setAppendedMessages((prev) => prev.filter((message) => !persistedIds.has(message.id)));
};
const pollMessages = async () => {
if (cancelled) return;
if (Date.now() - pendingGeneration.timestamp > maxDurationMs) {
console.warn("[Chat] Polling timeout for pending generation");
completeGeneration();
if (
imageGenerationPhaseRef.current === "queued" ||
imageGenerationPhaseRef.current === "processing" ||
imageGenerationPhaseRef.current === "finalizing"
) {
updateImageGenerationPhase("failed");
}
return;
}
try {
const response = await fetch(`/api/messages?chatId=${id}&_t=${Date.now()}`, {
cache: "no-store",
});
if (!response.ok) return;
const data = await response.json();
if (!data.messages || !Array.isArray(data.messages)) return;
const dbMessages = data.messages as ChatMessage[];
mergeMessagesFromDatabase(dbMessages);
const assistantAfterTargetUser = getAssistantMessagesAfterTargetUser(
dbMessages,
pendingGeneration.userMessageId,
);
if (assistantAfterTargetUser.length === 0) return;
const hasImage = assistantAfterTargetUser.some((message) =>
hasImageMarkdown((message as any).parts),
);
const hasError = assistantAfterTargetUser.some((message) =>
hasLikelyGenerationErrorMessage(message)
);
if (!hasImage && !hasError) return;
console.log("[Chat] Polling detected finished assistant response");
completeGeneration();
if (hasImage) {
updateImageGenerationPhase("idle");
} else if (
imageGenerationPhaseRef.current === "queued" ||
imageGenerationPhaseRef.current === "processing" ||
imageGenerationPhaseRef.current === "finalizing"
) {
updateImageGenerationPhase("failed");
}
} catch (error) {
console.error("[Chat] Polling messages failed:", error);
}
};
pollMessages();
const timer = setInterval(pollMessages, 800);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [pendingGeneration, id, setMessages, completeGeneration, updateImageGenerationPhase]);
// 🆕 保存 initialMessages 的引用,防止历史消息丢失
// 当 useChat 内部状态重置时,我们仍然有初始消息
const initialMessagesRef = useRef<ChatMessage[]>(initialMessages);
@ -683,9 +849,31 @@ export function Chat({
useEffect(() => {
console.log(`[Chat] Status changed: ${status}`);
if (status !== "submitted") {
startedGenerationMessageIdRef.current = null;
}
// 🆕 当状态变为 submitted 时,记录生成任务
if (status === "submitted") {
const latestMessage = messages.length > 0 ? messages[messages.length - 1] : null;
const latestUserMessageId = latestMessage?.role === "user" ? latestMessage.id : null;
if (latestUserMessageId && startedGenerationMessageIdRef.current === latestUserMessageId) {
return;
}
const latestUserHasImageInput =
latestMessage?.role === "user" &&
Array.isArray(latestMessage.parts) &&
latestMessage.parts.some((part: any) => {
if (!part) return false;
if (part.type === "file" || part.type === "image" || part.type === "image_url") return true;
if (part.type === "text" && typeof part.text === "string") {
return /\!\[thumb\||\!\[Generated Image/i.test(part.text);
}
return false;
});
// 判断模型类型 - 使用 ref 获取最新的 modelsData
const models = modelsDataRef.current?.models;
const model = models?.find((m: any) => m.id === currentModelId);
@ -694,7 +882,7 @@ export function Chat({
// 🆕 gemini-image 和 huanke-gemini 都视为 gemini 类型
if (model?.providerType === "gemini" || model?.providerType === "gemini-image" || model?.providerType === "huanke-gemini") modelType = "gemini";
else if (model?.providerType === "volcano-vision") modelType = "volcano-vision";
else if (model?.providerType === "volcano-video") modelType = "volcano-video";
else if (model?.providerType === "volcano-video" || model?.providerType === "doubao-video") modelType = "volcano-video";
else if (model?.providerType === "qianwen-video") modelType = "qianwen-video";
else if (model?.providerType === "fal-video") modelType = "fal-video";
else if (
@ -702,15 +890,23 @@ export function Chat({
model?.providerType === "ruxa-image" ||
model?.providerType === "fal-image"
) modelType = "alibaba-image";
// 智能自动模式下,如果当前轮输入包含图片,默认按图片生成链路跟踪
if (currentModelId === "auto" && latestUserHasImageInput) {
modelType = "gemini";
}
console.log(`[Chat] 🚀 记录生成任务: modelType=${modelType}, modelId=${currentModelId}`);
startGeneration(currentModelId, modelType);
startGeneration(currentModelId, modelType, latestUserMessageId || undefined);
if (latestUserMessageId) {
startedGenerationMessageIdRef.current = latestUserMessageId;
}
if (modelType === "gemini" || modelType === "volcano-vision" || modelType === "alibaba-image") {
updateImageGenerationPhase("queued");
}
}
}, [status, currentModelId, startGeneration, updateImageGenerationPhase]);
}, [status, currentModelId, messages, startGeneration, updateImageGenerationPhase]);
const searchParams = useSearchParams();
const query = searchParams.get("query");
@ -1510,6 +1706,87 @@ export function Chat({
}
};
// 轮询视频任务状态:解决刷新后“处理中”无法自动恢复结果的问题
useEffect(() => {
if (isReadonly) return;
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let previousPendingCount = -1;
const mergeMessagesFromDatabase = (dbMessages: ChatMessage[]) => {
setMessages((prev) => {
const mergedMap = new Map<string, ChatMessage>();
prev.forEach((message) => mergedMap.set(message.id, message));
dbMessages.forEach((message) => mergedMap.set(message.id, message));
return sortMessagesByCreatedAt(Array.from(mergedMap.values()));
});
const persistedIds = new Set<string>(dbMessages.map((message) => message.id));
setAppendedMessages((prev) => prev.filter((message) => !persistedIds.has(message.id)));
};
const resolveModelTypeByProvider = (
provider?: string
): PendingGeneration["modelType"] => {
if (provider === "qianwen-video") return "qianwen-video";
if (provider === "fal-video") return "fal-video";
if (provider === "volcano-video" || provider === "doubao-video") return "volcano-video";
return "other";
};
const pollPendingVideoTasks = async () => {
try {
const response = await fetch(`/api/video-tasks?chatId=${id}&_t=${Date.now()}`, {
cache: "no-store",
});
if (!response.ok) return;
const data = await response.json();
const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
const pendingCount = tasks.length;
if (pendingCount > 0 && !pendingGenerationRef.current) {
const firstTask = tasks[0] as { provider?: string } | undefined;
startGeneration(
currentModelIdRef.current,
resolveModelTypeByProvider(firstTask?.provider),
);
}
if (previousPendingCount > 0 && pendingCount === 0) {
const msgResponse = await fetch(`/api/messages?chatId=${id}&_t=${Date.now()}`, {
cache: "no-store",
});
if (msgResponse.ok) {
const msgData = await msgResponse.json();
if (Array.isArray(msgData?.messages)) {
mergeMessagesFromDatabase(msgData.messages as ChatMessage[]);
}
}
completeGeneration();
}
previousPendingCount = pendingCount;
} catch (error) {
console.warn("[Chat] video task polling failed", error);
} finally {
if (!cancelled) {
timer = setTimeout(pollPendingVideoTasks, 5000);
}
}
};
pollPendingVideoTasks();
return () => {
cancelled = true;
if (timer) {
clearTimeout(timer);
}
};
}, [id, isReadonly, setMessages, setAppendedMessages, startGeneration, completeGeneration]);
useAutoResume({
autoResume,
initialMessages,

View File

@ -139,6 +139,7 @@ function PureMessages({
(m: DynamicModel) =>
m.id === selectedModelId &&
(m.providerType === "volcano-video" ||
m.providerType === "doubao-video" ||
m.modelId?.toLowerCase().includes("video") ||
m.modelId?.toLowerCase().includes("sora") ||
m.displayName?.includes("视频"))
@ -327,6 +328,13 @@ function PureMessages({
})
);
const waitingForFirstAgentReply = !hasAssistantTextReply && !hasImageContent;
const shouldKeepThinking =
status === "streaming" ||
status === "submitted" ||
(status === "ready" && waitingForFirstAgentReply && !!pendingGeneration) ||
(lastMessage?.role === "user" && waitingForFirstAgentReply);
if (hasImageContent) {
// 有图片内容,隐藏占位图
console.log("[Messages useEffect] 有图片内容,隐藏占位图");
@ -339,9 +347,9 @@ function PureMessages({
// 收到了 AI 文字回复但还没开始生图(延迟期间),隐藏占位图让用户看文字
console.log("[Messages useEffect] 已收到AI文字回复,延迟期间隐藏占位图");
setShowThinkingWithDelay(false);
} else if (status === "streaming" || status === "submitted") {
// 正在处理中(分析意图或流式传输),显示普通思考占位图
console.log("[Messages useEffect] streaming/submitted,显示思考占位图");
} else if (shouldKeepThinking) {
// 正在处理中(分析意图/流式传输)或 SSE 首包延迟,保持思考占位图
console.log("[Messages useEffect] keep thinking placeholder");
setShowThinkingWithDelay(true);
} else {
// ready 或其他状态:不显示占位图
@ -377,7 +385,7 @@ function PureMessages({
setShowThinkingWithDelay(false);
}
}
}, [status, messages, isGeminiModel, isQianwenVideoModel, isVolcanoVisionModel, isVideoModel, isAlibabaImageModel, selectedModelId, isGeneratingImageFlag]);
}, [status, messages, isGeminiModel, isQianwenVideoModel, isVolcanoVisionModel, isVideoModel, isAlibabaImageModel, selectedModelId, isGeneratingImageFlag, pendingGeneration]);
// 🆕 智能自动模式:确保文字可见后再显示生图占位图
useEffect(() => {

View File

@ -1397,7 +1397,7 @@ function PureModelSelectorCompact({
<span className="flex h-5 w-5 items-center justify-center">
{(() => {
const providerType = model.providerType || "";
if (providerType === "volcano-video" || providerType === "qianwen-video" || providerType === "fal-video") {
if (providerType === "volcano-video" || providerType === "doubao-video" || providerType === "qianwen-video" || providerType === "fal-video") {
return <Video className="h-4 w-4" strokeWidth={2.2} />;
}
if (

View File

@ -130,6 +130,17 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
return qianwenVideoProvider as any;
}
case "doubao-video": {
providerInstance = createOpenAI({
apiKey: provider.apiKey,
baseURL: provider.baseUrl || "https://ark.cn-beijing.volces.com/api/v3",
});
console.log(
`[Provider] Created Doubao video provider: ${provider.baseUrl || "https://ark.cn-beijing.volces.com/api/v3"}`
);
break;
}
case "fal-video": {
// FAL 视频生成服务(Kling O3 等)
const falVideoProvider = createFalVideo({

View File

@ -52,7 +52,7 @@ const INTENT_MODEL_MAP: Record<
// 视频生成
video_gen: {
providerTypes: ["volcano-video", "qianwen-video", "fal-video"],
providerTypes: ["doubao-video", "volcano-video", "qianwen-video", "fal-video"],
description: "视频生成",
},

View File

@ -67,7 +67,7 @@ const runMigrate = async () => {
ADD CONSTRAINT "AiProvider_type_check"
CHECK (type IN (
'alibaba', 'openai', 'custom', 'local', 'gemini',
'huanke-gemini', 'qianwen-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'ruxa-image', 'fal-image', 'fal-video', 'zhipu'
'huanke-gemini', 'qianwen-video', 'doubao-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'ruxa-image', 'fal-image', 'fal-video', 'zhipu'
));
`);
const end = Date.now();

View File

@ -312,6 +312,87 @@ export async function updateMessageContent({
}
}
export async function updateMessageById({
messageId,
chatId,
parts,
videoStorageStatus,
temporaryVideoUrl,
permanentVideoUrl,
archivedAt,
videoMetadata,
}: {
messageId: string;
chatId: string;
parts?: DBMessage["parts"];
videoStorageStatus?: DBMessage["videoStorageStatus"] | null;
temporaryVideoUrl?: string | null;
permanentVideoUrl?: string | null;
archivedAt?: Date | null;
videoMetadata?: DBMessage["videoMetadata"] | null;
}) {
try {
const updateData: Partial<DBMessage> = {};
if (parts !== undefined) {
updateData.parts = parts;
}
if (videoStorageStatus !== undefined) {
updateData.videoStorageStatus = videoStorageStatus;
}
if (temporaryVideoUrl !== undefined) {
updateData.temporaryVideoUrl = temporaryVideoUrl;
}
if (permanentVideoUrl !== undefined) {
updateData.permanentVideoUrl = permanentVideoUrl;
}
if (archivedAt !== undefined) {
updateData.archivedAt = archivedAt;
}
if (videoMetadata !== undefined) {
updateData.videoMetadata = videoMetadata;
}
if (Object.keys(updateData).length === 0) {
return [];
}
return await db
.update(message)
.set(updateData)
.where(and(eq(message.id, messageId), eq(message.chatId, chatId)))
.returning();
} catch (_error) {
throw new ChatSDKError("bad_request:database", "Failed to update message");
}
}
export async function getPendingVideoMessagesByChatId({
chatId,
}: {
chatId: string;
}) {
try {
return await db
.select()
.from(message)
.where(
and(
eq(message.chatId, chatId),
eq(message.role, "assistant"),
sql`${message.videoMetadata} is not null`,
sql`coalesce(${message.videoMetadata} ->> 'status', '') in ('queued', 'running')`
)
)
.orderBy(desc(message.createdAt));
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",
"Failed to get pending video messages by chat id"
);
}
}
export async function getMessagesByChatId({ id }: { id: string }) {
try {
return await db
@ -1089,7 +1170,7 @@ export async function createAiProvider({
updatedBy,
}: {
name: string;
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu";
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu";
baseUrl: string;
apiKey: string;
isEnabled?: boolean;
@ -1127,7 +1208,7 @@ export async function updateAiProvider({
}: {
id: string;
name?: string;
type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu";
type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu";
baseUrl?: string;
apiKey?: string;
isEnabled?: boolean;

View File

@ -258,7 +258,7 @@ export type ToolConfig = InferSelectModel<typeof toolConfig>;
export const aiProvider = pgTable("AiProvider", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
name: varchar("name", { length: 128 }).notNull(), // 提供商名称,如 "阿里云通义千问"
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "huanke-gemini", "qianwen-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "fal-video", "zhipu"] })
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "huanke-gemini", "qianwen-video", "doubao-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "fal-video", "zhipu"] })
.notNull()
.default("custom"), // 提供商类型
baseUrl: text("baseUrl").notNull(), // API 基础 URL