feat: add doubao video provider and persist video task recovery
This commit is contained in:
parent
0ac60e3c4e
commit
23521d8362
@ -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
72
app/(chat)/api/video-tasks/route.ts
Normal file
72
app/(chat)/api/video-tasks/route.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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: "视频生成",
|
||||
},
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user