Compare commits
8 Commits
5ad191684a
...
cb05f06483
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb05f06483 | ||
|
|
8bfe420676 | ||
|
|
615a59567d | ||
|
|
3459f3821f | ||
|
|
5a6a147bd8 | ||
|
|
f0cc0eb996 | ||
|
|
97d89f44ac | ||
|
|
baf27ceca6 |
6
.gitignore
vendored
6
.gitignore
vendored
@ -42,3 +42,9 @@ next-env.d.ts
|
||||
|
||||
# docs
|
||||
/docs
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
# Other projects
|
||||
cchcode-ui/
|
||||
|
||||
@ -9,7 +9,7 @@ export default defineConfig({
|
||||
port: parseInt(process.env.DB_PORT || '35433'),
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
database: process.env.DB_NAME || 'lioncode_ui',
|
||||
database: process.env.DB_NAME || 'cchcode_ui',
|
||||
ssl: false,
|
||||
},
|
||||
});
|
||||
|
||||
@ -233,6 +233,9 @@ export async function POST(request: Request) {
|
||||
// 解密 API Key
|
||||
const decryptedApiKey = decryptApiKey(settings.cchApiKey);
|
||||
|
||||
// 解密秘塔 API Key(如果已配置)
|
||||
const decryptedMetasoApiKey = settings.metasoApiKey ? decryptApiKey(settings.metasoApiKey) : undefined;
|
||||
|
||||
// 获取对话信息
|
||||
const conversation = await db.query.conversations.findFirst({
|
||||
where: eq(conversations.conversationId, conversationId),
|
||||
@ -307,6 +310,7 @@ export async function POST(request: Request) {
|
||||
let thinkingContent = '';
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
let usedTools: string[] = []; // 收集使用过的工具名称
|
||||
|
||||
// 【重要】处理器选择优先级说明:
|
||||
// 1. 首先检查 apiFormat === 'openai':如果用户选择了 "OpenAI 兼容" 格式,
|
||||
@ -332,12 +336,14 @@ export async function POST(request: Request) {
|
||||
controller,
|
||||
encoder,
|
||||
images,
|
||||
metasoApiKey: decryptedMetasoApiKey,
|
||||
});
|
||||
|
||||
fullContent = result.fullContent;
|
||||
thinkingContent = result.thinkingContent;
|
||||
totalInputTokens = result.inputTokens;
|
||||
totalOutputTokens = result.outputTokens;
|
||||
usedTools = result.usedTools;
|
||||
} else if (isCodex) {
|
||||
// ==================== Codex 模型处理(使用 Codex Response API) ====================
|
||||
// 仅当使用 Claude 原生格式 + Codex 模型时,才使用 /v1/responses 端点
|
||||
@ -355,11 +361,13 @@ export async function POST(request: Request) {
|
||||
controller,
|
||||
encoder,
|
||||
images, // 传递用户上传的图片
|
||||
metasoApiKey: decryptedMetasoApiKey,
|
||||
});
|
||||
|
||||
fullContent = result.fullContent;
|
||||
totalInputTokens = result.inputTokens;
|
||||
totalOutputTokens = result.outputTokens;
|
||||
usedTools = result.usedTools;
|
||||
} else {
|
||||
// ==================== Claude 原生格式处理 ====================
|
||||
console.log('[API/chat] 使用 Claude 原生格式 (/v1/messages)');
|
||||
@ -376,12 +384,14 @@ export async function POST(request: Request) {
|
||||
controller,
|
||||
encoder,
|
||||
images, // 传递用户上传的图片
|
||||
metasoApiKey: decryptedMetasoApiKey,
|
||||
});
|
||||
|
||||
fullContent = result.fullContent;
|
||||
thinkingContent = result.thinkingContent;
|
||||
totalInputTokens = result.inputTokens;
|
||||
totalOutputTokens = result.outputTokens;
|
||||
usedTools = result.usedTools;
|
||||
}
|
||||
|
||||
// 保存 AI 回复到数据库
|
||||
@ -391,6 +401,7 @@ export async function POST(request: Request) {
|
||||
role: 'assistant',
|
||||
content: fullContent,
|
||||
thinkingContent: thinkingContent || null,
|
||||
usedTools: usedTools.length > 0 ? usedTools : null,
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
status: 'completed',
|
||||
@ -417,6 +428,7 @@ export async function POST(request: Request) {
|
||||
messageId: assistantMessageId,
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
usedTools: usedTools.length > 0 ? usedTools : undefined,
|
||||
})}\n\n`));
|
||||
|
||||
controller.close();
|
||||
@ -466,6 +478,8 @@ interface CodexChatParams {
|
||||
media_type: string;
|
||||
data: string;
|
||||
}[];
|
||||
// 秘塔 API Key
|
||||
metasoApiKey?: string;
|
||||
}
|
||||
|
||||
// Codex Response API 的输入项类型
|
||||
@ -491,6 +505,7 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
|
||||
fullContent: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
usedTools: string[];
|
||||
}> {
|
||||
const {
|
||||
cchUrl,
|
||||
@ -504,6 +519,7 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
|
||||
controller,
|
||||
encoder,
|
||||
images,
|
||||
metasoApiKey,
|
||||
} = params;
|
||||
|
||||
// 构建 Codex Response API 格式的输入(过滤空内容的消息)
|
||||
@ -568,6 +584,7 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
|
||||
let fullContent = '';
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
const usedTools: string[] = []; // 收集使用过的工具名称
|
||||
let loopCount = 0;
|
||||
const maxLoops = 10;
|
||||
|
||||
@ -727,6 +744,16 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
|
||||
|
||||
// 执行所有工具并收集结果
|
||||
for (const fc of functionCalls) {
|
||||
// 收集工具名称(避免重复)
|
||||
if (!usedTools.includes(fc.name)) {
|
||||
usedTools.push(fc.name);
|
||||
// 发送实时工具使用事件
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'tool_used',
|
||||
toolName: fc.name,
|
||||
})}\n\n`));
|
||||
}
|
||||
|
||||
// 发送工具执行开始事件
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'tool_execution_start',
|
||||
@ -743,7 +770,7 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
|
||||
}
|
||||
|
||||
// 执行工具
|
||||
const result = await executeTool(fc.name, toolInput);
|
||||
const result = await executeTool(fc.name, toolInput, { metasoApiKey });
|
||||
|
||||
// 发送工具执行结果事件
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
@ -755,6 +782,16 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
|
||||
images: result.images,
|
||||
})}\n\n`));
|
||||
|
||||
// 如果有搜索图片结果,发送专门的图片事件
|
||||
if (result.searchImages && result.searchImages.length > 0) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'tool_search_images',
|
||||
id: fc.call_id,
|
||||
name: fc.name,
|
||||
searchImages: result.searchImages,
|
||||
})}\n\n`));
|
||||
}
|
||||
|
||||
// 将工具结果显示给用户
|
||||
const toolDisplayText = `\n\n${result.displayResult}\n\n`;
|
||||
fullContent += toolDisplayText;
|
||||
@ -783,6 +820,7 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
|
||||
fullContent,
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
usedTools,
|
||||
};
|
||||
}
|
||||
|
||||
@ -805,6 +843,8 @@ interface ClaudeChatParams {
|
||||
media_type: string;
|
||||
data: string;
|
||||
}[];
|
||||
// 秘塔 API Key
|
||||
metasoApiKey?: string;
|
||||
}
|
||||
|
||||
async function handleClaudeChat(params: ClaudeChatParams): Promise<{
|
||||
@ -812,6 +852,7 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
|
||||
thinkingContent: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
usedTools: string[];
|
||||
}> {
|
||||
const {
|
||||
cchUrl,
|
||||
@ -826,6 +867,7 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
|
||||
controller,
|
||||
encoder,
|
||||
images,
|
||||
metasoApiKey,
|
||||
} = params;
|
||||
|
||||
// 构建消息历史(过滤空内容的消息)
|
||||
@ -890,6 +932,7 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
|
||||
let thinkingContent = '';
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
const usedTools: string[] = []; // 收集使用过的工具名称
|
||||
let loopCount = 0;
|
||||
const maxLoops = 10;
|
||||
let hasToolResults = false;
|
||||
@ -1092,13 +1135,23 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
|
||||
const toolResults: ContentBlock[] = [];
|
||||
|
||||
for (const tc of toolCalls) {
|
||||
// 收集工具名称(避免重复)
|
||||
if (!usedTools.includes(tc.name)) {
|
||||
usedTools.push(tc.name);
|
||||
// 发送实时工具使用事件
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'tool_used',
|
||||
toolName: tc.name,
|
||||
})}\n\n`));
|
||||
}
|
||||
|
||||
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);
|
||||
const result = await executeTool(tc.name, tc.input, { metasoApiKey });
|
||||
|
||||
if (result.requiresPyodide) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
@ -1126,6 +1179,16 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
|
||||
images: result.images,
|
||||
})}\n\n`));
|
||||
|
||||
// 如果有搜索图片结果,发送专门的图片事件
|
||||
if (result.searchImages && result.searchImages.length > 0) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'tool_search_images',
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
searchImages: result.searchImages,
|
||||
})}\n\n`));
|
||||
}
|
||||
|
||||
const toolDisplayText = `\n\n${result.displayResult}\n\n`;
|
||||
fullContent += toolDisplayText;
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
@ -1157,6 +1220,7 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
|
||||
thinkingContent,
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
usedTools,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1177,6 +1241,8 @@ interface OpenAICompatibleChatParams {
|
||||
media_type: string;
|
||||
data: string;
|
||||
}[];
|
||||
// 秘塔 API Key
|
||||
metasoApiKey?: string;
|
||||
}
|
||||
|
||||
// OpenAI 消息格式
|
||||
@ -1207,6 +1273,7 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
|
||||
thinkingContent: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
usedTools: string[];
|
||||
}> {
|
||||
const {
|
||||
cchUrl,
|
||||
@ -1220,6 +1287,7 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
|
||||
controller,
|
||||
encoder,
|
||||
images,
|
||||
metasoApiKey,
|
||||
} = params;
|
||||
|
||||
// 构建 OpenAI 格式的消息历史(过滤空内容的消息)
|
||||
@ -1280,6 +1348,7 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
|
||||
let thinkingContent = ''; // 用于收集 <think> 标签中的思考内容
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
const usedTools: string[] = []; // 收集使用过的工具名称
|
||||
let loopCount = 0;
|
||||
const maxLoops = 10;
|
||||
|
||||
@ -1558,6 +1627,16 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
|
||||
|
||||
// 执行所有工具并收集结果
|
||||
for (const tc of toolCalls) {
|
||||
// 收集工具名称(避免重复)
|
||||
if (!usedTools.includes(tc.name)) {
|
||||
usedTools.push(tc.name);
|
||||
// 发送实时工具使用事件
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'tool_used',
|
||||
toolName: tc.name,
|
||||
})}\n\n`));
|
||||
}
|
||||
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'tool_execution_start',
|
||||
id: tc.id,
|
||||
@ -1573,7 +1652,7 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
|
||||
}
|
||||
|
||||
// 执行工具
|
||||
const result = await executeTool(tc.name, toolInput);
|
||||
const result = await executeTool(tc.name, toolInput, { metasoApiKey });
|
||||
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'tool_execution_result',
|
||||
@ -1584,6 +1663,16 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
|
||||
images: result.images,
|
||||
})}\n\n`));
|
||||
|
||||
// 如果有搜索图片结果,发送专门的图片事件
|
||||
if (result.searchImages && result.searchImages.length > 0) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'tool_search_images',
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
searchImages: result.searchImages,
|
||||
})}\n\n`));
|
||||
}
|
||||
|
||||
// 将工具结果显示给用户
|
||||
const toolDisplayText = `\n\n${result.displayResult}\n\n`;
|
||||
fullContent += toolDisplayText;
|
||||
@ -1613,6 +1702,7 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
|
||||
thinkingContent,
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
usedTools,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1647,6 +1737,43 @@ function buildClaudeToolDefinitions(toolIds: string[]) {
|
||||
required: ['url'],
|
||||
},
|
||||
},
|
||||
mita_search: {
|
||||
name: 'mita_search',
|
||||
description: '秘塔AI智能搜索。支持网页搜索和图片搜索两种模式。当需要搜索高质量的中文内容或需要更精准的搜索结果时使用网页搜索;当用户明确要求搜索图片或需要图片素材时使用图片搜索。',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: '搜索查询关键词',
|
||||
},
|
||||
scope: {
|
||||
type: 'string',
|
||||
enum: ['webpage', 'image'],
|
||||
description: '搜索类型:webpage(网页搜索,默认)或 image(图片搜索)',
|
||||
},
|
||||
size: {
|
||||
type: 'number',
|
||||
description: '返回结果数量,网页搜索默认10,图片搜索默认5',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
mita_reader: {
|
||||
name: 'mita_reader',
|
||||
description: '秘塔AI网页读取。获取网页内容并返回结构化的Markdown格式,适合阅读长文章。',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: '要读取的网页URL',
|
||||
},
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return toolIds
|
||||
@ -1691,6 +1818,49 @@ function buildOpenAIToolDefinitions(toolIds: string[]) {
|
||||
},
|
||||
},
|
||||
},
|
||||
mita_search: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'mita_search',
|
||||
description: '秘塔AI智能搜索。支持网页搜索和图片搜索两种模式。当需要搜索高质量的中文内容或需要更精准的搜索结果时使用网页搜索;当用户明确要求搜索图片或需要图片素材时使用图片搜索。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: '搜索查询关键词',
|
||||
},
|
||||
scope: {
|
||||
type: 'string',
|
||||
enum: ['webpage', 'image'],
|
||||
description: '搜索类型:webpage(网页搜索,默认)或 image(图片搜索)',
|
||||
},
|
||||
size: {
|
||||
type: 'number',
|
||||
description: '返回结果数量,网页搜索默认10,图片搜索默认5',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
},
|
||||
mita_reader: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'mita_reader',
|
||||
description: '秘塔AI网页读取。获取网页内容并返回结构化的Markdown格式,适合阅读长文章。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: '要读取的网页URL',
|
||||
},
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return toolIds
|
||||
@ -1731,6 +1901,45 @@ function buildCodexToolDefinitions(toolIds: string[]) {
|
||||
required: ['url'],
|
||||
},
|
||||
},
|
||||
mita_search: {
|
||||
type: 'function',
|
||||
name: 'mita_search',
|
||||
description: '秘塔AI智能搜索。支持网页搜索和图片搜索两种模式。当需要搜索高质量的中文内容或需要更精准的搜索结果时使用网页搜索;当用户明确要求搜索图片或需要图片素材时使用图片搜索。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: '搜索查询关键词',
|
||||
},
|
||||
scope: {
|
||||
type: 'string',
|
||||
enum: ['webpage', 'image'],
|
||||
description: '搜索类型:webpage(网页搜索,默认)或 image(图片搜索)',
|
||||
},
|
||||
size: {
|
||||
type: 'number',
|
||||
description: '返回结果数量,网页搜索默认10,图片搜索默认5',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
mita_reader: {
|
||||
type: 'function',
|
||||
name: 'mita_reader',
|
||||
description: '秘塔AI网页读取。获取网页内容并返回结构化的Markdown格式,适合阅读长文章。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: '要读取的网页URL',
|
||||
},
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return toolIds
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/drizzle/db';
|
||||
import { messages } from '@/drizzle/schema';
|
||||
import { messages, type SearchImageData } from '@/drizzle/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
interface RouteParams {
|
||||
@ -9,7 +9,7 @@ interface RouteParams {
|
||||
|
||||
/**
|
||||
* PATCH /api/messages/[messageId] - 更新消息
|
||||
* 主要用于更新消息的图片数据(Pyodide 执行后保存图片)
|
||||
* 主要用于更新消息的图片数据(Pyodide 执行后保存图片)和搜索图片
|
||||
*/
|
||||
export async function PATCH(request: Request, { params }: RouteParams) {
|
||||
try {
|
||||
@ -39,6 +39,7 @@ export async function PATCH(request: Request, { params }: RouteParams) {
|
||||
// 构建更新数据
|
||||
const updateData: {
|
||||
images?: string[];
|
||||
searchImages?: SearchImageData[];
|
||||
content?: string;
|
||||
updatedAt: Date;
|
||||
} = {
|
||||
@ -51,6 +52,12 @@ export async function PATCH(request: Request, { params }: RouteParams) {
|
||||
updateData.images = [...existingImages, ...body.images];
|
||||
}
|
||||
|
||||
// 更新搜索图片(追加模式)
|
||||
if (body.searchImages && Array.isArray(body.searchImages)) {
|
||||
const existingSearchImages = (existingMessage.searchImages as SearchImageData[]) || [];
|
||||
updateData.searchImages = [...existingSearchImages, ...body.searchImages];
|
||||
}
|
||||
|
||||
// 更新内容(如果提供)
|
||||
if (body.content !== undefined) {
|
||||
updateData.content = body.content;
|
||||
|
||||
@ -9,9 +9,10 @@ import { encryptApiKey } from '@/lib/crypto';
|
||||
const DEFAULT_SETTINGS = {
|
||||
cchUrl: process.env.CCH_DEFAULT_URL || 'https://claude.leocoder.cn/',
|
||||
cchApiKeyConfigured: false,
|
||||
metasoApiKeyConfigured: false,
|
||||
apiFormat: 'claude' as 'claude' | 'openai', // API 格式:claude(原生)| openai(兼容)
|
||||
defaultModel: 'claude-sonnet-4-5-20250929',
|
||||
defaultTools: ['web_search', 'web_fetch'],
|
||||
defaultTools: ['web_search', 'web_fetch', 'mita_search', 'mita_reader'],
|
||||
systemPrompt: '',
|
||||
temperature: '0.7',
|
||||
theme: 'light',
|
||||
@ -30,6 +31,7 @@ function formatSettingsResponse(settings: typeof userSettings.$inferSelect | nul
|
||||
return {
|
||||
cchUrl: settings.cchUrl || DEFAULT_SETTINGS.cchUrl,
|
||||
cchApiKeyConfigured: settings.cchApiKeyConfigured || false,
|
||||
metasoApiKeyConfigured: settings.metasoApiKeyConfigured || false,
|
||||
apiFormat: (settings.apiFormat as 'claude' | 'openai') || DEFAULT_SETTINGS.apiFormat,
|
||||
defaultModel: settings.defaultModel || DEFAULT_SETTINGS.defaultModel,
|
||||
defaultTools: settings.defaultTools || DEFAULT_SETTINGS.defaultTools,
|
||||
@ -104,6 +106,7 @@ export async function PUT(request: Request) {
|
||||
const {
|
||||
cchUrl,
|
||||
cchApiKey,
|
||||
metasoApiKey,
|
||||
apiFormat,
|
||||
defaultModel,
|
||||
defaultTools,
|
||||
@ -138,6 +141,19 @@ export async function PUT(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 如果提供了秘塔 API Key,加密后存储
|
||||
if (metasoApiKey !== undefined) {
|
||||
if (metasoApiKey === '') {
|
||||
// 清除秘塔 API Key
|
||||
updateData.metasoApiKey = null;
|
||||
updateData.metasoApiKeyConfigured = false;
|
||||
} else {
|
||||
// 加密存储秘塔 API Key
|
||||
updateData.metasoApiKey = encryptApiKey(metasoApiKey);
|
||||
updateData.metasoApiKeyConfigured = true;
|
||||
}
|
||||
}
|
||||
|
||||
// API 格式类型
|
||||
if (apiFormat !== undefined) {
|
||||
updateData.apiFormat = apiFormat;
|
||||
|
||||
@ -89,10 +89,14 @@ export default function ChatPage({ params }: PageProps) {
|
||||
outputTokens: msg.outputTokens || undefined,
|
||||
// 从数据库加载图片数据(代码执行产生的)
|
||||
images: (msg.images as string[]) || undefined,
|
||||
// 从数据库加载搜索到的图片(图片搜索工具结果)
|
||||
searchImages: (msg.searchImages as { title: string; imageUrl: string; width: number; height: number; score: string; position: number; sourceUrl?: string }[]) || undefined,
|
||||
// 从数据库加载用户上传的图片
|
||||
uploadedImages: (msg.uploadedImages as string[]) || undefined,
|
||||
// 从数据库加载用户上传的文档
|
||||
uploadedDocuments: (msg.uploadedDocuments as { name: string; size: number; type: string; content: string }[]) || undefined,
|
||||
// 从数据库加载使用的工具列表
|
||||
usedTools: (msg.usedTools as string[]) || undefined,
|
||||
}));
|
||||
setInitialMessages(historyMessages);
|
||||
}
|
||||
@ -563,8 +567,10 @@ export default function ChatPage({ params }: PageProps) {
|
||||
isStreaming={message.status === 'streaming'}
|
||||
error={message.error}
|
||||
images={message.images}
|
||||
searchImages={message.searchImages}
|
||||
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}
|
||||
|
||||
@ -56,6 +56,11 @@ export default function SettingsPage() {
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
|
||||
// 秘塔AI 配置状态
|
||||
const [metasoApiKey, setMetasoApiKey] = useState('');
|
||||
const [showMetasoApiKey, setShowMetasoApiKey] = useState(false);
|
||||
const [metasoSaveStatus, setMetasoSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
|
||||
// AI 行为设置状态
|
||||
const [systemPrompt, setSystemPrompt] = useState('');
|
||||
const [temperature, setTemperature] = useState('0.7');
|
||||
@ -107,6 +112,31 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 保存秘塔AI API Key
|
||||
const handleSaveMetasoConfig = async () => {
|
||||
setMetasoSaveStatus('saving');
|
||||
try {
|
||||
if (metasoApiKey) {
|
||||
await updateSettings({ metasoApiKey });
|
||||
}
|
||||
setMetasoSaveStatus('saved');
|
||||
setMetasoApiKey(''); // 清除输入的 API Key
|
||||
setTimeout(() => setMetasoSaveStatus('idle'), 2000);
|
||||
} catch {
|
||||
setMetasoSaveStatus('error');
|
||||
setTimeout(() => setMetasoSaveStatus('idle'), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// 清除秘塔 API Key
|
||||
const handleClearMetasoApiKey = async () => {
|
||||
try {
|
||||
await updateSettings({ metasoApiKey: '' });
|
||||
} catch (error) {
|
||||
console.error('Failed to clear Metaso API key:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 更新默认模型
|
||||
const handleModelChange = async (modelId: string) => {
|
||||
try {
|
||||
@ -476,6 +506,95 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* 秘塔AI 配置 */}
|
||||
<SettingsSection
|
||||
title="秘塔AI 配置"
|
||||
description="配置秘塔AI搜索和读取工具(可选)"
|
||||
>
|
||||
<SettingsItem
|
||||
label="秘塔 API Key"
|
||||
description={
|
||||
settings?.metasoApiKeyConfigured ? (
|
||||
'已配置秘塔 API Key'
|
||||
) : (
|
||||
<span>
|
||||
配置后可使用 Metaso Search 和 Metaso Reader 工具。
|
||||
<a
|
||||
href="https://metaso.cn/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--color-primary)] hover:underline ml-1"
|
||||
>
|
||||
前往秘塔平台获取 API Key →
|
||||
</a>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{settings?.metasoApiKeyConfigured ? (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1 text-sm text-green-600">
|
||||
<Check size={14} />
|
||||
已配置
|
||||
</span>
|
||||
<button
|
||||
onClick={handleClearMetasoApiKey}
|
||||
className="btn-ghost text-red-600 hover:text-red-700 text-sm"
|
||||
disabled={saving}
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showMetasoApiKey ? 'text' : 'password'}
|
||||
className="settings-input pr-10 w-80"
|
||||
value={metasoApiKey}
|
||||
onChange={(e) => setMetasoApiKey(e.target.value)}
|
||||
placeholder="输入秘塔 API Key"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]"
|
||||
onClick={() => setShowMetasoApiKey(!showMetasoApiKey)}
|
||||
>
|
||||
{showMetasoApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsItem>
|
||||
|
||||
{!settings?.metasoApiKeyConfigured && (
|
||||
<div className="px-5 py-4 border-t border-[var(--color-border-light)]">
|
||||
<button
|
||||
onClick={handleSaveMetasoConfig}
|
||||
disabled={metasoSaveStatus === 'saving' || !metasoApiKey}
|
||||
className="btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
{metasoSaveStatus === 'saving' ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : metasoSaveStatus === 'saved' ? (
|
||||
<Check size={16} />
|
||||
) : null}
|
||||
{metasoSaveStatus === 'saving' ? '保存中...' : metasoSaveStatus === 'saved' ? '已保存' : '保存秘塔配置'}
|
||||
</button>
|
||||
{metasoSaveStatus === 'error' && (
|
||||
<span className="ml-3 text-sm text-red-600">保存失败</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-5 py-3 bg-[var(--color-bg-tertiary)] border-t border-[var(--color-border-light)]">
|
||||
<p className="text-xs text-[var(--color-text-tertiary)]">
|
||||
💡 秘塔工具说明:Metaso Search 提供高质量中文搜索,Metaso Reader 可将网页转换为结构化 Markdown。
|
||||
未配置 API Key 时,这些工具仍可选择但无法使用。
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* 模型和工具设置 */}
|
||||
<SettingsSection
|
||||
title="AI 配置"
|
||||
|
||||
@ -1,18 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Copy, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check, FileText, FileCode, Bookmark } from 'lucide-react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Copy, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check, FileText, FileCode, Bookmark, Wrench } from 'lucide-react';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { AILogo } from '@/components/ui/AILogo';
|
||||
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 { ImageLightbox } from '@/components/ui/ImageLightbox';
|
||||
import { DocumentPreview, type DocumentData } from '@/components/ui/DocumentPreview';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Message, User, ToolResult } from '@/types';
|
||||
import type { UploadedDocument } from '@/hooks/useStreamChat';
|
||||
|
||||
// 工具名称中文映射
|
||||
const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
||||
web_search: '网络搜索',
|
||||
web_fetch: '网页读取',
|
||||
mita_search: '秘塔搜索',
|
||||
mita_reader: '秘塔阅读',
|
||||
code_execution: '代码执行',
|
||||
};
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
user?: User;
|
||||
@ -21,10 +31,14 @@ interface MessageBubbleProps {
|
||||
error?: string;
|
||||
/** 代码执行产生的图片(Base64) */
|
||||
images?: string[];
|
||||
/** 搜索到的图片(来自图片搜索工具) */
|
||||
searchImages?: SearchImageItem[];
|
||||
/** 用户上传的图片(Base64 或 URL) */
|
||||
uploadedImages?: string[];
|
||||
/** 用户上传的文档 */
|
||||
uploadedDocuments?: UploadedDocument[];
|
||||
/** 使用的工具列表 */
|
||||
usedTools?: string[];
|
||||
/** Pyodide 加载状态 */
|
||||
pyodideStatus?: {
|
||||
stage: 'loading' | 'ready' | 'error';
|
||||
@ -56,7 +70,7 @@ function getDocumentIcon(type: string) {
|
||||
return FileText;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, uploadedImages, uploadedDocuments, pyodideStatus, onRegenerate, onSaveToNote, 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);
|
||||
@ -99,6 +113,13 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
||||
setDocumentPreviewData(null);
|
||||
};
|
||||
|
||||
// 处理 Markdown 中图片链接点击(在灯箱中打开)
|
||||
const handleImageLinkClick = useCallback((url: string) => {
|
||||
setLightboxImages([url]);
|
||||
setLightboxInitialIndex(0);
|
||||
setLightboxOpen(true);
|
||||
}, []);
|
||||
|
||||
// 复制消息内容
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
@ -238,11 +259,37 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 使用的工具提示 */}
|
||||
{usedTools && usedTools.length > 0 && (
|
||||
<div className="mb-3 flex items-center gap-2 flex-wrap">
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-blue-50 text-blue-700 rounded-md text-sm border border-blue-100">
|
||||
<Wrench size={14} />
|
||||
<span>本次使用工具:</span>
|
||||
</div>
|
||||
{usedTools.map((tool, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2 py-0.5 bg-gray-100 text-gray-700 rounded text-sm border border-gray-200"
|
||||
>
|
||||
{TOOL_DISPLAY_NAMES[tool] || tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 主要内容 */}
|
||||
<div className="bg-[var(--color-message-assistant-bg)] border border-[var(--color-message-assistant-border)] rounded-md px-5 py-4 shadow-sm">
|
||||
{/* 搜索到的图片(图片搜索工具结果)- 显示在最上面 */}
|
||||
{searchImages && searchImages.length > 0 && (
|
||||
<SearchImagesGrid images={searchImages} className="mt-0 mb-4" />
|
||||
)}
|
||||
|
||||
<div className="text-[var(--color-text-primary)] leading-[1.75]">
|
||||
{message.content ? (
|
||||
<MarkdownRenderer content={message.content} />
|
||||
<MarkdownRenderer
|
||||
content={message.content}
|
||||
onImageLinkClick={handleImageLinkClick}
|
||||
/>
|
||||
) : isStreaming ? (
|
||||
<div className="flex items-center gap-2 text-[var(--color-text-tertiary)]">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
@ -301,6 +348,7 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
||||
);
|
||||
})()}
|
||||
|
||||
|
||||
{/* 流式状态指示器 */}
|
||||
{isStreaming && message.content && (
|
||||
<div className="flex items-center gap-2 mt-3 text-sm text-[var(--color-text-tertiary)]">
|
||||
|
||||
227
src/components/features/SearchImagesGrid.tsx
Normal file
227
src/components/features/SearchImagesGrid.tsx
Normal file
@ -0,0 +1,227 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { ExternalLink, Image as ImageIcon } from 'lucide-react';
|
||||
import { ImageLightbox } from '@/components/ui/ImageLightbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SearchImageItem {
|
||||
title: string;
|
||||
imageUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
score: string;
|
||||
position: number;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
interface SearchImagesGridProps {
|
||||
images: SearchImageItem[];
|
||||
className?: string;
|
||||
/** 最大显示图片数量,默认为 5 */
|
||||
maxDisplay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索图片瀑布流展示组件
|
||||
* 实现动态回填:当图片加载失败时,自动用备用图片替换
|
||||
* 确保始终显示指定数量的有效图片
|
||||
*/
|
||||
export function SearchImagesGrid({
|
||||
images,
|
||||
className,
|
||||
maxDisplay = 5
|
||||
}: SearchImagesGridProps) {
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||
|
||||
// 已加载成功的图片索引
|
||||
const [loadedImages, setLoadedImages] = useState<Set<number>>(new Set());
|
||||
// 加载失败的图片索引
|
||||
const [errorImages, setErrorImages] = useState<Set<number>>(new Set());
|
||||
|
||||
/**
|
||||
* 计算当前要显示的图片列表
|
||||
* 策略:从所有图片中选择前 maxDisplay 张有效的图片
|
||||
* 跳过已知失败的图片,自动用后续图片回填
|
||||
*/
|
||||
const displayImages = useMemo(() => {
|
||||
const result: { image: SearchImageItem; originalIndex: number }[] = [];
|
||||
|
||||
for (let i = 0; i < images.length && result.length < maxDisplay; i++) {
|
||||
// 跳过已知加载失败的图片
|
||||
if (!errorImages.has(i)) {
|
||||
result.push({ image: images[i], originalIndex: i });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [images, errorImages, maxDisplay]);
|
||||
|
||||
// 有效图片的 URL 列表(用于 Lightbox)
|
||||
const validImageUrls = useMemo(() => {
|
||||
return displayImages
|
||||
.filter(({ originalIndex }) => loadedImages.has(originalIndex))
|
||||
.map(({ image }) => image.imageUrl);
|
||||
}, [displayImages, loadedImages]);
|
||||
|
||||
// 图片加载成功
|
||||
const handleImageLoad = useCallback((index: number) => {
|
||||
setLoadedImages((prev) => new Set(prev).add(index));
|
||||
}, []);
|
||||
|
||||
// 图片加载失败 - 标记为错误,会触发重新计算 displayImages,自动回填
|
||||
const handleImageError = useCallback((index: number) => {
|
||||
setErrorImages((prev) => new Set(prev).add(index));
|
||||
}, []);
|
||||
|
||||
// 打开灯箱
|
||||
const openLightbox = useCallback((displayIndex: number) => {
|
||||
// 找到在有效加载图片中的索引
|
||||
const loadedDisplayImages = displayImages.filter(
|
||||
({ originalIndex }) => loadedImages.has(originalIndex)
|
||||
);
|
||||
const clickedImage = displayImages[displayIndex];
|
||||
const lightboxIdx = loadedDisplayImages.findIndex(
|
||||
({ originalIndex }) => originalIndex === clickedImage?.originalIndex
|
||||
);
|
||||
if (lightboxIdx >= 0) {
|
||||
setLightboxIndex(lightboxIdx);
|
||||
setLightboxOpen(true);
|
||||
}
|
||||
}, [displayImages, loadedImages]);
|
||||
|
||||
// 获取源网站域名
|
||||
const getSourceDomain = (url?: string) => {
|
||||
if (!url) return null;
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.hostname.replace('www.', '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!images || images.length === 0) return null;
|
||||
|
||||
// 如果所有图片都加载失败,不显示任何内容
|
||||
if (displayImages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 统计已成功加载的图片数量
|
||||
const successCount = displayImages.filter(
|
||||
({ originalIndex }) => loadedImages.has(originalIndex)
|
||||
).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('mt-3', className)}>
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center gap-2 mb-3 text-sm text-muted-foreground">
|
||||
<ImageIcon size={14} />
|
||||
<span>
|
||||
搜索到 {successCount > 0 ? successCount : displayImages.length} 张相关图片
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 瀑布流容器 */}
|
||||
<div className="columns-2 sm:columns-3 gap-3 space-y-3">
|
||||
{displayImages.map(({ image, originalIndex }, displayIndex) => {
|
||||
const isLoaded = loadedImages.has(originalIndex);
|
||||
const sourceDomain = getSourceDomain(image.sourceUrl);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${image.imageUrl}-${originalIndex}`}
|
||||
className={cn(
|
||||
'break-inside-avoid mb-3 group relative',
|
||||
'rounded-lg overflow-hidden',
|
||||
'bg-muted/30 dark:bg-muted/20',
|
||||
'border border-border/50',
|
||||
'transition-all duration-200',
|
||||
'hover:shadow-lg hover:border-primary/30',
|
||||
!isLoaded && 'animate-pulse'
|
||||
)}
|
||||
>
|
||||
{/* 图片容器 */}
|
||||
<div
|
||||
className="relative cursor-pointer"
|
||||
onClick={() => openLightbox(displayIndex)}
|
||||
>
|
||||
{/* 图片 */}
|
||||
<img
|
||||
src={image.imageUrl}
|
||||
alt={image.title || `搜索图片 ${displayIndex + 1}`}
|
||||
className={cn(
|
||||
'w-full h-auto object-cover',
|
||||
'transition-opacity duration-300',
|
||||
isLoaded ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
onLoad={() => handleImageLoad(originalIndex)}
|
||||
onError={() => handleImageError(originalIndex)}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
{/* 加载占位 */}
|
||||
{!isLoaded && (
|
||||
<div
|
||||
className="absolute inset-0 bg-muted/30 flex items-center justify-center"
|
||||
style={{
|
||||
aspectRatio: image.width && image.height ? `${image.width}/${image.height}` : '16/9',
|
||||
}}
|
||||
>
|
||||
<div className="w-8 h-8 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 悬浮遮罩 */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors duration-200 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* 图片信息 */}
|
||||
<div className="p-2 space-y-1">
|
||||
{/* 标题 */}
|
||||
{image.title && (
|
||||
<p
|
||||
className="text-xs font-medium text-foreground/90 line-clamp-2"
|
||||
title={image.title}
|
||||
>
|
||||
{image.title}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 来源链接 */}
|
||||
{image.sourceUrl && sourceDomain && (
|
||||
<a
|
||||
href={image.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs',
|
||||
'text-muted-foreground hover:text-primary',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
>
|
||||
<ExternalLink size={10} />
|
||||
<span className="truncate">{sourceDomain}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图片灯箱 - 只包含已成功加载的图片 */}
|
||||
<ImageLightbox
|
||||
images={validImageUrls}
|
||||
initialIndex={lightboxIndex}
|
||||
isOpen={lightboxOpen}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
@ -9,10 +9,27 @@ import { cn } from '@/lib/utils';
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
/** 图片链接点击回调,用于在灯箱中打开图片 */
|
||||
onImageLinkClick?: (url: string) => void;
|
||||
}
|
||||
|
||||
// 将 components 配置提取到组件外部,避免每次渲染时创建新对象
|
||||
const markdownComponents = {
|
||||
/**
|
||||
* 判断 URL 是否为图片链接
|
||||
*/
|
||||
function isImageUrl(url: string): boolean {
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.ico'];
|
||||
const urlLower = url.toLowerCase();
|
||||
// 检查扩展名(忽略查询参数)
|
||||
const urlWithoutQuery = urlLower.split('?')[0];
|
||||
return imageExtensions.some(ext => urlWithoutQuery.endsWith(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Markdown 组件配置
|
||||
* 使用工厂函数以支持传入回调
|
||||
*/
|
||||
function createMarkdownComponents(onImageLinkClick?: (url: string) => void) {
|
||||
return {
|
||||
// 代码块
|
||||
code({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
@ -101,8 +118,24 @@ const markdownComponents = {
|
||||
);
|
||||
},
|
||||
|
||||
// 链接
|
||||
// 链接 - 支持图片链接在灯箱中打开
|
||||
a({ href, children }: { href?: string; children?: React.ReactNode }) {
|
||||
// 如果是图片链接且有回调,则拦截点击事件
|
||||
if (href && onImageLinkClick && isImageUrl(href)) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onImageLinkClick(href);
|
||||
}}
|
||||
className="text-[var(--color-primary)] hover:underline cursor-pointer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
// 非图片链接保持原有行为
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
@ -206,14 +239,21 @@ const markdownComponents = {
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 使用 memo 包裹组件,避免不必要的重渲染
|
||||
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
|
||||
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className, onImageLinkClick }: MarkdownRendererProps) {
|
||||
// 使用 useMemo 缓存 components 配置,仅在 onImageLinkClick 变化时重新创建
|
||||
const components = useMemo(
|
||||
() => createMarkdownComponents(onImageLinkClick),
|
||||
[onImageLinkClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('markdown-content', className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={markdownComponents}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
|
||||
3
src/drizzle/migrations/0010_pretty_khan.sql
Normal file
3
src/drizzle/migrations/0010_pretty_khan.sql
Normal file
@ -0,0 +1,3 @@
|
||||
ALTER TABLE "user_settings" ALTER COLUMN "default_tools" SET DEFAULT '["web_search","web_fetch","mita_search","mita_reader"]'::jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "user_settings" ADD COLUMN "metaso_api_key" varchar(512);--> statement-breakpoint
|
||||
ALTER TABLE "user_settings" ADD COLUMN "metaso_api_key_configured" boolean DEFAULT false;
|
||||
1
src/drizzle/migrations/0011_spooky_toad_men.sql
Normal file
1
src/drizzle/migrations/0011_spooky_toad_men.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE "messages" ADD COLUMN "used_tools" jsonb DEFAULT '[]'::jsonb;
|
||||
1
src/drizzle/migrations/0012_flippant_marvel_apes.sql
Normal file
1
src/drizzle/migrations/0012_flippant_marvel_apes.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE "messages" ADD COLUMN "search_images" jsonb;
|
||||
1191
src/drizzle/migrations/meta/0010_snapshot.json
Normal file
1191
src/drizzle/migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1198
src/drizzle/migrations/meta/0011_snapshot.json
Normal file
1198
src/drizzle/migrations/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1204
src/drizzle/migrations/meta/0012_snapshot.json
Normal file
1204
src/drizzle/migrations/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -71,6 +71,27 @@
|
||||
"when": 1766323563111,
|
||||
"tag": "0009_omniscient_vision",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1766335500962,
|
||||
"tag": "0010_pretty_khan",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1766337225624,
|
||||
"tag": "0011_spooky_toad_men",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1766339459689,
|
||||
"tag": "0012_flippant_marvel_apes",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -63,11 +63,14 @@ export const userSettings = pgTable('user_settings', {
|
||||
cchUrl: varchar('cch_url', { length: 512 }).notNull().default('http://localhost:13500'),
|
||||
cchApiKey: varchar('cch_api_key', { length: 512 }),
|
||||
cchApiKeyConfigured: boolean('cch_api_key_configured').default(false),
|
||||
// 秘塔AI配置
|
||||
metasoApiKey: varchar('metaso_api_key', { length: 512 }),
|
||||
metasoApiKeyConfigured: boolean('metaso_api_key_configured').default(false),
|
||||
// API 格式类型:claude(原生)| openai(兼容)
|
||||
apiFormat: varchar('api_format', { length: 20 }).default('claude'),
|
||||
// 默认设置
|
||||
defaultModel: varchar('default_model', { length: 64 }).default('claude-sonnet-4-5-20250929'),
|
||||
defaultTools: jsonb('default_tools').$type<string[]>().default(['web_search', 'web_fetch']),
|
||||
defaultTools: jsonb('default_tools').$type<string[]>().default(['web_search', 'web_fetch', 'mita_search', 'mita_reader']),
|
||||
// AI 行为设置
|
||||
systemPrompt: text('system_prompt'), // 系统提示词
|
||||
temperature: varchar('temperature', { length: 10 }).default('0.7'), // 温度参数 (0-1)
|
||||
@ -200,6 +203,10 @@ export const messages = pgTable('messages', {
|
||||
uploadedImages: jsonb('uploaded_images').$type<string[]>(),
|
||||
// 用户上传的文档
|
||||
uploadedDocuments: jsonb('uploaded_documents').$type<UploadedDocumentData[]>(),
|
||||
// 使用的工具名称列表(用于简洁显示)
|
||||
usedTools: jsonb('used_tools').$type<string[]>().default([]),
|
||||
// 搜索到的图片(图片搜索工具返回)
|
||||
searchImages: jsonb('search_images').$type<SearchImageData[]>(),
|
||||
// Token 统计
|
||||
inputTokens: integer('input_tokens').default(0),
|
||||
outputTokens: integer('output_tokens').default(0),
|
||||
@ -415,6 +422,17 @@ export interface UploadedDocumentData {
|
||||
content: string;
|
||||
}
|
||||
|
||||
// 搜索到的图片数据(用于图片搜索结果持久化)
|
||||
export interface SearchImageData {
|
||||
title: string;
|
||||
imageUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
score: string;
|
||||
position: number;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
// 导出类型
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type NewUser = typeof users.$inferInsert;
|
||||
|
||||
@ -13,8 +13,9 @@ async function seedUserSettings() {
|
||||
id: 1,
|
||||
cchUrl: 'http://localhost:13500',
|
||||
cchApiKeyConfigured: false,
|
||||
metasoApiKeyConfigured: false,
|
||||
defaultModel: 'claude-sonnet-4-5-20250929',
|
||||
defaultTools: ['web_search', 'web_fetch'],
|
||||
defaultTools: ['web_search', 'web_fetch', 'mita_search', 'mita_reader'],
|
||||
theme: 'light',
|
||||
language: 'zh-CN',
|
||||
enableThinking: false,
|
||||
@ -63,6 +64,41 @@ async function seedTools() {
|
||||
isDefault: true,
|
||||
sortOrder: 2,
|
||||
},
|
||||
{
|
||||
toolId: 'mita_search',
|
||||
name: 'mita_search',
|
||||
displayName: 'Metaso Search',
|
||||
description: '秘塔AI智能搜索,需要配置API Key',
|
||||
icon: 'Search',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: '搜索查询关键词' },
|
||||
size: { type: 'number', description: '返回结果数量,默认10' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
isEnabled: true,
|
||||
isDefault: true,
|
||||
sortOrder: 3,
|
||||
},
|
||||
{
|
||||
toolId: 'mita_reader',
|
||||
name: 'mita_reader',
|
||||
displayName: 'Metaso Reader',
|
||||
description: '秘塔AI网页读取,返回Markdown格式',
|
||||
icon: 'FileText',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: '要读取的网页URL' },
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
isEnabled: true,
|
||||
isDefault: true,
|
||||
sortOrder: 4,
|
||||
},
|
||||
];
|
||||
|
||||
for (const tool of toolsData) {
|
||||
|
||||
@ -5,6 +5,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
export interface Settings {
|
||||
cchUrl: string;
|
||||
cchApiKeyConfigured: boolean;
|
||||
metasoApiKeyConfigured: boolean;
|
||||
apiFormat: 'claude' | 'openai';
|
||||
defaultModel: string;
|
||||
defaultTools: string[];
|
||||
@ -51,9 +52,10 @@ export interface Model {
|
||||
const defaultSettings: Settings = {
|
||||
cchUrl: 'http://localhost:13500',
|
||||
cchApiKeyConfigured: false,
|
||||
metasoApiKeyConfigured: false,
|
||||
apiFormat: 'claude',
|
||||
defaultModel: 'claude-sonnet-4-5-20250929',
|
||||
defaultTools: ['web_search', 'web_fetch'],
|
||||
defaultTools: ['web_search', 'web_fetch', 'mita_search', 'mita_reader'],
|
||||
systemPrompt: '',
|
||||
temperature: '0.7',
|
||||
theme: 'light',
|
||||
@ -88,7 +90,7 @@ export function useSettings() {
|
||||
}, []);
|
||||
|
||||
// 更新设置
|
||||
const updateSettings = useCallback(async (updates: Partial<Settings & { cchApiKey?: string }>) => {
|
||||
const updateSettings = useCallback(async (updates: Partial<Settings & { cchApiKey?: string; metasoApiKey?: string }>) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
@ -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' | 'pyodide_execution_required' | '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;
|
||||
@ -19,6 +19,22 @@ export interface StreamMessage {
|
||||
success?: boolean;
|
||||
result?: string;
|
||||
images?: string[];
|
||||
// 搜索到的图片
|
||||
searchImages?: SearchImageData[];
|
||||
// 工具使用相关
|
||||
toolName?: string;
|
||||
usedTools?: string[];
|
||||
}
|
||||
|
||||
// 搜索图片数据类型
|
||||
export interface SearchImageData {
|
||||
title: string;
|
||||
imageUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
score: string;
|
||||
position: number;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
@ -32,10 +48,14 @@ export interface ChatMessage {
|
||||
outputTokens?: number;
|
||||
// 工具执行产生的图片
|
||||
images?: string[];
|
||||
// 搜索到的图片
|
||||
searchImages?: SearchImageData[];
|
||||
// 用户上传的图片(Base64)
|
||||
uploadedImages?: string[];
|
||||
// 用户上传的文档
|
||||
uploadedDocuments?: UploadedDocument[];
|
||||
// 使用的工具列表
|
||||
usedTools?: string[];
|
||||
// Pyodide 加载状态
|
||||
pyodideStatus?: {
|
||||
stage: 'loading' | 'ready' | 'error';
|
||||
@ -67,6 +87,29 @@ async function saveMessageImages(messageId: string, images: string[]): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存搜索到的图片到数据库
|
||||
*/
|
||||
async function saveMessageSearchImages(messageId: string, searchImages: SearchImageData[]): Promise<void> {
|
||||
if (!messageId || !searchImages || searchImages.length === 0) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/messages/${messageId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ searchImages }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to save search images:', await response.text());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving search images:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文件转换为 Base64
|
||||
*/
|
||||
@ -159,6 +202,8 @@ export function useStreamChat() {
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
// 临时存储 Pyodide 执行产生的图片,等待 messageId
|
||||
const pendingImagesRef = useRef<string[]>([]);
|
||||
// 临时存储搜索到的图片,等待 messageId
|
||||
const pendingSearchImagesRef = useRef<SearchImageData[]>([]);
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = useCallback(async (options: {
|
||||
@ -371,6 +416,47 @@ export function useStreamChat() {
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
} else if (event.type === 'tool_search_images') {
|
||||
// 处理图片搜索结果
|
||||
if (event.searchImages && event.searchImages.length > 0) {
|
||||
// 存储到临时变量,等待 messageId 后保存到数据库
|
||||
pendingSearchImagesRef.current = [
|
||||
...pendingSearchImagesRef.current,
|
||||
...event.searchImages,
|
||||
];
|
||||
// 更新 UI
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const lastIndex = updated.length - 1;
|
||||
if (updated[lastIndex]?.role === 'assistant') {
|
||||
const existingSearchImages = updated[lastIndex].searchImages || [];
|
||||
updated[lastIndex] = {
|
||||
...updated[lastIndex],
|
||||
searchImages: [...existingSearchImages, ...event.searchImages!],
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
} else if (event.type === 'tool_used') {
|
||||
// 实时工具使用事件
|
||||
if (event.toolName) {
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const lastIndex = updated.length - 1;
|
||||
if (updated[lastIndex]?.role === 'assistant') {
|
||||
const existingTools = updated[lastIndex].usedTools || [];
|
||||
// 避免重复添加
|
||||
if (!existingTools.includes(event.toolName!)) {
|
||||
updated[lastIndex] = {
|
||||
...updated[lastIndex],
|
||||
usedTools: [...existingTools, event.toolName!],
|
||||
};
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
} else if (event.type === 'pyodide_execution_required') {
|
||||
// 需要在浏览器端执行 Python 图形代码
|
||||
const code = event.code || '';
|
||||
@ -444,16 +530,25 @@ export function useStreamChat() {
|
||||
pendingImagesRef.current = []; // 清空临时存储
|
||||
}
|
||||
|
||||
// 如果有待保存的搜索图片,保存到数据库
|
||||
if (event.messageId && pendingSearchImagesRef.current.length > 0) {
|
||||
saveMessageSearchImages(event.messageId, pendingSearchImagesRef.current);
|
||||
pendingSearchImagesRef.current = []; // 清空临时存储
|
||||
}
|
||||
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const lastIndex = updated.length - 1;
|
||||
if (updated[lastIndex]?.role === 'assistant') {
|
||||
// 如果 done 事件包含 usedTools,使用它(保证完整性)
|
||||
const finalUsedTools = event.usedTools || updated[lastIndex].usedTools;
|
||||
updated[lastIndex] = {
|
||||
...updated[lastIndex],
|
||||
id: event.messageId || updated[lastIndex].id,
|
||||
status: 'completed',
|
||||
inputTokens: event.inputTokens,
|
||||
outputTokens: event.outputTokens,
|
||||
usedTools: finalUsedTools,
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
|
||||
@ -24,6 +24,23 @@ import {
|
||||
type WebFetchInput,
|
||||
type WebFetchResponse,
|
||||
} from './webFetch';
|
||||
import {
|
||||
metasoSearch,
|
||||
formatMetasoSearchResults,
|
||||
formatMetasoSearchResultsShort,
|
||||
getValidImages,
|
||||
ImageValidationConfig,
|
||||
type MetasoSearchInput,
|
||||
type MetasoSearchResponse,
|
||||
type MetasoImageResult,
|
||||
} from './metasoSearch';
|
||||
import {
|
||||
metasoReader,
|
||||
formatMetasoReaderResult,
|
||||
formatMetasoReaderResultShort,
|
||||
type MetasoReaderInput,
|
||||
type MetasoReaderResponse,
|
||||
} from './metasoReader';
|
||||
import { shouldUsePyodide, analyzeCode, type LoadingCallback } from './codeAnalyzer';
|
||||
|
||||
// 导出代码分析函数供外部使用
|
||||
@ -39,6 +56,8 @@ export interface ToolExecutionResult {
|
||||
rawData?: unknown;
|
||||
/** Base64 编码的图片数组(代码执行时可能产生) */
|
||||
images?: string[];
|
||||
/** 搜索到的图片数组(图片搜索时产生) */
|
||||
searchImages?: MetasoImageResult[];
|
||||
/** 是否需要浏览器端 Pyodide 执行 */
|
||||
requiresPyodide?: boolean;
|
||||
/** 代码内容(当 requiresPyodide 为 true 时) */
|
||||
@ -47,18 +66,26 @@ export interface ToolExecutionResult {
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface ToolExecutionOptions {
|
||||
/** Pyodide 加载进度回调 */
|
||||
onProgress?: LoadingCallback;
|
||||
/** 秘塔AI API Key */
|
||||
metasoApiKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行工具
|
||||
* @param toolName 工具名称
|
||||
* @param input 工具输入参数
|
||||
* @param onProgress Pyodide 加载进度回调(可选)
|
||||
* @param options 执行选项
|
||||
* @returns 执行结果(包含完整版和简短版)
|
||||
*/
|
||||
export async function executeTool(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
onProgress?: LoadingCallback
|
||||
options?: ToolExecutionOptions
|
||||
): Promise<ToolExecutionResult> {
|
||||
const { onProgress, metasoApiKey } = options || {};
|
||||
try {
|
||||
switch (toolName) {
|
||||
case 'web_search': {
|
||||
@ -119,6 +146,63 @@ export async function executeTool(
|
||||
};
|
||||
}
|
||||
|
||||
case 'mita_search': {
|
||||
const query = String(input.query || '');
|
||||
const scope = (input.scope as 'webpage' | 'image') || 'webpage';
|
||||
|
||||
// 图片搜索时请求更多图片用于验证筛选,网页搜索保持原有逻辑
|
||||
const requestSize = scope === 'image'
|
||||
? ImageValidationConfig.REQUEST_SIZE
|
||||
: (input.size ? Number(input.size) : 10);
|
||||
|
||||
const searchInput: MetasoSearchInput = {
|
||||
query,
|
||||
scope,
|
||||
size: requestSize,
|
||||
includeSummary: Boolean(input.includeSummary),
|
||||
};
|
||||
|
||||
const response: MetasoSearchResponse = await metasoSearch(searchInput, metasoApiKey || '');
|
||||
|
||||
// 如果是图片搜索且成功,验证图片有效性
|
||||
let validatedImages: MetasoImageResult[] | undefined;
|
||||
if (scope === 'image' && response.success && response.images && response.images.length > 0) {
|
||||
// 验证图片,返回指定数量的有效图片
|
||||
validatedImages = await getValidImages(
|
||||
response.images,
|
||||
ImageValidationConfig.TARGET_COUNT,
|
||||
ImageValidationConfig.CONCURRENCY
|
||||
);
|
||||
|
||||
// 更新 response 中的图片为验证后的有效图片
|
||||
response.images = validatedImages;
|
||||
}
|
||||
|
||||
return {
|
||||
success: response.success,
|
||||
fullResult: formatMetasoSearchResults(response),
|
||||
displayResult: formatMetasoSearchResultsShort(response, query),
|
||||
rawData: response,
|
||||
// 如果是图片搜索,返回验证后的图片数据
|
||||
searchImages: scope === 'image' ? validatedImages : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
case 'mita_reader': {
|
||||
const url = String(input.url || '');
|
||||
const readerInput: MetasoReaderInput = {
|
||||
url,
|
||||
format: 'Markdown',
|
||||
};
|
||||
const response: MetasoReaderResponse = await metasoReader(readerInput, metasoApiKey || '');
|
||||
return {
|
||||
success: response.success,
|
||||
fullResult: formatMetasoReaderResult(response, url),
|
||||
displayResult: formatMetasoReaderResultShort(response, url),
|
||||
rawData: response,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
@ -148,17 +232,23 @@ export function getAvailableTools() {
|
||||
description: '搜索互联网获取最新信息',
|
||||
icon: '🔍',
|
||||
},
|
||||
{
|
||||
id: 'code_execution',
|
||||
name: '代码执行',
|
||||
description: '执行代码并返回结果',
|
||||
icon: '💻',
|
||||
},
|
||||
{
|
||||
id: 'web_fetch',
|
||||
name: '网页获取',
|
||||
description: '获取指定 URL 的网页内容',
|
||||
icon: '🌐',
|
||||
},
|
||||
{
|
||||
id: 'mita_search',
|
||||
name: 'Metaso Search',
|
||||
description: '秘塔AI智能搜索,需要配置API Key',
|
||||
icon: '🔎',
|
||||
},
|
||||
{
|
||||
id: 'mita_reader',
|
||||
name: 'Metaso Reader',
|
||||
description: '秘塔AI网页读取,返回Markdown格式',
|
||||
icon: '📄',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
139
src/services/tools/metasoReader.ts
Normal file
139
src/services/tools/metasoReader.ts
Normal file
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 秘塔AI网页读取工具服务
|
||||
* 使用秘塔AI API 读取网页内容并返回Markdown格式
|
||||
*/
|
||||
|
||||
export interface MetasoReaderResponse {
|
||||
success: boolean;
|
||||
content?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface MetasoReaderInput {
|
||||
url: string;
|
||||
format?: 'Markdown' | 'Text';
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取网页内容
|
||||
*/
|
||||
export async function metasoReader(
|
||||
input: MetasoReaderInput,
|
||||
apiKey: string
|
||||
): Promise<MetasoReaderResponse> {
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: '请先在设置中配置秘塔AI API Key',
|
||||
};
|
||||
}
|
||||
|
||||
// 验证URL格式
|
||||
try {
|
||||
new URL(input.url);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '无效的URL格式',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://metaso.cn/api/v1/reader', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Accept': 'text/plain',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: input.url,
|
||||
format: input.format || 'Markdown',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Metaso Reader API error:', errorText);
|
||||
|
||||
if (response.status === 401) {
|
||||
return {
|
||||
success: false,
|
||||
error: '秘塔AI API Key 无效,请检查配置',
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
return {
|
||||
success: false,
|
||||
error: '无法访问该网页,请检查URL是否正确',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `秘塔读取API错误: ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 秘塔Reader返回的是文本内容
|
||||
const content = await response.text();
|
||||
|
||||
if (!content || content.trim().length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: '网页内容为空或无法解析',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: content,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Metaso reader error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '秘塔读取服务异常',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化读取结果为文本(完整版 - 发送给 AI)
|
||||
*/
|
||||
export function formatMetasoReaderResult(response: MetasoReaderResponse, url: string): string {
|
||||
if (!response.success) {
|
||||
return `读取网页失败: ${response.error}`;
|
||||
}
|
||||
|
||||
let result = `## 网页内容\n`;
|
||||
result += `**来源**: ${url}\n\n`;
|
||||
result += `---\n\n`;
|
||||
result += response.content || '无内容';
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化读取结果为简短文本(显示给用户)
|
||||
*/
|
||||
export function formatMetasoReaderResultShort(response: MetasoReaderResponse, url: string): string {
|
||||
if (!response.success) {
|
||||
return `读取失败: ${response.error}`;
|
||||
}
|
||||
|
||||
// 获取网站域名
|
||||
let hostname = url;
|
||||
try {
|
||||
hostname = new URL(url).hostname.replace('www.', '');
|
||||
} catch {
|
||||
// 保持原始URL
|
||||
}
|
||||
|
||||
// 计算内容长度
|
||||
const contentLength = response.content?.length || 0;
|
||||
const wordCount = response.content?.split(/\s+/).length || 0;
|
||||
|
||||
return `> 📄 已读取 [${hostname}](${url}) 的内容\n> 📊 共 ${contentLength} 字符,约 ${wordCount} 词`;
|
||||
}
|
||||
369
src/services/tools/metasoSearch.ts
Normal file
369
src/services/tools/metasoSearch.ts
Normal file
@ -0,0 +1,369 @@
|
||||
/**
|
||||
* 秘塔AI搜索工具服务
|
||||
* 使用秘塔AI API 实现智能搜索(支持网页搜索和图片搜索)
|
||||
*/
|
||||
|
||||
// ============ 图片验证相关常量 ============
|
||||
const IMAGE_VALIDATION_TIMEOUT = 3000; // 单张图片验证超时时间(毫秒)
|
||||
const IMAGE_VALIDATION_CONCURRENCY = 5; // 并发验证数量
|
||||
const IMAGE_REQUEST_SIZE = 15; // 图片搜索时请求的数量
|
||||
const IMAGE_TARGET_COUNT = 10; // 返回给前端的图片数量(前端会显示5张+5张备用)
|
||||
|
||||
// 网页搜索结果
|
||||
export interface MetasoSearchResult {
|
||||
title: string;
|
||||
link: string;
|
||||
snippet: string;
|
||||
score: string;
|
||||
position: number;
|
||||
date?: string;
|
||||
authors?: string[];
|
||||
}
|
||||
|
||||
// 图片搜索结果
|
||||
export interface MetasoImageResult {
|
||||
title: string;
|
||||
imageUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
score: string;
|
||||
position: number;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
// 搜索响应
|
||||
export interface MetasoSearchResponse {
|
||||
success: boolean;
|
||||
credits?: number;
|
||||
total?: number;
|
||||
scope?: 'webpage' | 'image';
|
||||
// 网页搜索结果
|
||||
results?: MetasoSearchResult[];
|
||||
// 图片搜索结果
|
||||
images?: MetasoImageResult[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 搜索输入参数
|
||||
export interface MetasoSearchInput {
|
||||
query: string;
|
||||
scope?: 'webpage' | 'image';
|
||||
size?: number;
|
||||
/** 页码,用于获取不同页的结果(从1开始) */
|
||||
page?: number;
|
||||
includeSummary?: boolean;
|
||||
includeRawContent?: boolean;
|
||||
conciseSnippet?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行秘塔AI搜索
|
||||
*/
|
||||
export async function metasoSearch(
|
||||
input: MetasoSearchInput,
|
||||
apiKey: string
|
||||
): Promise<MetasoSearchResponse> {
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: '请先在设置中配置秘塔AI API Key',
|
||||
};
|
||||
}
|
||||
|
||||
const scope = input.scope || 'webpage';
|
||||
const size = input.size || (scope === 'image' ? 5 : 10); // 图片默认5张
|
||||
|
||||
try {
|
||||
const response = await fetch('https://metaso.cn/api/v1/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
q: input.query,
|
||||
scope: scope,
|
||||
size: size,
|
||||
includeSummary: input.includeSummary || false,
|
||||
includeRawContent: input.includeRawContent || false,
|
||||
conciseSnippet: input.conciseSnippet || false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Metaso API error:', errorText);
|
||||
|
||||
if (response.status === 401) {
|
||||
return {
|
||||
success: false,
|
||||
error: '秘塔AI API Key 无效,请检查配置',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `秘塔搜索API错误: ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 根据 scope 解析不同的结果
|
||||
if (scope === 'image') {
|
||||
// 图片搜索结果
|
||||
return {
|
||||
success: true,
|
||||
credits: data.credits,
|
||||
total: data.total,
|
||||
scope: 'image',
|
||||
images: data.images?.map((item: {
|
||||
title: string;
|
||||
imageUrl: string;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
score: string;
|
||||
position: number;
|
||||
sourceUrl?: string;
|
||||
}) => ({
|
||||
title: item.title,
|
||||
imageUrl: item.imageUrl,
|
||||
width: item.imageWidth,
|
||||
height: item.imageHeight,
|
||||
score: item.score,
|
||||
position: item.position,
|
||||
sourceUrl: item.sourceUrl,
|
||||
})),
|
||||
};
|
||||
} else {
|
||||
// 网页搜索结果
|
||||
return {
|
||||
success: true,
|
||||
credits: data.credits,
|
||||
total: data.total,
|
||||
scope: 'webpage',
|
||||
results: data.webpages?.map((item: {
|
||||
title: string;
|
||||
link: string;
|
||||
snippet: string;
|
||||
score: string;
|
||||
position: number;
|
||||
date?: string;
|
||||
authors?: string[];
|
||||
}) => ({
|
||||
title: item.title,
|
||||
link: item.link,
|
||||
snippet: item.snippet,
|
||||
score: item.score,
|
||||
position: item.position,
|
||||
date: item.date,
|
||||
authors: item.authors,
|
||||
})),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Metaso search error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '秘塔搜索服务异常',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化网页搜索结果为文本(完整版 - 发送给 AI)
|
||||
*/
|
||||
export function formatMetasoSearchResults(response: MetasoSearchResponse): string {
|
||||
if (!response.success) {
|
||||
return `搜索失败: ${response.error}`;
|
||||
}
|
||||
|
||||
// 图片搜索结果
|
||||
if (response.scope === 'image' && response.images) {
|
||||
let result = `## 秘塔图片搜索结果 (共${response.total}张)\n\n`;
|
||||
response.images.forEach((item, index) => {
|
||||
result += `${index + 1}. **${item.title}**\n`;
|
||||
result += ` - 图片URL: ${item.imageUrl}\n`;
|
||||
result += ` - 尺寸: ${item.width}x${item.height}\n`;
|
||||
if (item.sourceUrl) {
|
||||
result += ` - 来源: ${item.sourceUrl}\n`;
|
||||
}
|
||||
result += '\n';
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// 网页搜索结果
|
||||
let result = '';
|
||||
if (response.results && response.results.length > 0) {
|
||||
result += `## 秘塔搜索结果 (共${response.total}条)\n\n`;
|
||||
response.results.forEach((item, index) => {
|
||||
result += `### ${index + 1}. ${item.title}\n`;
|
||||
result += `**链接**: ${item.link}\n`;
|
||||
if (item.date) {
|
||||
result += `**日期**: ${item.date}\n`;
|
||||
}
|
||||
if (item.authors && item.authors.length > 0) {
|
||||
result += `**作者**: ${item.authors.join(', ')}\n`;
|
||||
}
|
||||
result += `${item.snippet}\n\n`;
|
||||
});
|
||||
}
|
||||
|
||||
return result || '未找到相关结果';
|
||||
}
|
||||
|
||||
// ============ 图片验证相关函数 ============
|
||||
|
||||
/**
|
||||
* 验证单张图片 URL 是否可访问
|
||||
* 使用 GET + Range 请求模拟真实浏览器行为,比 HEAD 更可靠
|
||||
* @param url 图片 URL
|
||||
* @param timeout 超时时间(毫秒)
|
||||
* @returns 是否可访问
|
||||
*/
|
||||
export async function validateImageUrl(
|
||||
url: string,
|
||||
timeout: number = IMAGE_VALIDATION_TIMEOUT
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
// 使用 GET 请求 + Range 头部,只请求前 1KB 数据
|
||||
// 这比 HEAD 更可靠,因为某些服务器对 HEAD 和 GET 的处理不一致
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
// 只请求前 1024 字节,节省带宽
|
||||
'Range': 'bytes=0-1023',
|
||||
// 模拟浏览器请求,避免被某些服务器拒绝
|
||||
'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',
|
||||
'Accept': 'image/webp,image/apng,image/*,*/*;q=0.8',
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// 检查响应状态:200 OK 或 206 Partial Content 都是有效的
|
||||
if (!response.ok && response.status !== 206) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
// 必须是图片类型
|
||||
if (contentType && !contentType.startsWith('image/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 额外检查:尝试读取一些数据确保内容存在
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (buffer.byteLength === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
// 网络错误、超时或其他异常
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量验证图片,返回指定数量的有效图片
|
||||
* @param images 原始图片列表
|
||||
* @param targetCount 目标有效图片数量
|
||||
* @param concurrency 并发验证数量
|
||||
* @returns 有效图片列表
|
||||
*/
|
||||
export async function getValidImages(
|
||||
images: MetasoImageResult[],
|
||||
targetCount: number = IMAGE_TARGET_COUNT,
|
||||
concurrency: number = IMAGE_VALIDATION_CONCURRENCY
|
||||
): Promise<MetasoImageResult[]> {
|
||||
const validImages: MetasoImageResult[] = [];
|
||||
|
||||
// 如果图片数量不足,直接返回所有图片(不验证)
|
||||
if (images.length <= targetCount) {
|
||||
return images;
|
||||
}
|
||||
|
||||
// 分批并行验证
|
||||
for (let i = 0; i < images.length && validImages.length < targetCount; i += concurrency) {
|
||||
const batch = images.slice(i, i + concurrency);
|
||||
|
||||
// 并行验证当前批次的图片
|
||||
const validationResults = await Promise.all(
|
||||
batch.map(async (img) => ({
|
||||
img,
|
||||
valid: await validateImageUrl(img.imageUrl),
|
||||
}))
|
||||
);
|
||||
|
||||
// 收集有效图片
|
||||
for (const { img, valid } of validationResults) {
|
||||
if (valid && validImages.length < targetCount) {
|
||||
validImages.push(img);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果已经收集到足够的有效图片,提前结束
|
||||
if (validImages.length >= targetCount) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return validImages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出图片验证相关常量供外部使用
|
||||
*/
|
||||
export const ImageValidationConfig = {
|
||||
TIMEOUT: IMAGE_VALIDATION_TIMEOUT,
|
||||
CONCURRENCY: IMAGE_VALIDATION_CONCURRENCY,
|
||||
REQUEST_SIZE: IMAGE_REQUEST_SIZE,
|
||||
TARGET_COUNT: IMAGE_TARGET_COUNT,
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化搜索结果为简短文本(显示给用户)
|
||||
*/
|
||||
export function formatMetasoSearchResultsShort(
|
||||
response: MetasoSearchResponse,
|
||||
query: string
|
||||
): string {
|
||||
if (!response.success) {
|
||||
return `搜索失败: ${response.error}`;
|
||||
}
|
||||
|
||||
// 图片搜索结果
|
||||
if (response.scope === 'image' && response.images) {
|
||||
const imageCount = response.images.length;
|
||||
return `> 🖼️ 秘塔搜索「${query}」图片,找到 ${response.total || imageCount} 张相关图片`;
|
||||
}
|
||||
|
||||
// 网页搜索结果
|
||||
const resultCount = response.results?.length || 0;
|
||||
|
||||
// 提取来源网站并生成 Markdown 链接
|
||||
const sourceLinks = response.results?.slice(0, 3).map((r, index) => {
|
||||
try {
|
||||
const url = new URL(r.link);
|
||||
const hostname = url.hostname.replace('www.', '');
|
||||
return `${index + 1}. [${hostname}](${r.link})`;
|
||||
} catch {
|
||||
return `${index + 1}. ${r.title.slice(0, 20)}`;
|
||||
}
|
||||
}) || [];
|
||||
|
||||
let result = `> 🔍 秘塔搜索「${query}」,找到 ${response.total || resultCount} 个相关结果`;
|
||||
|
||||
if (sourceLinks.length > 0) {
|
||||
result += `\n\n**🔗 来源:**\n${sourceLinks.join('\n')}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -32,6 +32,21 @@ export interface Message {
|
||||
toolCalls?: ToolCall[];
|
||||
/** 工具调用结果 */
|
||||
toolResults?: ToolResult[];
|
||||
/** 使用的工具名称列表 */
|
||||
usedTools?: string[];
|
||||
/** 搜索到的图片(图片搜索工具结果) */
|
||||
searchImages?: SearchImageData[];
|
||||
}
|
||||
|
||||
// 搜索图片数据类型
|
||||
export interface SearchImageData {
|
||||
title: string;
|
||||
imageUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
score: string;
|
||||
position: number;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
// 工具调用记录
|
||||
|
||||
Loading…
Reference in New Issue
Block a user