Compare commits
7 Commits
92ec88e1a3
...
be03aebb09
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be03aebb09 | ||
|
|
e0b82b6257 | ||
|
|
2852f746f0 | ||
|
|
ecf11e6b2b | ||
|
|
cab19672e0 | ||
|
|
1e81e9151b | ||
|
|
159009dd56 |
@ -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'],
|
||||
|
||||
149
src/app/api/video/episodes/route.ts
Normal file
149
src/app/api/video/episodes/route.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* 视频选集信息
|
||||
*/
|
||||
interface VideoEpisode {
|
||||
page: number; // 分P编号,从1开始
|
||||
part: string; // 分P标题
|
||||
duration: number; // 时长(秒)
|
||||
cid?: number; // B站视频分片ID
|
||||
}
|
||||
|
||||
/**
|
||||
* B站 API 返回的视频详情结构
|
||||
*/
|
||||
interface BilibiliVideoResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: {
|
||||
bvid: string;
|
||||
title: string;
|
||||
pages: {
|
||||
cid: number;
|
||||
page: number;
|
||||
part: string;
|
||||
duration: number;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URL 中解析 B站视频 ID
|
||||
*/
|
||||
function parseBilibiliUrl(url: string): { bvid?: string; aid?: string } | null {
|
||||
try {
|
||||
// BV号格式: bilibili.com/video/BVxxxxx
|
||||
const bvMatch = url.match(/bilibili\.com\/video\/(BV[\w]+)/i);
|
||||
if (bvMatch) {
|
||||
return { bvid: bvMatch[1] };
|
||||
}
|
||||
|
||||
// AV号格式: bilibili.com/video/avxxxxx
|
||||
const avMatch = url.match(/bilibili\.com\/video\/av(\d+)/i);
|
||||
if (avMatch) {
|
||||
return { aid: avMatch[1] };
|
||||
}
|
||||
|
||||
// 短链接格式: b23.tv/xxxxx (需要跟随重定向)
|
||||
// 暂不支持,返回 null
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/video/episodes
|
||||
* 获取视频选集列表
|
||||
*
|
||||
* Query params:
|
||||
* - url: 视频链接
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const videoUrl = searchParams.get('url');
|
||||
|
||||
if (!videoUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少 url 参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 解析视频 URL
|
||||
const videoId = parseBilibiliUrl(videoUrl);
|
||||
|
||||
if (!videoId) {
|
||||
// 不是 B站视频或无法解析,返回空选集
|
||||
return NextResponse.json({
|
||||
platform: 'unknown',
|
||||
episodes: [],
|
||||
totalEpisodes: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// 构建 B站 API 请求 URL
|
||||
let apiUrl = 'https://api.bilibili.com/x/web-interface/view?';
|
||||
if (videoId.bvid) {
|
||||
apiUrl += `bvid=${videoId.bvid}`;
|
||||
} else if (videoId.aid) {
|
||||
apiUrl += `aid=${videoId.aid}`;
|
||||
}
|
||||
|
||||
// 调用 B站 API
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Referer': 'https://www.bilibili.com/',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[API/video/episodes] B站 API 请求失败:', response.status);
|
||||
return NextResponse.json({
|
||||
platform: 'bilibili',
|
||||
episodes: [],
|
||||
totalEpisodes: 0,
|
||||
error: 'B站 API 请求失败',
|
||||
});
|
||||
}
|
||||
|
||||
const data: BilibiliVideoResponse = await response.json();
|
||||
|
||||
if (data.code !== 0 || !data.data) {
|
||||
console.error('[API/video/episodes] B站 API 返回错误:', data.message);
|
||||
return NextResponse.json({
|
||||
platform: 'bilibili',
|
||||
episodes: [],
|
||||
totalEpisodes: 0,
|
||||
error: data.message || '获取视频信息失败',
|
||||
});
|
||||
}
|
||||
|
||||
// 提取选集信息
|
||||
const episodes: VideoEpisode[] = data.data.pages.map((page) => ({
|
||||
page: page.page,
|
||||
part: page.part,
|
||||
duration: page.duration,
|
||||
cid: page.cid,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
platform: 'bilibili',
|
||||
bvid: data.data.bvid,
|
||||
title: data.data.title,
|
||||
episodes,
|
||||
totalEpisodes: episodes.length,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[API/video/episodes] 错误:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取选集信息失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@ import { MessageBubble } from '@/components/features/MessageBubble';
|
||||
import { ChatHeaderInfo } from '@/components/features/ChatHeader';
|
||||
import { SaveToNoteModal } from '@/components/features/SaveToNoteModal';
|
||||
import { PromptOptimizer } from '@/components/features/PromptOptimizer';
|
||||
import { LinkPreviewModal } from '@/components/features/LinkPreviewModal';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConversation, useConversations } from '@/hooks/useConversations';
|
||||
import { useStreamChat, type ChatMessage } from '@/hooks/useStreamChat';
|
||||
@ -47,6 +48,10 @@ export default function ChatPage({ params }: PageProps) {
|
||||
const [noteModalOpen, setNoteModalOpen] = useState(false);
|
||||
const [noteContent, setNoteContent] = useState('');
|
||||
|
||||
// 链接预览状态
|
||||
const [linkPreviewOpen, setLinkPreviewOpen] = useState(false);
|
||||
const [linkPreviewUrl, setLinkPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
// 获取数据
|
||||
const { conversation, loading: conversationLoading, error: conversationError } = useConversation(chatId);
|
||||
const { createConversation, updateConversation, deleteConversation } = useConversations();
|
||||
@ -342,6 +347,12 @@ export default function ChatPage({ params }: PageProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// 处理链接点击 - 在预览弹窗中打开
|
||||
const handleLinkClick = (url: string) => {
|
||||
setLinkPreviewUrl(url);
|
||||
setLinkPreviewOpen(true);
|
||||
};
|
||||
|
||||
// 转换模型格式
|
||||
const modelOptions = models.map((m) => ({
|
||||
id: m.modelId,
|
||||
@ -568,12 +579,14 @@ export default function ChatPage({ params }: PageProps) {
|
||||
error={message.error}
|
||||
images={message.images}
|
||||
searchImages={message.searchImages}
|
||||
searchVideos={message.searchVideos}
|
||||
uploadedImages={message.uploadedImages}
|
||||
uploadedDocuments={message.uploadedDocuments}
|
||||
usedTools={message.usedTools}
|
||||
pyodideStatus={message.pyodideStatus}
|
||||
onRegenerate={message.role === 'assistant' && !isStreaming ? handleRegenerate : undefined}
|
||||
onSaveToNote={message.role === 'assistant' && !isStreaming ? handleSaveToNote : undefined}
|
||||
onLinkClick={handleLinkClick}
|
||||
conversationId={chatId}
|
||||
/>
|
||||
))
|
||||
@ -624,6 +637,13 @@ export default function ChatPage({ params }: PageProps) {
|
||||
conversationId={chatId}
|
||||
/>
|
||||
|
||||
{/* 链接预览弹窗 */}
|
||||
<LinkPreviewModal
|
||||
url={linkPreviewUrl}
|
||||
isOpen={linkPreviewOpen}
|
||||
onClose={() => setLinkPreviewOpen(false)}
|
||||
/>
|
||||
|
||||
{/* 提示词优化工具浮动按钮 */}
|
||||
<PromptOptimizer onUsePrompt={setOptimizedPrompt} />
|
||||
</div>
|
||||
|
||||
144
src/components/features/EpisodeList.tsx
Normal file
144
src/components/features/EpisodeList.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Play, Clock, List, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* 选集信息
|
||||
*/
|
||||
export interface VideoEpisode {
|
||||
page: number; // 分P编号,从1开始
|
||||
part: string; // 分P标题
|
||||
duration: number; // 时长(秒)
|
||||
cid?: number; // B站视频分片ID
|
||||
}
|
||||
|
||||
interface EpisodeListProps {
|
||||
episodes: VideoEpisode[];
|
||||
currentEpisode: number;
|
||||
onEpisodeChange: (page: number) => void;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时长(秒 -> MM:SS 或 HH:MM:SS)
|
||||
*/
|
||||
function formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频选集列表组件
|
||||
*/
|
||||
export function EpisodeList({
|
||||
episodes,
|
||||
currentEpisode,
|
||||
onEpisodeChange,
|
||||
isLoading = false,
|
||||
className,
|
||||
}: EpisodeListProps) {
|
||||
const handleClick = useCallback((page: number) => {
|
||||
if (page !== currentEpisode) {
|
||||
onEpisodeChange(page);
|
||||
}
|
||||
}, [currentEpisode, onEpisodeChange]);
|
||||
|
||||
// 如果没有选集或只有一个,不显示
|
||||
if (!isLoading && episodes.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col h-full', className)}>
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50 bg-muted/30">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<List size={14} />
|
||||
<span>视频选集</span>
|
||||
{!isLoading && episodes.length > 0 && (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({currentEpisode}/{episodes.length})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 选集列表 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
// 加载状态
|
||||
<div className="flex flex-col items-center justify-center h-full py-8 text-muted-foreground">
|
||||
<Loader2 size={24} className="animate-spin mb-2" />
|
||||
<span className="text-sm">加载选集中...</span>
|
||||
</div>
|
||||
) : episodes.length === 0 ? (
|
||||
// 无选集
|
||||
<div className="flex flex-col items-center justify-center h-full py-8 text-muted-foreground">
|
||||
<span className="text-sm">暂无选集信息</span>
|
||||
</div>
|
||||
) : (
|
||||
// 选集列表
|
||||
<div className="py-1">
|
||||
{episodes.map((episode) => {
|
||||
const isActive = episode.page === currentEpisode;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={episode.page}
|
||||
onClick={() => handleClick(episode.page)}
|
||||
className={cn(
|
||||
'w-full flex items-start gap-2 px-3 py-2 text-left',
|
||||
'transition-colors duration-150',
|
||||
'hover:bg-muted/50',
|
||||
isActive && 'bg-primary/10 hover:bg-primary/15'
|
||||
)}
|
||||
>
|
||||
{/* 播放指示器 / 序号 */}
|
||||
<div className={cn(
|
||||
'flex-shrink-0 w-5 h-5 flex items-center justify-center',
|
||||
'text-xs',
|
||||
isActive ? 'text-primary' : 'text-muted-foreground'
|
||||
)}>
|
||||
{isActive ? (
|
||||
<Play size={12} fill="currentColor" />
|
||||
) : (
|
||||
<span>{episode.page.toString().padStart(2, '0')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标题和时长 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn(
|
||||
'text-sm truncate',
|
||||
isActive ? 'text-primary font-medium' : 'text-foreground'
|
||||
)}>
|
||||
{episode.part || `第 ${episode.page} 集`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 时长 */}
|
||||
<div className={cn(
|
||||
'flex-shrink-0 flex items-center gap-1 text-xs',
|
||||
isActive ? 'text-primary/70' : 'text-muted-foreground'
|
||||
)}>
|
||||
<Clock size={10} />
|
||||
<span>{formatDuration(episode.duration)}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
502
src/components/features/LinkPreviewModal.tsx
Normal file
502
src/components/features/LinkPreviewModal.tsx
Normal file
@ -0,0 +1,502 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { X, ExternalLink, AlertCircle, Copy, Check, Loader2, Lock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LinkPreviewModalProps {
|
||||
url: string | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type LinkType = 'webpage' | 'image' | 'unknown';
|
||||
|
||||
/**
|
||||
* 检测链接类型
|
||||
*/
|
||||
function detectLinkType(url: string): LinkType {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
|
||||
// 图片链接检测
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.ico'];
|
||||
if (imageExtensions.some(ext => lowerUrl.includes(ext))) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
// 图片托管服务
|
||||
const imageHosts = ['imgur.com', 'i.imgur.com', 'images.unsplash.com', 'picsum.photos'];
|
||||
if (imageHosts.some(host => lowerUrl.includes(host))) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
return 'webpage';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网站 favicon URL
|
||||
*/
|
||||
function getFaviconUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=32`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取域名
|
||||
*/
|
||||
function getDomain(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 URL 路径
|
||||
*/
|
||||
function getUrlPath(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.pathname + urlObj.search;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 链接预览弹窗组件
|
||||
* 浮岛浏览器风格 - 浅色主题
|
||||
*/
|
||||
export function LinkPreviewModal({
|
||||
url,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: LinkPreviewModalProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// 重置状态
|
||||
useEffect(() => {
|
||||
if (isOpen && url) {
|
||||
setIsLoading(true);
|
||||
setLoadError(false);
|
||||
setCopied(false);
|
||||
}
|
||||
}, [isOpen, url]);
|
||||
|
||||
// 键盘事件处理
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// 打开原链接
|
||||
const openInNewWindow = useCallback(() => {
|
||||
if (url) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
// 复制链接
|
||||
const copyLink = useCallback(async () => {
|
||||
if (url) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err);
|
||||
}
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
// iframe 加载完成
|
||||
const handleIframeLoad = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
// iframe 加载错误
|
||||
const handleIframeError = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
setLoadError(true);
|
||||
}, []);
|
||||
|
||||
// 图片加载完成
|
||||
const handleImageLoad = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
// 图片加载错误
|
||||
const handleImageError = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
setLoadError(true);
|
||||
}, []);
|
||||
|
||||
if (!isOpen || !url) return null;
|
||||
|
||||
const linkType = detectLinkType(url);
|
||||
const domain = getDomain(url);
|
||||
const urlPath = getUrlPath(url);
|
||||
const faviconUrl = getFaviconUrl(url);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="link-preview-overlay fixed inset-0 z-50 flex items-center justify-center p-6"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* 弹窗容器 */}
|
||||
<div
|
||||
className="link-preview-modal relative flex flex-col w-[95vw] max-w-[1400px] h-[92vh] max-h-[960px] rounded-md overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 顶部标题栏 - 仿浏览器 */}
|
||||
<div className="link-preview-header flex items-center gap-3.5 px-4 py-3.5 flex-shrink-0">
|
||||
{/* 三色圆点 */}
|
||||
<div className="flex gap-2 flex-shrink-0 pl-1">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-3 h-3 rounded-full bg-gradient-to-br from-[#ff5f57] to-[#ff3b30] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.1)] hover:scale-110 transition-transform"
|
||||
title="关闭"
|
||||
/>
|
||||
<div className="w-3 h-3 rounded-full bg-gradient-to-br from-[#ffcc00] to-[#febc2e] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.1)]" />
|
||||
<div className="w-3 h-3 rounded-full bg-gradient-to-br from-[#34d058] to-[#28c840] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.1)]" />
|
||||
</div>
|
||||
|
||||
{/* 地址栏 */}
|
||||
<div
|
||||
className="link-preview-url-bar flex-1 flex items-center gap-2.5 px-4 py-2.5 rounded-xl cursor-pointer"
|
||||
onClick={copyLink}
|
||||
title="点击复制链接"
|
||||
>
|
||||
{/* 安全锁图标 */}
|
||||
<Lock size={14} className="flex-shrink-0 text-[#28c840]" />
|
||||
|
||||
{/* Favicon */}
|
||||
{faviconUrl && (
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
className="w-4.5 h-4.5 rounded flex-shrink-0"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 域名 */}
|
||||
<span className="link-preview-domain text-sm font-semibold whitespace-nowrap">
|
||||
{linkType === 'image' ? '图片预览' : domain}
|
||||
</span>
|
||||
|
||||
{/* 路径 */}
|
||||
<span className="link-preview-path text-sm whitespace-nowrap overflow-hidden text-ellipsis flex-1">
|
||||
{urlPath}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{/* 复制链接 */}
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className={cn(
|
||||
'link-preview-action-btn w-9 h-9 flex items-center justify-center rounded-xl transition-all',
|
||||
copied && 'text-[#28c840]!'
|
||||
)}
|
||||
title={copied ? '已复制' : '复制链接'}
|
||||
>
|
||||
{copied ? <Check size={18} /> : <Copy size={18} />}
|
||||
</button>
|
||||
|
||||
{/* 新窗口打开 */}
|
||||
<button
|
||||
onClick={openInNewWindow}
|
||||
className="link-preview-action-btn w-9 h-9 flex items-center justify-center rounded-xl transition-all"
|
||||
title="在新窗口打开"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
</button>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="link-preview-action-btn w-9 h-9 flex items-center justify-center rounded-xl transition-all"
|
||||
title="关闭 (ESC)"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容预览区域 */}
|
||||
<div className="link-preview-content relative flex-1 min-h-0 overflow-hidden">
|
||||
{/* 加载状态 */}
|
||||
{isLoading && (
|
||||
<div className="link-preview-loading absolute inset-0 flex flex-col items-center justify-center z-10 gap-5">
|
||||
<div className="w-11 h-11 border-3 border-gray-200 border-t-[#6366f1] rounded-full animate-spin" />
|
||||
<span className="text-[15px] font-medium text-gray-500">正在加载预览...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载失败 */}
|
||||
{loadError && (
|
||||
<div className="link-preview-error absolute inset-0 flex flex-col items-center justify-center z-10 gap-4 px-10">
|
||||
<div className="w-[72px] h-[72px] rounded-full bg-gradient-to-br from-gray-100 to-gray-200 flex items-center justify-center">
|
||||
<AlertCircle size={32} className="text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">
|
||||
{linkType === 'image' ? '图片加载失败' : '该网站不支持嵌入预览'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 text-center max-w-[360px] leading-relaxed">
|
||||
{linkType === 'image'
|
||||
? '图片可能已失效或无法访问'
|
||||
: '此网站设置了安全策略,禁止被嵌入到其他页面中。您可以在新窗口中查看完整内容。'}
|
||||
</p>
|
||||
<button
|
||||
onClick={openInNewWindow}
|
||||
className="mt-2 px-7 py-3 bg-gradient-to-br from-[#6366f1] to-[#4f46e5] text-white rounded-xl text-[15px] font-semibold flex items-center gap-2 shadow-[0_4px_14px_-3px_rgba(99,102,241,0.5)] hover:translate-y-[-2px] hover:shadow-[0_6px_20px_-3px_rgba(99,102,241,0.6)] transition-all"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
在新窗口中打开
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片预览 */}
|
||||
{linkType === 'image' && (
|
||||
<div className="w-full h-full flex items-center justify-center p-8 bg-gradient-to-b from-gray-50 to-gray-100">
|
||||
<img
|
||||
src={url}
|
||||
alt="图片预览"
|
||||
className={cn(
|
||||
'max-w-full max-h-full object-contain rounded-xl shadow-[0_8px_32px_-8px_rgba(0,0,0,0.2)] transition-opacity duration-300',
|
||||
isLoading ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 网页预览 (iframe) */}
|
||||
{linkType === 'webpage' && !loadError && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={url}
|
||||
className={cn(
|
||||
'w-full h-full border-none transition-opacity duration-300',
|
||||
isLoading ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
onLoad={handleIframeLoad}
|
||||
onError={handleIframeError}
|
||||
title="网页预览"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部状态栏 */}
|
||||
<div className="link-preview-footer px-5 py-3 flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-2 text-[13px] text-gray-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#28c840] animate-pulse" />
|
||||
<span>{linkType === 'image' ? '图片预览' : '网页预览'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[13px] text-gray-400">
|
||||
<span>按</span>
|
||||
<kbd className="px-2 py-1 bg-black/5 rounded-md text-xs font-medium text-gray-500 border border-black/5">
|
||||
ESC
|
||||
</kbd>
|
||||
<span>关闭</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 样式 */}
|
||||
<style jsx global>{`
|
||||
/* 遮罩层 */
|
||||
.link-preview-overlay {
|
||||
background: rgba(30, 30, 40, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
animation: linkPreviewFadeIn 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes linkPreviewFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* 弹窗容器 */
|
||||
.link-preview-modal {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow:
|
||||
0 25px 80px -12px rgba(0, 0, 0, 0.3),
|
||||
0 12px 40px -8px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
animation: linkPreviewScaleIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@keyframes linkPreviewScaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.92) translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 顶部标题栏 */
|
||||
.link-preview-header {
|
||||
background: linear-gradient(180deg, #f8f9fa 0%, #f1f3f4 100%);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* 地址栏 */
|
||||
.link-preview-url-bar {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.link-preview-url-bar:hover {
|
||||
background: rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
|
||||
.link-preview-domain {
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.link-preview-path {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.link-preview-action-btn {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.link-preview-action-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.link-preview-action-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* 内容区域 */
|
||||
.link-preview-content {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* 加载状态背景 */
|
||||
.link-preview-loading {
|
||||
background: linear-gradient(180deg, #fafbfc 0%, #f5f7fa 100%);
|
||||
}
|
||||
|
||||
/* 错误状态背景 */
|
||||
.link-preview-error {
|
||||
background: linear-gradient(180deg, #fafbfc 0%, #f5f7fa 100%);
|
||||
}
|
||||
|
||||
/* 底部状态栏 */
|
||||
.link-preview-footer {
|
||||
background: linear-gradient(180deg, #f8f9fa 0%, #f1f3f4 100%);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* 暗色主题适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.link-preview-modal {
|
||||
background: rgba(30, 32, 40, 0.98);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.link-preview-header {
|
||||
background: linear-gradient(180deg, #1e2028 0%, #252830 100%);
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.link-preview-url-bar {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.link-preview-url-bar:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.link-preview-domain {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.link-preview-path {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.link-preview-action-btn {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.link-preview-action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.link-preview-content {
|
||||
background: #1a1b1e;
|
||||
}
|
||||
|
||||
.link-preview-loading {
|
||||
background: linear-gradient(180deg, #1e2028 0%, #252830 100%);
|
||||
}
|
||||
|
||||
.link-preview-error {
|
||||
background: linear-gradient(180deg, #1e2028 0%, #252830 100%);
|
||||
}
|
||||
|
||||
.link-preview-error h3 {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.link-preview-error p {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.link-preview-footer {
|
||||
background: linear-gradient(180deg, #1e2028 0%, #252830 100%);
|
||||
border-top-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.link-preview-footer span,
|
||||
.link-preview-footer kbd {
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -8,6 +8,7 @@ import { Tooltip } from '@/components/ui/Tooltip';
|
||||
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer';
|
||||
import { CodeExecutionResult, PyodideLoading } from '@/components/features/CodeExecutionResult';
|
||||
import { SearchImagesGrid, type SearchImageItem } from '@/components/features/SearchImagesGrid';
|
||||
import { SearchVideosGrid, type SearchVideoItem } from '@/components/features/SearchVideosGrid';
|
||||
import { ImageLightbox } from '@/components/ui/ImageLightbox';
|
||||
import { DocumentPreview, type DocumentData } from '@/components/ui/DocumentPreview';
|
||||
import { cn } from '@/lib/utils';
|
||||
@ -33,6 +34,8 @@ interface MessageBubbleProps {
|
||||
images?: string[];
|
||||
/** 搜索到的图片(来自图片搜索工具) */
|
||||
searchImages?: SearchImageItem[];
|
||||
/** 搜索到的视频(来自视频搜索工具) */
|
||||
searchVideos?: SearchVideoItem[];
|
||||
/** 用户上传的图片(Base64 或 URL) */
|
||||
uploadedImages?: string[];
|
||||
/** 用户上传的文档 */
|
||||
@ -49,6 +52,8 @@ interface MessageBubbleProps {
|
||||
onRegenerate?: (messageId: string) => void;
|
||||
/** 保存到笔记回调(仅对 AI 消息有效),传入消息内容 */
|
||||
onSaveToNote?: (content: string) => void;
|
||||
/** 链接点击回调,用于在预览窗口中打开链接 */
|
||||
onLinkClick?: (url: string) => void;
|
||||
/** 对话 ID(用于关联笔记来源) */
|
||||
conversationId?: string;
|
||||
}
|
||||
@ -70,7 +75,7 @@ function getDocumentIcon(type: string) {
|
||||
return FileText;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, searchImages, uploadedImages, uploadedDocuments, usedTools, pyodideStatus, onRegenerate, onSaveToNote, conversationId }: MessageBubbleProps) {
|
||||
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, searchImages, searchVideos, uploadedImages, uploadedDocuments, usedTools, pyodideStatus, onRegenerate, onSaveToNote, onLinkClick, conversationId }: MessageBubbleProps) {
|
||||
const isUser = message.role === 'user';
|
||||
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
@ -284,11 +289,17 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
||||
<SearchImagesGrid images={searchImages} className="mt-0 mb-4" />
|
||||
)}
|
||||
|
||||
{/* 搜索到的视频(视频搜索工具结果)- 显示在图片下方 */}
|
||||
{searchVideos && searchVideos.length > 0 && (
|
||||
<SearchVideosGrid videos={searchVideos} className="mt-0 mb-4" />
|
||||
)}
|
||||
|
||||
<div className="text-[var(--color-text-primary)] leading-[1.75]">
|
||||
{message.content ? (
|
||||
<MarkdownRenderer
|
||||
content={message.content}
|
||||
onImageLinkClick={handleImageLinkClick}
|
||||
onLinkClick={onLinkClick}
|
||||
/>
|
||||
) : isStreaming ? (
|
||||
<div className="flex items-center gap-2 text-[var(--color-text-tertiary)]">
|
||||
|
||||
246
src/components/features/SearchVideosGrid.tsx
Normal file
246
src/components/features/SearchVideosGrid.tsx
Normal file
@ -0,0 +1,246 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { Play, Video, Clock, User, Calendar, ExternalLink } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatDuration, detectPlatform, getPlatformName, getPlatformColor } from '@/lib/videoUtils';
|
||||
import { VideoPlayerModal } from './VideoPlayerModal';
|
||||
|
||||
export interface SearchVideoItem {
|
||||
title: string;
|
||||
link: string;
|
||||
snippet: string;
|
||||
score: string;
|
||||
position: number;
|
||||
authors: string[];
|
||||
date: string;
|
||||
duration: string;
|
||||
coverImage: string;
|
||||
}
|
||||
|
||||
interface SearchVideosGridProps {
|
||||
videos: SearchVideoItem[];
|
||||
className?: string;
|
||||
/** 最大显示视频数量,默认为 5 */
|
||||
maxDisplay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索视频网格展示组件
|
||||
* 显示视频封面、标题、时长、作者等信息
|
||||
* 点击视频卡片打开播放弹窗
|
||||
*/
|
||||
export function SearchVideosGrid({
|
||||
videos,
|
||||
className,
|
||||
maxDisplay = 5
|
||||
}: SearchVideosGridProps) {
|
||||
const [playerOpen, setPlayerOpen] = useState(false);
|
||||
const [selectedVideo, setSelectedVideo] = useState<SearchVideoItem | null>(null);
|
||||
|
||||
// 封面加载状态
|
||||
const [loadedCovers, setLoadedCovers] = useState<Set<number>>(new Set());
|
||||
const [errorCovers, setErrorCovers] = useState<Set<number>>(new Set());
|
||||
|
||||
/**
|
||||
* 计算当前要显示的视频列表
|
||||
* 跳过封面加载失败的视频(可选择保留)
|
||||
*/
|
||||
const displayVideos = useMemo(() => {
|
||||
const result: { video: SearchVideoItem; originalIndex: number }[] = [];
|
||||
|
||||
for (let i = 0; i < videos.length && result.length < maxDisplay; i++) {
|
||||
// 即使封面加载失败也显示,使用占位图
|
||||
result.push({ video: videos[i], originalIndex: i });
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [videos, maxDisplay]);
|
||||
|
||||
// 封面加载成功
|
||||
const handleCoverLoad = useCallback((index: number) => {
|
||||
setLoadedCovers((prev) => new Set(prev).add(index));
|
||||
}, []);
|
||||
|
||||
// 封面加载失败
|
||||
const handleCoverError = useCallback((index: number) => {
|
||||
setErrorCovers((prev) => new Set(prev).add(index));
|
||||
// 标记为已加载(显示占位图)
|
||||
setLoadedCovers((prev) => new Set(prev).add(index));
|
||||
}, []);
|
||||
|
||||
// 打开视频播放器
|
||||
const openPlayer = useCallback((video: SearchVideoItem) => {
|
||||
setSelectedVideo(video);
|
||||
setPlayerOpen(true);
|
||||
}, []);
|
||||
|
||||
// 关闭视频播放器
|
||||
const closePlayer = useCallback(() => {
|
||||
setPlayerOpen(false);
|
||||
setSelectedVideo(null);
|
||||
}, []);
|
||||
|
||||
if (!videos || videos.length === 0) return null;
|
||||
|
||||
if (displayVideos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('mt-3', className)}>
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center gap-2 mb-3 text-sm text-muted-foreground">
|
||||
<Video size={14} />
|
||||
<span>
|
||||
搜索到 {displayVideos.length} 个相关视频
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 视频网格容器 - 3列布局 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{displayVideos.map(({ video, originalIndex }, displayIndex) => {
|
||||
const isLoaded = loadedCovers.has(originalIndex);
|
||||
const hasError = errorCovers.has(originalIndex);
|
||||
const platform = detectPlatform(video.link);
|
||||
const platformName = getPlatformName(platform);
|
||||
const platformColor = getPlatformColor(platform);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${video.link}-${originalIndex}`}
|
||||
className={cn(
|
||||
'group relative cursor-pointer',
|
||||
'rounded overflow-hidden',
|
||||
'bg-white dark:bg-zinc-900',
|
||||
'ring-1 ring-black/5 dark:ring-white/10',
|
||||
'shadow-[0_2px_8px_rgba(0,0,0,0.06)]',
|
||||
'transition-all duration-200',
|
||||
'hover:shadow-[0_6px_20px_rgba(0,0,0,0.1)]',
|
||||
'hover:ring-primary/20',
|
||||
!isLoaded && 'animate-pulse'
|
||||
)}
|
||||
onClick={() => openPlayer(video)}
|
||||
>
|
||||
{/* 封面容器 */}
|
||||
<div className="relative aspect-video overflow-hidden bg-muted/30">
|
||||
{/* 封面图片 */}
|
||||
{video.coverImage && !hasError ? (
|
||||
<img
|
||||
src={video.coverImage}
|
||||
alt={video.title || `视频 ${displayIndex + 1}`}
|
||||
className={cn(
|
||||
'w-full h-full object-cover',
|
||||
'transition-all duration-300',
|
||||
isLoaded ? 'opacity-100 scale-100' : 'opacity-0 scale-105',
|
||||
'group-hover:scale-105'
|
||||
)}
|
||||
onLoad={() => handleCoverLoad(originalIndex)}
|
||||
onError={() => handleCoverError(originalIndex)}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
// 占位图
|
||||
<div className="w-full h-full flex items-center justify-center bg-muted/50">
|
||||
<Video size={32} className="text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载中指示器 */}
|
||||
{!isLoaded && video.coverImage && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放按钮遮罩 */}
|
||||
<div className={cn(
|
||||
'absolute inset-0 flex items-center justify-center',
|
||||
'bg-black/0 group-hover:bg-black/30',
|
||||
'transition-all duration-200'
|
||||
)}>
|
||||
<div className={cn(
|
||||
'w-12 h-12 rounded-full',
|
||||
'bg-white/90 dark:bg-black/80',
|
||||
'flex items-center justify-center',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
'scale-75 group-hover:scale-100',
|
||||
'transition-all duration-200',
|
||||
'shadow-lg'
|
||||
)}>
|
||||
<Play size={20} className="text-primary ml-0.5" fill="currentColor" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 时长标签 */}
|
||||
{video.duration && (
|
||||
<div className={cn(
|
||||
'absolute bottom-2 right-2',
|
||||
'px-1.5 py-0.5 rounded',
|
||||
'bg-black/75 text-white',
|
||||
'text-xs font-medium',
|
||||
'flex items-center gap-1'
|
||||
)}>
|
||||
<Clock size={10} />
|
||||
<span>{formatDuration(video.duration)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 平台标签 */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-2 left-2',
|
||||
'px-1.5 py-0.5 rounded',
|
||||
'text-white text-xs font-medium'
|
||||
)}
|
||||
style={{ backgroundColor: platformColor }}
|
||||
>
|
||||
{platformName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 视频信息 */}
|
||||
<div className="p-2.5 space-y-1.5">
|
||||
{/* 标题 */}
|
||||
<h4
|
||||
className="text-sm font-medium text-foreground/90 line-clamp-2 leading-snug"
|
||||
title={video.title}
|
||||
>
|
||||
{video.title}
|
||||
</h4>
|
||||
|
||||
{/* 元信息行 */}
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{/* 作者 */}
|
||||
{video.authors && video.authors.length > 0 && (
|
||||
<div className="flex items-center gap-1 truncate">
|
||||
<User size={10} />
|
||||
<span className="truncate">{video.authors[0]}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 日期 */}
|
||||
{video.date && (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Calendar size={10} />
|
||||
<span>{video.date}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 视频播放弹窗 */}
|
||||
<VideoPlayerModal
|
||||
video={selectedVideo}
|
||||
isOpen={playerOpen}
|
||||
onClose={closePlayer}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
497
src/components/features/VideoPlayerModal.tsx
Normal file
497
src/components/features/VideoPlayerModal.tsx
Normal file
@ -0,0 +1,497 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useCallback, useRef, useState } from 'react';
|
||||
import { X, ExternalLink, User, Calendar, Clock, AlertCircle, List, ChevronRight, Play } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { parseVideoUrl, formatDuration, detectPlatform, getPlatformName, getPlatformColor, supportsEmbed, getEmbedUrlWithPage, supportsEpisodes } from '@/lib/videoUtils';
|
||||
import { VideoEpisode } from './EpisodeList';
|
||||
|
||||
interface VideoPlayerModalProps {
|
||||
video: {
|
||||
title: string;
|
||||
link: string;
|
||||
snippet?: string;
|
||||
authors?: string[];
|
||||
date?: string;
|
||||
duration?: string;
|
||||
coverImage?: string;
|
||||
} | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频播放弹窗组件
|
||||
* 支持 B站、YouTube 视频嵌入播放
|
||||
* 悬浮选集面板设计(类似B站原生)
|
||||
*/
|
||||
export function VideoPlayerModal({
|
||||
video,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: VideoPlayerModalProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 选集状态管理
|
||||
const [episodes, setEpisodes] = useState<VideoEpisode[]>([]);
|
||||
const [currentEpisode, setCurrentEpisode] = useState(1);
|
||||
const [isLoadingEpisodes, setIsLoadingEpisodes] = useState(false);
|
||||
const [episodesError, setEpisodesError] = useState<string | null>(null);
|
||||
|
||||
// 选集面板展开状态
|
||||
const [isEpisodePanelOpen, setIsEpisodePanelOpen] = useState(false);
|
||||
|
||||
// 键盘事件处理
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (isEpisodePanelOpen) {
|
||||
setIsEpisodePanelOpen(false);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen, onClose, isEpisodePanelOpen]);
|
||||
|
||||
// 获取视频选集
|
||||
useEffect(() => {
|
||||
if (!isOpen || !video?.link) {
|
||||
setEpisodes([]);
|
||||
setCurrentEpisode(1);
|
||||
setEpisodesError(null);
|
||||
setIsEpisodePanelOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const platform = detectPlatform(video.link);
|
||||
|
||||
if (!supportsEpisodes(platform)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchEpisodes = async () => {
|
||||
setIsLoadingEpisodes(true);
|
||||
setEpisodesError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/video/episodes?url=${encodeURIComponent(video.link)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取选集失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
setEpisodes(data.episodes || []);
|
||||
} catch (error) {
|
||||
console.error('[VideoPlayerModal] 获取选集失败:', error);
|
||||
setEpisodesError(error instanceof Error ? error.message : '获取选集失败');
|
||||
setEpisodes([]);
|
||||
} finally {
|
||||
setIsLoadingEpisodes(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchEpisodes();
|
||||
}, [isOpen, video?.link]);
|
||||
|
||||
// 打开原链接
|
||||
const openOriginalLink = useCallback(() => {
|
||||
if (video?.link) {
|
||||
window.open(video.link, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}, [video?.link]);
|
||||
|
||||
// 切换选集
|
||||
const handleEpisodeChange = useCallback((page: number) => {
|
||||
setCurrentEpisode(page);
|
||||
}, []);
|
||||
|
||||
// 切换选集面板
|
||||
const toggleEpisodePanel = useCallback(() => {
|
||||
setIsEpisodePanelOpen(prev => !prev);
|
||||
}, []);
|
||||
|
||||
if (!isOpen || !video) return null;
|
||||
|
||||
const videoInfo = parseVideoUrl(video.link);
|
||||
const platform = detectPlatform(video.link);
|
||||
const platformName = getPlatformName(platform);
|
||||
const platformColor = getPlatformColor(platform);
|
||||
const canEmbed = supportsEmbed(platform);
|
||||
|
||||
// 计算选集相关状态
|
||||
const hasEpisodes = episodes.length > 1;
|
||||
const showEpisodeButton = supportsEpisodes(platform) && (isLoadingEpisodes || hasEpisodes);
|
||||
|
||||
// 获取当前选集的嵌入 URL
|
||||
const currentEmbedUrl = hasEpisodes
|
||||
? getEmbedUrlWithPage(video.link, currentEpisode)
|
||||
: videoInfo?.embedUrl;
|
||||
|
||||
// 格式化时长
|
||||
const formatEpisodeDuration = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-md"
|
||||
onClick={onClose}
|
||||
style={{ animation: 'fadeIn 0.2s ease-out' }}
|
||||
>
|
||||
{/* 弹窗容器 */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full max-w-5xl mx-4',
|
||||
'bg-zinc-900',
|
||||
'rounded-md overflow-hidden',
|
||||
'shadow-2xl shadow-black/50',
|
||||
'border border-white/10'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ animation: 'scaleIn 0.25s ease-out' }}
|
||||
>
|
||||
{/* 顶部标题栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-zinc-800/80 border-b border-white/5">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{/* 平台标签 */}
|
||||
<span
|
||||
className="px-2.5 py-1 rounded-md text-xs font-semibold text-white flex-shrink-0 shadow-sm"
|
||||
style={{ backgroundColor: platformColor }}
|
||||
>
|
||||
{platformName}
|
||||
</span>
|
||||
|
||||
{/* 标题 */}
|
||||
<h3
|
||||
className="text-sm font-medium text-white/90 truncate"
|
||||
title={video.title}
|
||||
>
|
||||
{video.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0 ml-3">
|
||||
{/* 选集按钮 */}
|
||||
{showEpisodeButton && (
|
||||
<button
|
||||
onClick={toggleEpisodePanel}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium',
|
||||
'transition-all duration-200',
|
||||
isEpisodePanelOpen
|
||||
? 'bg-pink-500/20 text-pink-400 border border-pink-500/30'
|
||||
: 'text-white/70 hover:text-white hover:bg-white/10'
|
||||
)}
|
||||
>
|
||||
<List size={16} />
|
||||
<span>选集</span>
|
||||
{hasEpisodes && (
|
||||
<span className="text-xs opacity-70">
|
||||
({currentEpisode}/{episodes.length})
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={cn(
|
||||
'transition-transform duration-200',
|
||||
isEpisodePanelOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 打开原链接 */}
|
||||
<button
|
||||
onClick={openOriginalLink}
|
||||
className={cn(
|
||||
'p-2 rounded-lg',
|
||||
'text-white/60 hover:text-white',
|
||||
'hover:bg-white/10',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title="在新标签页打开"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
</button>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'p-2 rounded-lg',
|
||||
'text-white/60 hover:text-white',
|
||||
'hover:bg-white/10',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title="关闭 (ESC)"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 视频播放区域 - 相对定位容器 */}
|
||||
<div className="relative">
|
||||
{/* 视频播放器 */}
|
||||
<div className="aspect-video bg-black">
|
||||
{canEmbed && currentEmbedUrl ? (
|
||||
<iframe
|
||||
src={currentEmbedUrl}
|
||||
className="w-full h-full"
|
||||
allowFullScreen
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
title={video.title}
|
||||
/>
|
||||
) : (
|
||||
// 不支持嵌入的视频,显示封面和跳转提示
|
||||
<div className="relative w-full h-full flex items-center justify-center">
|
||||
{video.coverImage && (
|
||||
<img
|
||||
src={video.coverImage}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-cover blur-sm opacity-30"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 text-center p-6">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-white/10 flex items-center justify-center">
|
||||
<AlertCircle size={32} className="text-white/80" />
|
||||
</div>
|
||||
<p className="text-white/90 text-lg font-medium mb-2">
|
||||
{platformName}视频暂不支持嵌入播放
|
||||
</p>
|
||||
<p className="text-white/60 text-sm mb-6">
|
||||
点击下方按钮前往{platformName}观看
|
||||
</p>
|
||||
<button
|
||||
onClick={openOriginalLink}
|
||||
className={cn(
|
||||
'px-6 py-2.5 rounded-lg',
|
||||
'bg-white text-black',
|
||||
'font-medium text-sm',
|
||||
'hover:bg-white/90',
|
||||
'transition-colors duration-150',
|
||||
'flex items-center gap-2 mx-auto'
|
||||
)}
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
前往{platformName}观看
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 悬浮选集面板 */}
|
||||
{showEpisodeButton && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 right-0 h-full w-72',
|
||||
'bg-gradient-to-l from-zinc-900/98 via-zinc-900/95 to-zinc-900/90',
|
||||
'backdrop-blur-xl',
|
||||
'border-l border-white/10',
|
||||
'transition-all duration-300 ease-out',
|
||||
'flex flex-col',
|
||||
isEpisodePanelOpen
|
||||
? 'translate-x-0 opacity-100'
|
||||
: 'translate-x-full opacity-0 pointer-events-none'
|
||||
)}
|
||||
style={{
|
||||
boxShadow: isEpisodePanelOpen ? '-10px 0 30px rgba(0,0,0,0.5)' : 'none'
|
||||
}}
|
||||
>
|
||||
{/* 面板标题 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 bg-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<List size={16} className="text-pink-400" />
|
||||
<span className="text-sm font-semibold text-white">视频选集</span>
|
||||
</div>
|
||||
{hasEpisodes && (
|
||||
<span className="text-xs text-white/50 bg-white/10 px-2 py-0.5 rounded-full">
|
||||
{currentEpisode} / {episodes.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 选集列表 */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{isLoadingEpisodes ? (
|
||||
<div className="flex flex-col items-center justify-center h-full py-8">
|
||||
<div className="w-8 h-8 border-2 border-pink-500/30 border-t-pink-500 rounded-full animate-spin mb-3" />
|
||||
<span className="text-sm text-white/50">加载选集中...</span>
|
||||
</div>
|
||||
) : episodes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full py-8 text-white/40">
|
||||
<span className="text-sm">暂无选集信息</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2">
|
||||
{episodes.map((episode) => {
|
||||
const isActive = episode.page === currentEpisode;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={episode.page}
|
||||
onClick={() => handleEpisodeChange(episode.page)}
|
||||
className={cn(
|
||||
'w-full flex items-start gap-3 px-4 py-2.5 text-left',
|
||||
'transition-all duration-150',
|
||||
'hover:bg-white/10',
|
||||
'group',
|
||||
isActive && 'bg-pink-500/15'
|
||||
)}
|
||||
>
|
||||
{/* 播放指示器 / 序号 */}
|
||||
<div className={cn(
|
||||
'flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-md',
|
||||
'text-xs font-medium',
|
||||
isActive
|
||||
? 'bg-pink-500 text-white'
|
||||
: 'bg-white/10 text-white/60 group-hover:bg-white/20'
|
||||
)}>
|
||||
{isActive ? (
|
||||
<Play size={12} fill="currentColor" />
|
||||
) : (
|
||||
<span>{episode.page}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标题和时长 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn(
|
||||
'text-sm truncate mb-0.5',
|
||||
isActive ? 'text-pink-400 font-medium' : 'text-white/80 group-hover:text-white'
|
||||
)}>
|
||||
{episode.part || `第 ${episode.page} 集`}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'flex items-center gap-1 text-xs',
|
||||
isActive ? 'text-pink-400/60' : 'text-white/40'
|
||||
)}>
|
||||
<Clock size={10} />
|
||||
<span>{formatEpisodeDuration(episode.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 面板底部提示 */}
|
||||
<div className="px-4 py-2 border-t border-white/5 bg-white/5">
|
||||
<p className="text-xs text-white/30 text-center">
|
||||
点击选集切换视频
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部信息栏 */}
|
||||
<div className="px-4 py-3 bg-zinc-800/50 border-t border-white/5">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 左侧:元信息 */}
|
||||
<div className="flex items-center gap-4 text-sm text-white/50">
|
||||
{video.authors && video.authors.length > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<User size={14} />
|
||||
<span>{video.authors.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{video.date && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar size={14} />
|
||||
<span>{video.date}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{video.duration && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock size={14} />
|
||||
<span>{formatDuration(video.duration)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:快捷提示 */}
|
||||
<div className="text-xs text-white/30">
|
||||
按 ESC 关闭
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{video.snippet && video.snippet.length < 200 && (
|
||||
<p className="mt-2 text-xs text-white/40 line-clamp-2">
|
||||
{video.snippet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 自定义滚动条样式 */}
|
||||
<style jsx global>{`
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -11,6 +11,8 @@ interface MarkdownRendererProps {
|
||||
className?: string;
|
||||
/** 图片链接点击回调,用于在灯箱中打开图片 */
|
||||
onImageLinkClick?: (url: string) => void;
|
||||
/** 普通链接点击回调,用于在预览窗口中打开 */
|
||||
onLinkClick?: (url: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -28,7 +30,7 @@ function isImageUrl(url: string): boolean {
|
||||
* 创建 Markdown 组件配置
|
||||
* 使用工厂函数以支持传入回调
|
||||
*/
|
||||
function createMarkdownComponents(onImageLinkClick?: (url: string) => void) {
|
||||
function createMarkdownComponents(onImageLinkClick?: (url: string) => void, onLinkClick?: (url: string) => void) {
|
||||
return {
|
||||
// 代码块
|
||||
code({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
|
||||
@ -118,7 +120,7 @@ function createMarkdownComponents(onImageLinkClick?: (url: string) => void) {
|
||||
);
|
||||
},
|
||||
|
||||
// 链接 - 支持图片链接在灯箱中打开
|
||||
// 链接 - 支持图片链接在灯箱中打开,普通链接在预览窗口打开
|
||||
a({ href, children }: { href?: string; children?: React.ReactNode }) {
|
||||
// 如果是图片链接且有回调,则拦截点击事件
|
||||
if (href && onImageLinkClick && isImageUrl(href)) {
|
||||
@ -135,7 +137,22 @@ function createMarkdownComponents(onImageLinkClick?: (url: string) => void) {
|
||||
</a>
|
||||
);
|
||||
}
|
||||
// 非图片链接保持原有行为
|
||||
// 普通链接 - 如果有 onLinkClick 回调,则拦截点击事件在预览窗口打开
|
||||
if (href && onLinkClick) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onLinkClick(href);
|
||||
}}
|
||||
className="text-[var(--color-primary)] hover:underline cursor-pointer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
// 没有回调时保持原有行为
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
@ -242,11 +259,11 @@ function createMarkdownComponents(onImageLinkClick?: (url: string) => void) {
|
||||
}
|
||||
|
||||
// 使用 memo 包裹组件,避免不必要的重渲染
|
||||
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className, onImageLinkClick }: MarkdownRendererProps) {
|
||||
// 使用 useMemo 缓存 components 配置,仅在 onImageLinkClick 变化时重新创建
|
||||
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className, onImageLinkClick, onLinkClick }: MarkdownRendererProps) {
|
||||
// 使用 useMemo 缓存 components 配置,仅在回调变化时重新创建
|
||||
const components = useMemo(
|
||||
() => createMarkdownComponents(onImageLinkClick),
|
||||
[onImageLinkClick]
|
||||
() => createMarkdownComponents(onImageLinkClick, onLinkClick),
|
||||
[onImageLinkClick, onLinkClick]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -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;
|
||||
|
||||
185
src/lib/videoUtils.ts
Normal file
185
src/lib/videoUtils.ts
Normal file
@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 视频工具函数
|
||||
* 处理视频链接解析、时长格式化等
|
||||
*/
|
||||
|
||||
/**
|
||||
* 视频平台类型
|
||||
*/
|
||||
export type VideoPlatform = 'bilibili' | 'youtube' | 'douyin' | 'other';
|
||||
|
||||
/**
|
||||
* 视频解析信息
|
||||
*/
|
||||
export interface VideoInfo {
|
||||
platform: VideoPlatform;
|
||||
videoId: string;
|
||||
embedUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析视频链接,提取平台和视频ID
|
||||
* @param url 视频链接
|
||||
* @returns 视频信息,解析失败返回 null
|
||||
*/
|
||||
export function parseVideoUrl(url: string): VideoInfo | null {
|
||||
if (!url) return null;
|
||||
|
||||
try {
|
||||
// B站视频解析 - av号格式
|
||||
// 格式: bilibili.com/video/av123456 或 www.bilibili.com/video/av123456
|
||||
const bilibiliAvMatch = url.match(/bilibili\.com\/video\/av(\d+)/i);
|
||||
if (bilibiliAvMatch) {
|
||||
const aid = bilibiliAvMatch[1];
|
||||
return {
|
||||
platform: 'bilibili',
|
||||
videoId: `av${aid}`,
|
||||
embedUrl: `//player.bilibili.com/player.html?aid=${aid}&high_quality=1&danmaku=0`,
|
||||
};
|
||||
}
|
||||
|
||||
// B站视频解析 - BV号格式
|
||||
// 格式: bilibili.com/video/BV1xx411c7mD
|
||||
const bilibiliBvMatch = url.match(/bilibili\.com\/video\/(BV[\w]+)/i);
|
||||
if (bilibiliBvMatch) {
|
||||
const bvid = bilibiliBvMatch[1];
|
||||
return {
|
||||
platform: 'bilibili',
|
||||
videoId: bvid,
|
||||
embedUrl: `//player.bilibili.com/player.html?bvid=${bvid}&high_quality=1&danmaku=0`,
|
||||
};
|
||||
}
|
||||
|
||||
// YouTube 视频解析
|
||||
// 格式: youtube.com/watch?v=xxx 或 youtu.be/xxx
|
||||
const youtubeMatch = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/);
|
||||
if (youtubeMatch) {
|
||||
const videoId = youtubeMatch[1];
|
||||
return {
|
||||
platform: 'youtube',
|
||||
videoId,
|
||||
embedUrl: `https://www.youtube.com/embed/${videoId}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 抖音视频解析 (预留)
|
||||
// 格式: douyin.com/video/xxx
|
||||
const douyinMatch = url.match(/douyin\.com\/video\/(\d+)/);
|
||||
if (douyinMatch) {
|
||||
return {
|
||||
platform: 'douyin',
|
||||
videoId: douyinMatch[1],
|
||||
embedUrl: '', // 抖音暂不支持嵌入
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化视频时长
|
||||
* @param seconds 秒数(字符串或数字)
|
||||
* @returns 格式化后的时长 (如 "2:34" 或 "1:02:34")
|
||||
*/
|
||||
export function formatDuration(seconds: string | number): string {
|
||||
const totalSeconds = typeof seconds === 'string' ? parseInt(seconds, 10) : seconds;
|
||||
|
||||
if (isNaN(totalSeconds) || totalSeconds < 0) {
|
||||
return '--:--';
|
||||
}
|
||||
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const secs = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频平台显示名称
|
||||
* @param platform 平台标识
|
||||
* @returns 平台中文名称
|
||||
*/
|
||||
export function getPlatformName(platform: VideoPlatform): string {
|
||||
const names: Record<VideoPlatform, string> = {
|
||||
bilibili: 'B站',
|
||||
youtube: 'YouTube',
|
||||
douyin: '抖音',
|
||||
other: '视频',
|
||||
};
|
||||
return names[platform] || '视频';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频平台主题色
|
||||
* @param platform 平台标识
|
||||
* @returns 平台主题色 (hex)
|
||||
*/
|
||||
export function getPlatformColor(platform: VideoPlatform): string {
|
||||
const colors: Record<VideoPlatform, string> = {
|
||||
bilibili: '#fb7299',
|
||||
youtube: '#ff0000',
|
||||
douyin: '#000000',
|
||||
other: '#6b7280',
|
||||
};
|
||||
return colors[platform] || '#6b7280';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测视频链接的平台
|
||||
* @param url 视频链接
|
||||
* @returns 平台标识
|
||||
*/
|
||||
export function detectPlatform(url: string): VideoPlatform {
|
||||
if (!url) return 'other';
|
||||
|
||||
if (url.includes('bilibili.com')) return 'bilibili';
|
||||
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'youtube';
|
||||
if (url.includes('douyin.com')) return 'douyin';
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查视频是否支持嵌入播放
|
||||
* @param platform 平台标识
|
||||
* @returns 是否支持嵌入
|
||||
*/
|
||||
export function supportsEmbed(platform: VideoPlatform): boolean {
|
||||
return platform === 'bilibili' || platform === 'youtube';
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成带选集参数的嵌入 URL
|
||||
* @param url 原始视频链接
|
||||
* @param page 选集编号(从1开始)
|
||||
* @returns 嵌入 URL
|
||||
*/
|
||||
export function getEmbedUrlWithPage(url: string, page: number = 1): string | null {
|
||||
const videoInfo = parseVideoUrl(url);
|
||||
if (!videoInfo) return null;
|
||||
|
||||
if (videoInfo.platform === 'bilibili') {
|
||||
// B站嵌入链接支持 p 参数指定分P
|
||||
return `${videoInfo.embedUrl}&p=${page}`;
|
||||
}
|
||||
|
||||
// 其他平台暂不支持选集
|
||||
return videoInfo.embedUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查平台是否支持选集
|
||||
* @param platform 平台标识
|
||||
* @returns 是否支持选集
|
||||
*/
|
||||
export function supportsEpisodes(platform: VideoPlatform): boolean {
|
||||
return platform === 'bilibili';
|
||||
}
|
||||
@ -33,6 +33,7 @@ import {
|
||||
type MetasoSearchInput,
|
||||
type MetasoSearchResponse,
|
||||
type MetasoImageResult,
|
||||
type MetasoVideoResult,
|
||||
} from './metasoSearch';
|
||||
import {
|
||||
metasoReader,
|
||||
@ -58,6 +59,8 @@ export interface ToolExecutionResult {
|
||||
images?: string[];
|
||||
/** 搜索到的图片数组(图片搜索时产生) */
|
||||
searchImages?: MetasoImageResult[];
|
||||
/** 搜索到的视频数组(视频搜索时产生) */
|
||||
searchVideos?: MetasoVideoResult[];
|
||||
/** 是否需要浏览器端 Pyodide 执行 */
|
||||
requiresPyodide?: boolean;
|
||||
/** 代码内容(当 requiresPyodide 为 true 时) */
|
||||
@ -148,12 +151,12 @@ export async function executeTool(
|
||||
|
||||
case 'mita_search': {
|
||||
const query = String(input.query || '');
|
||||
const scope = (input.scope as 'webpage' | 'image') || 'webpage';
|
||||
const scope = (input.scope as 'webpage' | 'image' | 'video') || 'webpage';
|
||||
|
||||
// 图片搜索时请求更多图片用于验证筛选,网页搜索保持原有逻辑
|
||||
// 图片搜索时请求更多图片用于验证筛选,视频和网页搜索保持原有逻辑
|
||||
const requestSize = scope === 'image'
|
||||
? ImageValidationConfig.REQUEST_SIZE
|
||||
: (input.size ? Number(input.size) : 10);
|
||||
: (input.size ? Number(input.size) : (scope === 'video' ? 5 : 10));
|
||||
|
||||
const searchInput: MetasoSearchInput = {
|
||||
query,
|
||||
@ -185,6 +188,8 @@ export async function executeTool(
|
||||
rawData: response,
|
||||
// 如果是图片搜索,返回验证后的图片数据
|
||||
searchImages: scope === 'image' ? validatedImages : undefined,
|
||||
// 如果是视频搜索,返回视频数据
|
||||
searchVideos: scope === 'video' ? response.videos : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 秘塔AI搜索工具服务
|
||||
* 使用秘塔AI API 实现智能搜索(支持网页搜索和图片搜索)
|
||||
* 使用秘塔AI API 实现智能搜索(支持网页搜索、图片搜索和视频搜索)
|
||||
*/
|
||||
|
||||
// ============ 图片验证相关常量 ============
|
||||
@ -9,6 +9,9 @@ const IMAGE_VALIDATION_CONCURRENCY = 5; // 并发验证数量
|
||||
const IMAGE_REQUEST_SIZE = 15; // 图片搜索时请求的数量
|
||||
const IMAGE_TARGET_COUNT = 10; // 返回给前端的图片数量(前端会显示5张+5张备用)
|
||||
|
||||
// ============ 视频搜索相关常量 ============
|
||||
const VIDEO_DEFAULT_SIZE = 5; // 视频搜索默认数量
|
||||
|
||||
// 网页搜索结果
|
||||
export interface MetasoSearchResult {
|
||||
title: string;
|
||||
@ -31,23 +34,38 @@ export interface MetasoImageResult {
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
// 视频搜索结果
|
||||
export interface MetasoVideoResult {
|
||||
title: string;
|
||||
link: string;
|
||||
snippet: string;
|
||||
score: string;
|
||||
position: number;
|
||||
authors: string[];
|
||||
date: string;
|
||||
duration: string;
|
||||
coverImage: string;
|
||||
}
|
||||
|
||||
// 搜索响应
|
||||
export interface MetasoSearchResponse {
|
||||
success: boolean;
|
||||
credits?: number;
|
||||
total?: number;
|
||||
scope?: 'webpage' | 'image';
|
||||
scope?: 'webpage' | 'image' | 'video';
|
||||
// 网页搜索结果
|
||||
results?: MetasoSearchResult[];
|
||||
// 图片搜索结果
|
||||
images?: MetasoImageResult[];
|
||||
// 视频搜索结果
|
||||
videos?: MetasoVideoResult[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 搜索输入参数
|
||||
export interface MetasoSearchInput {
|
||||
query: string;
|
||||
scope?: 'webpage' | 'image';
|
||||
scope?: 'webpage' | 'image' | 'video';
|
||||
size?: number;
|
||||
/** 页码,用于获取不同页的结果(从1开始) */
|
||||
page?: number;
|
||||
@ -71,7 +89,8 @@ export async function metasoSearch(
|
||||
}
|
||||
|
||||
const scope = input.scope || 'webpage';
|
||||
const size = input.size || (scope === 'image' ? 5 : 10); // 图片默认5张
|
||||
// 根据 scope 设置默认 size
|
||||
const size = input.size || (scope === 'image' ? 5 : scope === 'video' ? VIDEO_DEFAULT_SIZE : 10);
|
||||
|
||||
try {
|
||||
const response = await fetch('https://metaso.cn/api/v1/search', {
|
||||
@ -111,7 +130,36 @@ export async function metasoSearch(
|
||||
const data = await response.json();
|
||||
|
||||
// 根据 scope 解析不同的结果
|
||||
if (scope === 'image') {
|
||||
if (scope === 'video') {
|
||||
// 视频搜索结果
|
||||
return {
|
||||
success: true,
|
||||
credits: data.credits,
|
||||
total: data.total,
|
||||
scope: 'video',
|
||||
videos: data.videos?.map((item: {
|
||||
title: string;
|
||||
link: string;
|
||||
snippet: string;
|
||||
score: string;
|
||||
position: number;
|
||||
authors?: string[];
|
||||
date?: string;
|
||||
duration?: string;
|
||||
coverImage?: string;
|
||||
}) => ({
|
||||
title: item.title,
|
||||
link: item.link,
|
||||
snippet: item.snippet,
|
||||
score: item.score,
|
||||
position: item.position,
|
||||
authors: item.authors || [],
|
||||
date: item.date || '',
|
||||
duration: item.duration || '0',
|
||||
coverImage: item.coverImage || '',
|
||||
})),
|
||||
};
|
||||
} else if (scope === 'image') {
|
||||
// 图片搜索结果
|
||||
return {
|
||||
success: true,
|
||||
@ -179,6 +227,25 @@ export function formatMetasoSearchResults(response: MetasoSearchResponse): strin
|
||||
return `搜索失败: ${response.error}`;
|
||||
}
|
||||
|
||||
// 视频搜索结果
|
||||
if (response.scope === 'video' && response.videos) {
|
||||
let result = `## 秘塔视频搜索结果 (共${response.total}个)\n\n`;
|
||||
response.videos.forEach((item, index) => {
|
||||
result += `${index + 1}. **${item.title}**\n`;
|
||||
result += ` - 链接: ${item.link}\n`;
|
||||
result += ` - 时长: ${item.duration}秒\n`;
|
||||
if (item.authors && item.authors.length > 0) {
|
||||
result += ` - 作者: ${item.authors.join(', ')}\n`;
|
||||
}
|
||||
if (item.date) {
|
||||
result += ` - 发布日期: ${item.date}\n`;
|
||||
}
|
||||
result += ` - 摘要: ${item.snippet}\n`;
|
||||
result += '\n';
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// 图片搜索结果
|
||||
if (response.scope === 'image' && response.images) {
|
||||
let result = `## 秘塔图片搜索结果 (共${response.total}张)\n\n`;
|
||||
@ -339,6 +406,12 @@ export function formatMetasoSearchResultsShort(
|
||||
return `搜索失败: ${response.error}`;
|
||||
}
|
||||
|
||||
// 视频搜索结果
|
||||
if (response.scope === 'video' && response.videos) {
|
||||
const videoCount = response.videos.length;
|
||||
return `> 🎬 秘塔搜索「${query}」视频,找到 ${response.total || videoCount} 个相关视频`;
|
||||
}
|
||||
|
||||
// 图片搜索结果
|
||||
if (response.scope === 'image' && response.images) {
|
||||
const imageCount = response.images.length;
|
||||
|
||||
@ -36,6 +36,8 @@ export interface Message {
|
||||
usedTools?: string[];
|
||||
/** 搜索到的图片(图片搜索工具结果) */
|
||||
searchImages?: SearchImageData[];
|
||||
/** 搜索到的视频(视频搜索工具结果) */
|
||||
searchVideos?: SearchVideoData[];
|
||||
}
|
||||
|
||||
// 搜索图片数据类型
|
||||
@ -49,6 +51,28 @@ export interface SearchImageData {
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
// 搜索视频数据类型
|
||||
export interface SearchVideoData {
|
||||
/** 视频标题 */
|
||||
title: string;
|
||||
/** 视频链接 */
|
||||
link: string;
|
||||
/** 视频摘要 */
|
||||
snippet: string;
|
||||
/** 相关度评分 */
|
||||
score: string;
|
||||
/** 排序位置 */
|
||||
position: number;
|
||||
/** 作者列表 */
|
||||
authors: string[];
|
||||
/** 发布日期 */
|
||||
date: string;
|
||||
/** 时长(秒) */
|
||||
duration: string;
|
||||
/** 封面图URL */
|
||||
coverImage: string;
|
||||
}
|
||||
|
||||
// 工具调用记录
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user