Compare commits

..

No commits in common. "be03aebb0943fd205a9e58b461ca0bf446686858" and "92ec88e1a34ed5867e43f46b90b32dd5602c4e5b" have entirely different histories.

14 changed files with 101 additions and 2137 deletions

View File

@ -77,57 +77,6 @@ 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 助手。请遵循以下规则来回复用户:
@ -329,9 +278,6 @@ 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';
@ -477,23 +423,23 @@ export async function POST(request: Request) {
.where(eq(conversations.conversationId, conversationId));
// 发送完成事件
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'done',
messageId: assistantMessageId,
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
usedTools: usedTools.length > 0 ? usedTools : undefined,
});
})}\n\n`));
safeWriter.close();
controller.close();
} catch (error) {
console.error('Stream error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'error',
error: errorMessage,
});
safeWriter.close();
})}\n\n`));
controller.close();
}
},
});
@ -576,9 +522,6 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
metasoApiKey,
} = params;
// 创建安全的 stream 写入器
const safeWriter = createSafeStreamWriter(controller, encoder);
// 构建 Codex Response API 格式的输入(过滤空内容的消息)
const inputItems: CodexInputItem[] = [
...historyMessages
@ -719,20 +662,20 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
const delta = event.delta || '';
currentTextContent += delta;
fullContent += delta;
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
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;
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'text',
content: delta,
});
})}\n\n`));
}
} else if (event.type === 'response.function_call_arguments.delta') {
// 函数调用参数增量
@ -749,11 +692,11 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
arguments: '',
};
hasToolUse = true;
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_use_start',
id: item.call_id,
name: item.name,
});
})}\n\n`));
}
} else if (event.type === 'response.output_item.done') {
// 输出项完成
@ -765,12 +708,12 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
currentFunctionCall.arguments = item.arguments || currentFunctionCall.arguments;
functionCalls.push({ ...currentFunctionCall });
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
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') {
@ -805,18 +748,18 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
if (!usedTools.includes(fc.name)) {
usedTools.push(fc.name);
// 发送实时工具使用事件
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_used',
toolName: fc.name,
});
})}\n\n`));
}
// 发送工具执行开始事件
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_execution_start',
id: fc.call_id,
name: fc.name,
});
})}\n\n`));
// 解析工具参数
let toolInput: Record<string, unknown> = {};
@ -830,42 +773,32 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
const result = await executeTool(fc.name, toolInput, { metasoApiKey });
// 发送工具执行结果事件
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
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) {
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_search_images',
id: fc.call_id,
name: fc.name,
searchImages: result.searchImages,
});
}
// 如果有搜索视频结果,发送专门的视频事件
if (result.searchVideos && result.searchVideos.length > 0) {
safeWriter.write({
type: 'tool_search_videos',
id: fc.call_id,
name: fc.name,
searchVideos: result.searchVideos,
});
})}\n\n`));
}
// 将工具结果显示给用户
const toolDisplayText = `\n\n${result.displayResult}\n\n`;
fullContent += toolDisplayText;
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'text',
content: toolDisplayText,
});
})}\n\n`));
// 将工具结果添加到输入历史Codex 格式)
inputItems.push({
@ -937,9 +870,6 @@ 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() !== '')
@ -1100,17 +1030,17 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
if (delta.type === 'thinking_delta') {
currentThinkingContent += delta.thinking || '';
thinkingContent += delta.thinking || '';
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'thinking',
content: delta.thinking || '',
});
})}\n\n`));
} else if (delta.type === 'text_delta') {
currentTextContent += delta.text || '';
fullContent += delta.text || '';
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'text',
content: delta.text || '',
});
})}\n\n`));
} else if (delta.type === 'input_json_delta') {
if (currentToolUse) {
currentToolUse.inputJson += delta.partial_json || '';
@ -1134,11 +1064,11 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
name: event.content_block.name,
inputJson: '',
};
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
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) {
@ -1149,12 +1079,12 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
name: currentToolUse.name,
input: toolInput,
});
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_use_complete',
id: currentToolUse.id,
name: currentToolUse.name,
input: toolInput,
});
})}\n\n`));
} catch (e) {
console.error('Failed to parse tool input:', e);
}
@ -1209,28 +1139,28 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
if (!usedTools.includes(tc.name)) {
usedTools.push(tc.name);
// 发送实时工具使用事件
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_used',
toolName: tc.name,
});
})}\n\n`));
}
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_execution_start',
id: tc.id,
name: tc.name,
});
})}\n\n`));
const result = await executeTool(tc.name, tc.input, { metasoApiKey });
if (result.requiresPyodide) {
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'pyodide_execution_required',
id: tc.id,
name: tc.name,
code: result.code,
language: result.language,
});
})}\n\n`));
toolResults.push({
type: 'tool_result',
@ -1240,41 +1170,31 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
continue;
}
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
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) {
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_search_images',
id: tc.id,
name: tc.name,
searchImages: result.searchImages,
});
}
// 如果有搜索视频结果,发送专门的视频事件
if (result.searchVideos && result.searchVideos.length > 0) {
safeWriter.write({
type: 'tool_search_videos',
id: tc.id,
name: tc.name,
searchVideos: result.searchVideos,
});
})}\n\n`));
}
const toolDisplayText = `\n\n${result.displayResult}\n\n`;
fullContent += toolDisplayText;
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'text',
content: toolDisplayText,
});
})}\n\n`));
toolResults.push({
type: 'tool_result',
@ -1370,9 +1290,6 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
metasoApiKey,
} = params;
// 创建安全的 stream 写入器
const safeWriter = createSafeStreamWriter(controller, encoder);
// 构建 OpenAI 格式的消息历史(过滤空内容的消息)
const openaiMessages: OpenAICompatibleMessage[] = [
{ role: 'system', content: systemPrompt },
@ -1525,10 +1442,10 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
if (thinkPart) {
thinkingContent += thinkPart;
// 发送 thinking 事件(让前端可以展示折叠的思考内容)
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'thinking',
content: thinkPart,
});
})}\n\n`));
}
// 移除已处理的内容和结束标签
pendingBuffer = pendingBuffer.slice(endTagIndex + 8); // 8 = '</think>'.length
@ -1542,10 +1459,10 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
if (safePart) {
thinkingContent += safePart;
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'thinking',
content: safePart,
});
})}\n\n`));
}
pendingBuffer = keepPart;
}
@ -1562,10 +1479,10 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
if (textPart) {
currentTextContent += textPart;
fullContent += textPart;
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'text',
content: textPart,
});
})}\n\n`));
}
// 移除已处理的内容和开始标签
pendingBuffer = pendingBuffer.slice(startTagIndex + 7); // 7 = '<think>'.length
@ -1583,10 +1500,10 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
if (safePart) {
currentTextContent += safePart;
fullContent += safePart;
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'text',
content: safePart,
});
})}\n\n`));
}
pendingBuffer = keepPart;
// 等待更多数据
@ -1595,10 +1512,10 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
// 安全输出所有内容
currentTextContent += pendingBuffer;
fullContent += pendingBuffer;
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'text',
content: pendingBuffer,
});
})}\n\n`));
pendingBuffer = '';
}
}
@ -1620,11 +1537,11 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
});
if (toolCall.id) {
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_use_start',
id: toolCall.id,
name: toolCall.function?.name || '',
});
})}\n\n`));
}
}
@ -1662,18 +1579,18 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
// 如果还在 thinking 模式,说明 </think> 标签没有正常闭合
// 将剩余内容作为 thinking 内容处理
thinkingContent += pendingBuffer;
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'thinking',
content: pendingBuffer,
});
})}\n\n`));
} else {
// 普通文本模式,输出剩余内容
currentTextContent += pendingBuffer;
fullContent += pendingBuffer;
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'text',
content: pendingBuffer,
});
})}\n\n`));
}
pendingBuffer = '';
}
@ -1682,12 +1599,12 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
for (const [, tc] of currentToolCallsMap) {
if (tc.id && tc.name) {
toolCalls.push(tc);
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_use_complete',
id: tc.id,
name: tc.name,
input: JSON.parse(tc.arguments || '{}'),
});
})}\n\n`));
}
}
@ -1714,17 +1631,17 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
if (!usedTools.includes(tc.name)) {
usedTools.push(tc.name);
// 发送实时工具使用事件
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_used',
toolName: tc.name,
});
})}\n\n`));
}
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_execution_start',
id: tc.id,
name: tc.name,
});
})}\n\n`));
// 解析工具参数
let toolInput: Record<string, unknown> = {};
@ -1737,42 +1654,32 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
// 执行工具
const result = await executeTool(tc.name, toolInput, { metasoApiKey });
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
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) {
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_search_images',
id: tc.id,
name: tc.name,
searchImages: result.searchImages,
});
}
// 如果有搜索视频结果,发送专门的视频事件
if (result.searchVideos && result.searchVideos.length > 0) {
safeWriter.write({
type: 'tool_search_videos',
id: tc.id,
name: tc.name,
searchVideos: result.searchVideos,
});
})}\n\n`));
}
// 将工具结果显示给用户
const toolDisplayText = `\n\n${result.displayResult}\n\n`;
fullContent += toolDisplayText;
safeWriter.write({
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'text',
content: toolDisplayText,
});
})}\n\n`));
// 将工具结果添加到消息历史OpenAI 格式)
openaiMessages.push({
@ -1832,7 +1739,7 @@ function buildClaudeToolDefinitions(toolIds: string[]) {
},
mita_search: {
name: 'mita_search',
description: '秘塔AI智能搜索。支持网页搜索、图片搜索和视频搜索三种模式。当需要搜索高质量的中文内容或需要更精准的搜索结果时使用网页搜索;当用户明确要求搜索图片或需要图片素材时使用图片搜索;当用户需要搜索视频内容时使用视频搜索。',
description: '秘塔AI智能搜索。支持网页搜索和图片搜索两种模式。当需要搜索高质量的中文内容或需要更精准的搜索结果时使用网页搜索;当用户明确要求搜索图片或需要图片素材时使用图片搜索。',
input_schema: {
type: 'object',
properties: {
@ -1842,12 +1749,12 @@ function buildClaudeToolDefinitions(toolIds: string[]) {
},
scope: {
type: 'string',
enum: ['webpage', 'image', 'video'],
description: '搜索类型webpage网页搜索默认、image图片搜索或 video视频搜索)',
enum: ['webpage', 'image'],
description: '搜索类型webpage网页搜索默认或 image图片搜索)',
},
size: {
type: 'number',
description: '返回结果数量网页搜索默认10图片搜索默认5视频搜索默认5',
description: '返回结果数量网页搜索默认10图片搜索默认5',
},
},
required: ['query'],
@ -1915,7 +1822,7 @@ function buildOpenAIToolDefinitions(toolIds: string[]) {
type: 'function',
function: {
name: 'mita_search',
description: '秘塔AI智能搜索。支持网页搜索、图片搜索和视频搜索三种模式。当需要搜索高质量的中文内容或需要更精准的搜索结果时使用网页搜索;当用户明确要求搜索图片或需要图片素材时使用图片搜索;当用户需要搜索视频内容时使用视频搜索。',
description: '秘塔AI智能搜索。支持网页搜索和图片搜索两种模式。当需要搜索高质量的中文内容或需要更精准的搜索结果时使用网页搜索;当用户明确要求搜索图片或需要图片素材时使用图片搜索。',
parameters: {
type: 'object',
properties: {
@ -1925,12 +1832,12 @@ function buildOpenAIToolDefinitions(toolIds: string[]) {
},
scope: {
type: 'string',
enum: ['webpage', 'image', 'video'],
description: '搜索类型webpage网页搜索默认、image图片搜索或 video视频搜索)',
enum: ['webpage', 'image'],
description: '搜索类型webpage网页搜索默认或 image图片搜索)',
},
size: {
type: 'number',
description: '返回结果数量网页搜索默认10图片搜索默认5视频搜索默认5',
description: '返回结果数量网页搜索默认10图片搜索默认5',
},
},
required: ['query'],
@ -1997,7 +1904,7 @@ function buildCodexToolDefinitions(toolIds: string[]) {
mita_search: {
type: 'function',
name: 'mita_search',
description: '秘塔AI智能搜索。支持网页搜索、图片搜索和视频搜索三种模式。当需要搜索高质量的中文内容或需要更精准的搜索结果时使用网页搜索;当用户明确要求搜索图片或需要图片素材时使用图片搜索;当用户需要搜索视频内容时使用视频搜索。',
description: '秘塔AI智能搜索。支持网页搜索和图片搜索两种模式。当需要搜索高质量的中文内容或需要更精准的搜索结果时使用网页搜索;当用户明确要求搜索图片或需要图片素材时使用图片搜索。',
parameters: {
type: 'object',
properties: {
@ -2007,12 +1914,12 @@ function buildCodexToolDefinitions(toolIds: string[]) {
},
scope: {
type: 'string',
enum: ['webpage', 'image', 'video'],
description: '搜索类型webpage网页搜索默认、image图片搜索或 video视频搜索)',
enum: ['webpage', 'image'],
description: '搜索类型webpage网页搜索默认或 image图片搜索)',
},
size: {
type: 'number',
description: '返回结果数量网页搜索默认10图片搜索默认5视频搜索默认5',
description: '返回结果数量网页搜索默认10图片搜索默认5',
},
},
required: ['query'],

View File

@ -1,149 +0,0 @@
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 }
);
}
}

View File

@ -10,7 +10,6 @@ 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';
@ -48,10 +47,6 @@ 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();
@ -347,12 +342,6 @@ export default function ChatPage({ params }: PageProps) {
}
};
// 处理链接点击 - 在预览弹窗中打开
const handleLinkClick = (url: string) => {
setLinkPreviewUrl(url);
setLinkPreviewOpen(true);
};
// 转换模型格式
const modelOptions = models.map((m) => ({
id: m.modelId,
@ -579,14 +568,12 @@ 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}
/>
))
@ -637,13 +624,6 @@ export default function ChatPage({ params }: PageProps) {
conversationId={chatId}
/>
{/* 链接预览弹窗 */}
<LinkPreviewModal
url={linkPreviewUrl}
isOpen={linkPreviewOpen}
onClose={() => setLinkPreviewOpen(false)}
/>
{/* 提示词优化工具浮动按钮 */}
<PromptOptimizer onUsePrompt={setOptimizedPrompt} />
</div>

View File

@ -1,144 +0,0 @@
'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>
);
}

View File

@ -1,502 +0,0 @@
'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>
);
}

View File

@ -8,7 +8,6 @@ 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';
@ -34,8 +33,6 @@ interface MessageBubbleProps {
images?: string[];
/** 搜索到的图片(来自图片搜索工具) */
searchImages?: SearchImageItem[];
/** 搜索到的视频(来自视频搜索工具) */
searchVideos?: SearchVideoItem[];
/** 用户上传的图片Base64 或 URL */
uploadedImages?: string[];
/** 用户上传的文档 */
@ -52,8 +49,6 @@ interface MessageBubbleProps {
onRegenerate?: (messageId: string) => void;
/** 保存到笔记回调(仅对 AI 消息有效),传入消息内容 */
onSaveToNote?: (content: string) => void;
/** 链接点击回调,用于在预览窗口中打开链接 */
onLinkClick?: (url: string) => void;
/** 对话 ID用于关联笔记来源 */
conversationId?: string;
}
@ -75,7 +70,7 @@ function getDocumentIcon(type: string) {
return FileText;
}
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, searchImages, searchVideos, uploadedImages, uploadedDocuments, usedTools, pyodideStatus, onRegenerate, onSaveToNote, onLinkClick, conversationId }: MessageBubbleProps) {
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, searchImages, uploadedImages, uploadedDocuments, usedTools, pyodideStatus, onRegenerate, onSaveToNote, conversationId }: MessageBubbleProps) {
const isUser = message.role === 'user';
const [thinkingExpanded, setThinkingExpanded] = useState(false);
const [copied, setCopied] = useState(false);
@ -289,17 +284,11 @@ 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)]">

View File

@ -1,246 +0,0 @@
'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}
/>
</>
);
}

View File

@ -1,497 +0,0 @@
'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>
);
}

View File

@ -11,8 +11,6 @@ interface MarkdownRendererProps {
className?: string;
/** 图片链接点击回调,用于在灯箱中打开图片 */
onImageLinkClick?: (url: string) => void;
/** 普通链接点击回调,用于在预览窗口中打开 */
onLinkClick?: (url: string) => void;
}
/**
@ -30,7 +28,7 @@ function isImageUrl(url: string): boolean {
* Markdown
* 使
*/
function createMarkdownComponents(onImageLinkClick?: (url: string) => void, onLinkClick?: (url: string) => void) {
function createMarkdownComponents(onImageLinkClick?: (url: string) => void) {
return {
// 代码块
code({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
@ -120,7 +118,7 @@ function createMarkdownComponents(onImageLinkClick?: (url: string) => void, onLi
);
},
// 链接 - 支持图片链接在灯箱中打开,普通链接在预览窗口打开
// 链接 - 支持图片链接在灯箱中打开
a({ href, children }: { href?: string; children?: React.ReactNode }) {
// 如果是图片链接且有回调,则拦截点击事件
if (href && onImageLinkClick && isImageUrl(href)) {
@ -137,22 +135,7 @@ function createMarkdownComponents(onImageLinkClick?: (url: string) => void, onLi
</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}
@ -259,11 +242,11 @@ function createMarkdownComponents(onImageLinkClick?: (url: string) => void, onLi
}
// 使用 memo 包裹组件,避免不必要的重渲染
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className, onImageLinkClick, onLinkClick }: MarkdownRendererProps) {
// 使用 useMemo 缓存 components 配置,仅在回调变化时重新创建
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className, onImageLinkClick }: MarkdownRendererProps) {
// 使用 useMemo 缓存 components 配置,仅在 onImageLinkClick 变化时重新创建
const components = useMemo(
() => createMarkdownComponents(onImageLinkClick, onLinkClick),
[onImageLinkClick, onLinkClick]
() => createMarkdownComponents(onImageLinkClick),
[onImageLinkClick]
);
return (

View File

@ -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' | 'tool_search_videos' | 'pyodide_execution_required' | 'tool_used' | 'done' | 'error';
type: 'thinking' | 'text' | 'tool_use_start' | 'tool_execution_result' | 'tool_search_images' | 'pyodide_execution_required' | 'tool_used' | 'done' | 'error';
content?: string;
id?: string;
name?: string;
@ -21,8 +21,6 @@ export interface StreamMessage {
images?: string[];
// 搜索到的图片
searchImages?: SearchImageData[];
// 搜索到的视频
searchVideos?: SearchVideoData[];
// 工具使用相关
toolName?: string;
usedTools?: string[];
@ -39,19 +37,6 @@ 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';
@ -65,8 +50,6 @@ export interface ChatMessage {
images?: string[];
// 搜索到的图片
searchImages?: SearchImageData[];
// 搜索到的视频
searchVideos?: SearchVideoData[];
// 用户上传的图片Base64
uploadedImages?: string[];
// 用户上传的文档
@ -127,29 +110,6 @@ 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
*/
@ -244,8 +204,6 @@ export function useStreamChat() {
const pendingImagesRef = useRef<string[]>([]);
// 临时存储搜索到的图片,等待 messageId
const pendingSearchImagesRef = useRef<SearchImageData[]>([]);
// 临时存储搜索到的视频,等待 messageId
const pendingSearchVideosRef = useRef<SearchVideoData[]>([]);
// 发送消息
const sendMessage = useCallback(async (options: {
@ -480,28 +438,6 @@ 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) {
@ -600,12 +536,6 @@ 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;

View File

@ -1,185 +0,0 @@
/**
*
*
*/
/**
*
*/
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';
}

View File

@ -33,7 +33,6 @@ import {
type MetasoSearchInput,
type MetasoSearchResponse,
type MetasoImageResult,
type MetasoVideoResult,
} from './metasoSearch';
import {
metasoReader,
@ -59,8 +58,6 @@ export interface ToolExecutionResult {
images?: string[];
/** 搜索到的图片数组(图片搜索时产生) */
searchImages?: MetasoImageResult[];
/** 搜索到的视频数组(视频搜索时产生) */
searchVideos?: MetasoVideoResult[];
/** 是否需要浏览器端 Pyodide 执行 */
requiresPyodide?: boolean;
/** 代码内容(当 requiresPyodide 为 true 时) */
@ -151,12 +148,12 @@ export async function executeTool(
case 'mita_search': {
const query = String(input.query || '');
const scope = (input.scope as 'webpage' | 'image' | 'video') || 'webpage';
const scope = (input.scope as 'webpage' | 'image') || 'webpage';
// 图片搜索时请求更多图片用于验证筛选,视频和网页搜索保持原有逻辑
// 图片搜索时请求更多图片用于验证筛选,网页搜索保持原有逻辑
const requestSize = scope === 'image'
? ImageValidationConfig.REQUEST_SIZE
: (input.size ? Number(input.size) : (scope === 'video' ? 5 : 10));
: (input.size ? Number(input.size) : 10);
const searchInput: MetasoSearchInput = {
query,
@ -188,8 +185,6 @@ export async function executeTool(
rawData: response,
// 如果是图片搜索,返回验证后的图片数据
searchImages: scope === 'image' ? validatedImages : undefined,
// 如果是视频搜索,返回视频数据
searchVideos: scope === 'video' ? response.videos : undefined,
};
}

View File

@ -1,6 +1,6 @@
/**
* AI搜索工具服务
* 使AI API
* 使AI API
*/
// ============ 图片验证相关常量 ============
@ -9,9 +9,6 @@ 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;
@ -34,38 +31,23 @@ 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' | 'video';
scope?: 'webpage' | 'image';
// 网页搜索结果
results?: MetasoSearchResult[];
// 图片搜索结果
images?: MetasoImageResult[];
// 视频搜索结果
videos?: MetasoVideoResult[];
error?: string;
}
// 搜索输入参数
export interface MetasoSearchInput {
query: string;
scope?: 'webpage' | 'image' | 'video';
scope?: 'webpage' | 'image';
size?: number;
/** 页码用于获取不同页的结果从1开始 */
page?: number;
@ -89,8 +71,7 @@ export async function metasoSearch(
}
const scope = input.scope || 'webpage';
// 根据 scope 设置默认 size
const size = input.size || (scope === 'image' ? 5 : scope === 'video' ? VIDEO_DEFAULT_SIZE : 10);
const size = input.size || (scope === 'image' ? 5 : 10); // 图片默认5张
try {
const response = await fetch('https://metaso.cn/api/v1/search', {
@ -130,36 +111,7 @@ export async function metasoSearch(
const data = await response.json();
// 根据 scope 解析不同的结果
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') {
if (scope === 'image') {
// 图片搜索结果
return {
success: true,
@ -227,25 +179,6 @@ 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`;
@ -406,12 +339,6 @@ 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;

View File

@ -36,8 +36,6 @@ export interface Message {
usedTools?: string[];
/** 搜索到的图片(图片搜索工具结果) */
searchImages?: SearchImageData[];
/** 搜索到的视频(视频搜索工具结果) */
searchVideos?: SearchVideoData[];
}
// 搜索图片数据类型
@ -51,28 +49,6 @@ 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;