feat(聊天): 集成视频搜索功能和优化Stream处理
- useStreamChat.ts Hook更新: - 新增 SearchVideoData 类型定义 - StreamMessage 支持 tool_search_videos 事件类型 - ChatMessage 添加 searchVideos 属性 - 添加 saveMessageSearchVideos 函数保存视频到数据库 - pendingSearchVideosRef 临时存储等待messageId的视频 - 处理视频搜索事件更新UI状态 - chat/route.ts API更新: - 创建 createSafeStreamWriter 安全写入器 - 解决客户端断开连接时的"Controller is already closed"错误 - 所有 controller.enqueue 替换为 safeWriter.write - 添加 tool_search_videos 事件发送逻辑 - mita_search 工具描述更新支持视频搜索 - Claude/OpenAI/Codex格式工具定义添加video scope
This commit is contained in:
parent
2852f746f0
commit
e0b82b6257
@ -77,6 +77,57 @@ function normalizeBaseUrl(url: string): string {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建安全的 Stream 写入器
|
||||
* 用于处理客户端断开连接时的安全写入,避免 "Controller is already closed" 错误
|
||||
*/
|
||||
function createSafeStreamWriter(
|
||||
controller: ReadableStreamDefaultController,
|
||||
encoder: TextEncoder
|
||||
) {
|
||||
let isClosed = false;
|
||||
|
||||
return {
|
||||
/**
|
||||
* 安全地向 stream 写入数据
|
||||
* 如果 controller 已关闭,则静默忽略
|
||||
*/
|
||||
write(data: object): boolean {
|
||||
if (isClosed) return false;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
return true;
|
||||
} catch {
|
||||
// Controller 已关闭,标记状态并静默处理
|
||||
isClosed = true;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 安全地关闭 stream
|
||||
*/
|
||||
close(): boolean {
|
||||
if (isClosed) return false;
|
||||
try {
|
||||
controller.close();
|
||||
isClosed = true;
|
||||
return true;
|
||||
} catch {
|
||||
isClosed = true;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查 stream 是否已关闭
|
||||
*/
|
||||
get closed(): boolean {
|
||||
return isClosed;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 默认系统提示词 - 用于生成更详细、更有结构的回复
|
||||
const DEFAULT_SYSTEM_PROMPT = `你是一个专业、友好的 AI 助手。请遵循以下规则来回复用户:
|
||||
|
||||
@ -278,6 +329,9 @@ export async function POST(request: Request) {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// 创建安全的 stream 写入器用于最终的 done/error 事件
|
||||
const safeWriter = createSafeStreamWriter(controller, encoder);
|
||||
|
||||
try {
|
||||
const cchUrl = settings.cchUrl || process.env.CCH_DEFAULT_URL || 'https://claude.leocoder.cn/';
|
||||
const apiFormat = (settings.apiFormat as 'claude' | 'openai') || 'claude';
|
||||
@ -423,23 +477,23 @@ export async function POST(request: Request) {
|
||||
.where(eq(conversations.conversationId, conversationId));
|
||||
|
||||
// 发送完成事件
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'done',
|
||||
messageId: assistantMessageId,
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
usedTools: usedTools.length > 0 ? usedTools : undefined,
|
||||
})}\n\n`));
|
||||
});
|
||||
|
||||
controller.close();
|
||||
safeWriter.close();
|
||||
} catch (error) {
|
||||
console.error('Stream error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'error',
|
||||
error: errorMessage,
|
||||
})}\n\n`));
|
||||
controller.close();
|
||||
});
|
||||
safeWriter.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -522,6 +576,9 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
|
||||
metasoApiKey,
|
||||
} = params;
|
||||
|
||||
// 创建安全的 stream 写入器
|
||||
const safeWriter = createSafeStreamWriter(controller, encoder);
|
||||
|
||||
// 构建 Codex Response API 格式的输入(过滤空内容的消息)
|
||||
const inputItems: CodexInputItem[] = [
|
||||
...historyMessages
|
||||
@ -662,20 +719,20 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
|
||||
const delta = event.delta || '';
|
||||
currentTextContent += delta;
|
||||
fullContent += delta;
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'text',
|
||||
content: delta,
|
||||
})}\n\n`));
|
||||
});
|
||||
} else if (event.type === 'response.content_part.delta') {
|
||||
// 内容部分增量(另一种格式)
|
||||
const delta = event.delta?.text || event.delta || '';
|
||||
if (delta) {
|
||||
currentTextContent += delta;
|
||||
fullContent += delta;
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'text',
|
||||
content: delta,
|
||||
})}\n\n`));
|
||||
});
|
||||
}
|
||||
} else if (event.type === 'response.function_call_arguments.delta') {
|
||||
// 函数调用参数增量
|
||||
@ -692,11 +749,11 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
|
||||
arguments: '',
|
||||
};
|
||||
hasToolUse = true;
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'tool_use_start',
|
||||
id: item.call_id,
|
||||
name: item.name,
|
||||
})}\n\n`));
|
||||
});
|
||||
}
|
||||
} else if (event.type === 'response.output_item.done') {
|
||||
// 输出项完成
|
||||
@ -708,12 +765,12 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
|
||||
currentFunctionCall.arguments = item.arguments || currentFunctionCall.arguments;
|
||||
functionCalls.push({ ...currentFunctionCall });
|
||||
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'tool_use_complete',
|
||||
id: currentFunctionCall.call_id,
|
||||
name: currentFunctionCall.name,
|
||||
input: JSON.parse(currentFunctionCall.arguments || '{}'),
|
||||
})}\n\n`));
|
||||
});
|
||||
currentFunctionCall = null;
|
||||
}
|
||||
} else if (event.type === 'response.completed') {
|
||||
@ -748,18 +805,18 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
|
||||
if (!usedTools.includes(fc.name)) {
|
||||
usedTools.push(fc.name);
|
||||
// 发送实时工具使用事件
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'tool_used',
|
||||
toolName: fc.name,
|
||||
})}\n\n`));
|
||||
});
|
||||
}
|
||||
|
||||
// 发送工具执行开始事件
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'tool_execution_start',
|
||||
id: fc.call_id,
|
||||
name: fc.name,
|
||||
})}\n\n`));
|
||||
});
|
||||
|
||||
// 解析工具参数
|
||||
let toolInput: Record<string, unknown> = {};
|
||||
@ -773,32 +830,42 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
|
||||
const result = await executeTool(fc.name, toolInput, { metasoApiKey });
|
||||
|
||||
// 发送工具执行结果事件
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'tool_execution_result',
|
||||
id: fc.call_id,
|
||||
name: fc.name,
|
||||
success: result.success,
|
||||
result: result.displayResult,
|
||||
images: result.images,
|
||||
})}\n\n`));
|
||||
});
|
||||
|
||||
// 如果有搜索图片结果,发送专门的图片事件
|
||||
if (result.searchImages && result.searchImages.length > 0) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'tool_search_images',
|
||||
id: fc.call_id,
|
||||
name: fc.name,
|
||||
searchImages: result.searchImages,
|
||||
})}\n\n`));
|
||||
});
|
||||
}
|
||||
|
||||
// 如果有搜索视频结果,发送专门的视频事件
|
||||
if (result.searchVideos && result.searchVideos.length > 0) {
|
||||
safeWriter.write({
|
||||
type: 'tool_search_videos',
|
||||
id: fc.call_id,
|
||||
name: fc.name,
|
||||
searchVideos: result.searchVideos,
|
||||
});
|
||||
}
|
||||
|
||||
// 将工具结果显示给用户
|
||||
const toolDisplayText = `\n\n${result.displayResult}\n\n`;
|
||||
fullContent += toolDisplayText;
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'text',
|
||||
content: toolDisplayText,
|
||||
})}\n\n`));
|
||||
});
|
||||
|
||||
// 将工具结果添加到输入历史(Codex 格式)
|
||||
inputItems.push({
|
||||
@ -870,6 +937,9 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
|
||||
metasoApiKey,
|
||||
} = params;
|
||||
|
||||
// 创建安全的 stream 写入器
|
||||
const safeWriter = createSafeStreamWriter(controller, encoder);
|
||||
|
||||
// 构建消息历史(过滤空内容的消息)
|
||||
const messageHistory: APIMessage[] = historyMessages
|
||||
.filter((msg) => msg.content && msg.content.trim() !== '')
|
||||
@ -1030,17 +1100,17 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
|
||||
if (delta.type === 'thinking_delta') {
|
||||
currentThinkingContent += delta.thinking || '';
|
||||
thinkingContent += delta.thinking || '';
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'thinking',
|
||||
content: delta.thinking || '',
|
||||
})}\n\n`));
|
||||
});
|
||||
} else if (delta.type === 'text_delta') {
|
||||
currentTextContent += delta.text || '';
|
||||
fullContent += delta.text || '';
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'text',
|
||||
content: delta.text || '',
|
||||
})}\n\n`));
|
||||
});
|
||||
} else if (delta.type === 'input_json_delta') {
|
||||
if (currentToolUse) {
|
||||
currentToolUse.inputJson += delta.partial_json || '';
|
||||
@ -1064,11 +1134,11 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
|
||||
name: event.content_block.name,
|
||||
inputJson: '',
|
||||
};
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'tool_use_start',
|
||||
id: event.content_block.id,
|
||||
name: event.content_block.name,
|
||||
})}\n\n`));
|
||||
});
|
||||
}
|
||||
} else if (event.type === 'content_block_stop') {
|
||||
if (currentToolUse) {
|
||||
@ -1079,12 +1149,12 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
|
||||
name: currentToolUse.name,
|
||||
input: toolInput,
|
||||
});
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'tool_use_complete',
|
||||
id: currentToolUse.id,
|
||||
name: currentToolUse.name,
|
||||
input: toolInput,
|
||||
})}\n\n`));
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to parse tool input:', e);
|
||||
}
|
||||
@ -1139,28 +1209,28 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
|
||||
if (!usedTools.includes(tc.name)) {
|
||||
usedTools.push(tc.name);
|
||||
// 发送实时工具使用事件
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'tool_used',
|
||||
toolName: tc.name,
|
||||
})}\n\n`));
|
||||
});
|
||||
}
|
||||
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'tool_execution_start',
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
})}\n\n`));
|
||||
});
|
||||
|
||||
const result = await executeTool(tc.name, tc.input, { metasoApiKey });
|
||||
|
||||
if (result.requiresPyodide) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'pyodide_execution_required',
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
code: result.code,
|
||||
language: result.language,
|
||||
})}\n\n`));
|
||||
});
|
||||
|
||||
toolResults.push({
|
||||
type: 'tool_result',
|
||||
@ -1170,31 +1240,41 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
|
||||
continue;
|
||||
}
|
||||
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'tool_execution_result',
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
success: result.success,
|
||||
result: result.displayResult,
|
||||
images: result.images,
|
||||
})}\n\n`));
|
||||
});
|
||||
|
||||
// 如果有搜索图片结果,发送专门的图片事件
|
||||
if (result.searchImages && result.searchImages.length > 0) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'tool_search_images',
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
searchImages: result.searchImages,
|
||||
})}\n\n`));
|
||||
});
|
||||
}
|
||||
|
||||
// 如果有搜索视频结果,发送专门的视频事件
|
||||
if (result.searchVideos && result.searchVideos.length > 0) {
|
||||
safeWriter.write({
|
||||
type: 'tool_search_videos',
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
searchVideos: result.searchVideos,
|
||||
});
|
||||
}
|
||||
|
||||
const toolDisplayText = `\n\n${result.displayResult}\n\n`;
|
||||
fullContent += toolDisplayText;
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'text',
|
||||
content: toolDisplayText,
|
||||
})}\n\n`));
|
||||
});
|
||||
|
||||
toolResults.push({
|
||||
type: 'tool_result',
|
||||
@ -1290,6 +1370,9 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
|
||||
metasoApiKey,
|
||||
} = params;
|
||||
|
||||
// 创建安全的 stream 写入器
|
||||
const safeWriter = createSafeStreamWriter(controller, encoder);
|
||||
|
||||
// 构建 OpenAI 格式的消息历史(过滤空内容的消息)
|
||||
const openaiMessages: OpenAICompatibleMessage[] = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
@ -1442,10 +1525,10 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
|
||||
if (thinkPart) {
|
||||
thinkingContent += thinkPart;
|
||||
// 发送 thinking 事件(让前端可以展示折叠的思考内容)
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'thinking',
|
||||
content: thinkPart,
|
||||
})}\n\n`));
|
||||
});
|
||||
}
|
||||
// 移除已处理的内容和结束标签
|
||||
pendingBuffer = pendingBuffer.slice(endTagIndex + 8); // 8 = '</think>'.length
|
||||
@ -1459,10 +1542,10 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
|
||||
|
||||
if (safePart) {
|
||||
thinkingContent += safePart;
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'thinking',
|
||||
content: safePart,
|
||||
})}\n\n`));
|
||||
});
|
||||
}
|
||||
pendingBuffer = keepPart;
|
||||
}
|
||||
@ -1479,10 +1562,10 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
|
||||
if (textPart) {
|
||||
currentTextContent += textPart;
|
||||
fullContent += textPart;
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'text',
|
||||
content: textPart,
|
||||
})}\n\n`));
|
||||
});
|
||||
}
|
||||
// 移除已处理的内容和开始标签
|
||||
pendingBuffer = pendingBuffer.slice(startTagIndex + 7); // 7 = '<think>'.length
|
||||
@ -1500,10 +1583,10 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
|
||||
if (safePart) {
|
||||
currentTextContent += safePart;
|
||||
fullContent += safePart;
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'text',
|
||||
content: safePart,
|
||||
})}\n\n`));
|
||||
});
|
||||
}
|
||||
pendingBuffer = keepPart;
|
||||
// 等待更多数据
|
||||
@ -1512,10 +1595,10 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
|
||||
// 安全输出所有内容
|
||||
currentTextContent += pendingBuffer;
|
||||
fullContent += pendingBuffer;
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'text',
|
||||
content: pendingBuffer,
|
||||
})}\n\n`));
|
||||
});
|
||||
pendingBuffer = '';
|
||||
}
|
||||
}
|
||||
@ -1537,11 +1620,11 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
|
||||
});
|
||||
|
||||
if (toolCall.id) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'tool_use_start',
|
||||
id: toolCall.id,
|
||||
name: toolCall.function?.name || '',
|
||||
})}\n\n`));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1579,18 +1662,18 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
|
||||
// 如果还在 thinking 模式,说明 </think> 标签没有正常闭合
|
||||
// 将剩余内容作为 thinking 内容处理
|
||||
thinkingContent += pendingBuffer;
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'thinking',
|
||||
content: pendingBuffer,
|
||||
})}\n\n`));
|
||||
});
|
||||
} else {
|
||||
// 普通文本模式,输出剩余内容
|
||||
currentTextContent += pendingBuffer;
|
||||
fullContent += pendingBuffer;
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'text',
|
||||
content: pendingBuffer,
|
||||
})}\n\n`));
|
||||
});
|
||||
}
|
||||
pendingBuffer = '';
|
||||
}
|
||||
@ -1599,12 +1682,12 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
|
||||
for (const [, tc] of currentToolCallsMap) {
|
||||
if (tc.id && tc.name) {
|
||||
toolCalls.push(tc);
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'tool_use_complete',
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
input: JSON.parse(tc.arguments || '{}'),
|
||||
})}\n\n`));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1631,17 +1714,17 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
|
||||
if (!usedTools.includes(tc.name)) {
|
||||
usedTools.push(tc.name);
|
||||
// 发送实时工具使用事件
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'tool_used',
|
||||
toolName: tc.name,
|
||||
})}\n\n`));
|
||||
});
|
||||
}
|
||||
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'tool_execution_start',
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
})}\n\n`));
|
||||
});
|
||||
|
||||
// 解析工具参数
|
||||
let toolInput: Record<string, unknown> = {};
|
||||
@ -1654,32 +1737,42 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
|
||||
// 执行工具
|
||||
const result = await executeTool(tc.name, toolInput, { metasoApiKey });
|
||||
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'tool_execution_result',
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
success: result.success,
|
||||
result: result.displayResult,
|
||||
images: result.images,
|
||||
})}\n\n`));
|
||||
});
|
||||
|
||||
// 如果有搜索图片结果,发送专门的图片事件
|
||||
if (result.searchImages && result.searchImages.length > 0) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'tool_search_images',
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
searchImages: result.searchImages,
|
||||
})}\n\n`));
|
||||
});
|
||||
}
|
||||
|
||||
// 如果有搜索视频结果,发送专门的视频事件
|
||||
if (result.searchVideos && result.searchVideos.length > 0) {
|
||||
safeWriter.write({
|
||||
type: 'tool_search_videos',
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
searchVideos: result.searchVideos,
|
||||
});
|
||||
}
|
||||
|
||||
// 将工具结果显示给用户
|
||||
const toolDisplayText = `\n\n${result.displayResult}\n\n`;
|
||||
fullContent += toolDisplayText;
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
safeWriter.write({
|
||||
type: 'text',
|
||||
content: toolDisplayText,
|
||||
})}\n\n`));
|
||||
});
|
||||
|
||||
// 将工具结果添加到消息历史(OpenAI 格式)
|
||||
openaiMessages.push({
|
||||
@ -1739,7 +1832,7 @@ function buildClaudeToolDefinitions(toolIds: string[]) {
|
||||
},
|
||||
mita_search: {
|
||||
name: 'mita_search',
|
||||
description: '秘塔AI智能搜索。支持网页搜索和图片搜索两种模式。当需要搜索高质量的中文内容或需要更精准的搜索结果时使用网页搜索;当用户明确要求搜索图片或需要图片素材时使用图片搜索。',
|
||||
description: '秘塔AI智能搜索。支持网页搜索、图片搜索和视频搜索三种模式。当需要搜索高质量的中文内容或需要更精准的搜索结果时使用网页搜索;当用户明确要求搜索图片或需要图片素材时使用图片搜索;当用户需要搜索视频内容时使用视频搜索。',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@ -1749,12 +1842,12 @@ function buildClaudeToolDefinitions(toolIds: string[]) {
|
||||
},
|
||||
scope: {
|
||||
type: 'string',
|
||||
enum: ['webpage', 'image'],
|
||||
description: '搜索类型:webpage(网页搜索,默认)或 image(图片搜索)',
|
||||
enum: ['webpage', 'image', 'video'],
|
||||
description: '搜索类型:webpage(网页搜索,默认)、image(图片搜索)或 video(视频搜索)',
|
||||
},
|
||||
size: {
|
||||
type: 'number',
|
||||
description: '返回结果数量,网页搜索默认10,图片搜索默认5',
|
||||
description: '返回结果数量,网页搜索默认10,图片搜索默认5,视频搜索默认5',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
@ -1822,7 +1915,7 @@ function buildOpenAIToolDefinitions(toolIds: string[]) {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'mita_search',
|
||||
description: '秘塔AI智能搜索。支持网页搜索和图片搜索两种模式。当需要搜索高质量的中文内容或需要更精准的搜索结果时使用网页搜索;当用户明确要求搜索图片或需要图片素材时使用图片搜索。',
|
||||
description: '秘塔AI智能搜索。支持网页搜索、图片搜索和视频搜索三种模式。当需要搜索高质量的中文内容或需要更精准的搜索结果时使用网页搜索;当用户明确要求搜索图片或需要图片素材时使用图片搜索;当用户需要搜索视频内容时使用视频搜索。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@ -1832,12 +1925,12 @@ function buildOpenAIToolDefinitions(toolIds: string[]) {
|
||||
},
|
||||
scope: {
|
||||
type: 'string',
|
||||
enum: ['webpage', 'image'],
|
||||
description: '搜索类型:webpage(网页搜索,默认)或 image(图片搜索)',
|
||||
enum: ['webpage', 'image', 'video'],
|
||||
description: '搜索类型:webpage(网页搜索,默认)、image(图片搜索)或 video(视频搜索)',
|
||||
},
|
||||
size: {
|
||||
type: 'number',
|
||||
description: '返回结果数量,网页搜索默认10,图片搜索默认5',
|
||||
description: '返回结果数量,网页搜索默认10,图片搜索默认5,视频搜索默认5',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
@ -1904,7 +1997,7 @@ function buildCodexToolDefinitions(toolIds: string[]) {
|
||||
mita_search: {
|
||||
type: 'function',
|
||||
name: 'mita_search',
|
||||
description: '秘塔AI智能搜索。支持网页搜索和图片搜索两种模式。当需要搜索高质量的中文内容或需要更精准的搜索结果时使用网页搜索;当用户明确要求搜索图片或需要图片素材时使用图片搜索。',
|
||||
description: '秘塔AI智能搜索。支持网页搜索、图片搜索和视频搜索三种模式。当需要搜索高质量的中文内容或需要更精准的搜索结果时使用网页搜索;当用户明确要求搜索图片或需要图片素材时使用图片搜索;当用户需要搜索视频内容时使用视频搜索。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@ -1914,12 +2007,12 @@ function buildCodexToolDefinitions(toolIds: string[]) {
|
||||
},
|
||||
scope: {
|
||||
type: 'string',
|
||||
enum: ['webpage', 'image'],
|
||||
description: '搜索类型:webpage(网页搜索,默认)或 image(图片搜索)',
|
||||
enum: ['webpage', 'image', 'video'],
|
||||
description: '搜索类型:webpage(网页搜索,默认)、image(图片搜索)或 video(视频搜索)',
|
||||
},
|
||||
size: {
|
||||
type: 'number',
|
||||
description: '返回结果数量,网页搜索默认10,图片搜索默认5',
|
||||
description: '返回结果数量,网页搜索默认10,图片搜索默认5,视频搜索默认5',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
|
||||
@ -4,7 +4,7 @@ import { useState, useCallback, useRef } from 'react';
|
||||
import { executePythonInPyodide, type LoadingCallback } from '@/services/tools/pyodideRunner';
|
||||
|
||||
export interface StreamMessage {
|
||||
type: 'thinking' | 'text' | 'tool_use_start' | 'tool_execution_result' | 'tool_search_images' | 'pyodide_execution_required' | 'tool_used' | 'done' | 'error';
|
||||
type: 'thinking' | 'text' | 'tool_use_start' | 'tool_execution_result' | 'tool_search_images' | 'tool_search_videos' | 'pyodide_execution_required' | 'tool_used' | 'done' | 'error';
|
||||
content?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
@ -21,6 +21,8 @@ export interface StreamMessage {
|
||||
images?: string[];
|
||||
// 搜索到的图片
|
||||
searchImages?: SearchImageData[];
|
||||
// 搜索到的视频
|
||||
searchVideos?: SearchVideoData[];
|
||||
// 工具使用相关
|
||||
toolName?: string;
|
||||
usedTools?: string[];
|
||||
@ -37,6 +39,19 @@ export interface SearchImageData {
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
// 搜索视频数据类型
|
||||
export interface SearchVideoData {
|
||||
title: string;
|
||||
link: string;
|
||||
snippet: string;
|
||||
score: string;
|
||||
position: number;
|
||||
authors: string[];
|
||||
date: string;
|
||||
duration: string;
|
||||
coverImage: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
@ -50,6 +65,8 @@ export interface ChatMessage {
|
||||
images?: string[];
|
||||
// 搜索到的图片
|
||||
searchImages?: SearchImageData[];
|
||||
// 搜索到的视频
|
||||
searchVideos?: SearchVideoData[];
|
||||
// 用户上传的图片(Base64)
|
||||
uploadedImages?: string[];
|
||||
// 用户上传的文档
|
||||
@ -110,6 +127,29 @@ async function saveMessageSearchImages(messageId: string, searchImages: SearchIm
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存搜索到的视频到数据库
|
||||
*/
|
||||
async function saveMessageSearchVideos(messageId: string, searchVideos: SearchVideoData[]): Promise<void> {
|
||||
if (!messageId || !searchVideos || searchVideos.length === 0) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/messages/${messageId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ searchVideos }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to save search videos:', await response.text());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving search videos:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文件转换为 Base64
|
||||
*/
|
||||
@ -204,6 +244,8 @@ export function useStreamChat() {
|
||||
const pendingImagesRef = useRef<string[]>([]);
|
||||
// 临时存储搜索到的图片,等待 messageId
|
||||
const pendingSearchImagesRef = useRef<SearchImageData[]>([]);
|
||||
// 临时存储搜索到的视频,等待 messageId
|
||||
const pendingSearchVideosRef = useRef<SearchVideoData[]>([]);
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = useCallback(async (options: {
|
||||
@ -438,6 +480,28 @@ export function useStreamChat() {
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
} else if (event.type === 'tool_search_videos') {
|
||||
// 处理视频搜索结果
|
||||
if (event.searchVideos && event.searchVideos.length > 0) {
|
||||
// 存储到临时变量,等待 messageId 后保存到数据库
|
||||
pendingSearchVideosRef.current = [
|
||||
...pendingSearchVideosRef.current,
|
||||
...event.searchVideos,
|
||||
];
|
||||
// 更新 UI
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const lastIndex = updated.length - 1;
|
||||
if (updated[lastIndex]?.role === 'assistant') {
|
||||
const existingSearchVideos = updated[lastIndex].searchVideos || [];
|
||||
updated[lastIndex] = {
|
||||
...updated[lastIndex],
|
||||
searchVideos: [...existingSearchVideos, ...event.searchVideos!],
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
} else if (event.type === 'tool_used') {
|
||||
// 实时工具使用事件
|
||||
if (event.toolName) {
|
||||
@ -536,6 +600,12 @@ export function useStreamChat() {
|
||||
pendingSearchImagesRef.current = []; // 清空临时存储
|
||||
}
|
||||
|
||||
// 如果有待保存的搜索视频,保存到数据库
|
||||
if (event.messageId && pendingSearchVideosRef.current.length > 0) {
|
||||
saveMessageSearchVideos(event.messageId, pendingSearchVideosRef.current);
|
||||
pendingSearchVideosRef.current = []; // 清空临时存储
|
||||
}
|
||||
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const lastIndex = updated.length - 1;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user