Compare commits

..

8 Commits

Author SHA1 Message Date
gaoziman
cb05f06483 chore(配置): 更新 .gitignore 忽略规则
- 添加 .idea/ IDE配置目录忽略
- 添加 cchcode-ui/ 其他项目目录忽略
2025-12-22 12:55:11 +08:00
gaoziman
8bfe420676 feat(页面): 添加秘塔AI配置界面和聊天页面增强
设置页面:
- 新增秘塔AI配置区块
- 支持秘塔API Key的配置和清除
- 添加秘塔平台链接和工具使用说明

聊天页面:
- 从数据库加载搜索图片和使用工具数据
- 将数据传递给 MessageBubble 组件展示
2025-12-22 12:38:06 +08:00
gaoziman
615a59567d feat(组件): 添加搜索图片展示和工具使用提示
SearchImagesGrid (新增):
- 瀑布流布局展示搜索图片
- 智能动态回填机制,自动替换加载失败的图片
- 支持图片灯箱预览
- 显示图片来源链接

MessageBubble:
- 添加工具使用提示栏,显示本次对话使用的工具
- 集成 SearchImagesGrid 展示图片搜索结果
- 支持 Markdown 中图片链接在灯箱中打开

MarkdownRenderer:
- 添加图片链接点击回调支持
- 识别并处理图片URL链接
2025-12-22 12:36:31 +08:00
gaoziman
3459f3821f feat(Hooks): 支持秘塔配置和图片搜索结果处理
useSettings:
- 添加 metasoApiKeyConfigured 状态
- 支持秘塔API Key的更新操作

useStreamChat:
- 添加 SearchImageData 类型定义
- 处理 tool_search_images 事件,实时展示搜索图片
- 处理 tool_used 事件,追踪使用的工具
- 添加搜索图片的数据库持久化逻辑
- ChatMessage 接口添加 searchImages 和 usedTools 字段
2025-12-22 12:23:22 +08:00
gaoziman
5a6a147bd8 feat(API): 集成秘塔AI工具和工具追踪功能
聊天API (chat/route.ts):
- 添加秘塔API Key解密和传递
- 集成工具使用追踪,记录每次对话使用的工具
- 支持图片搜索结果的流式返回
- 添加 tool_used 和 tool_search_images 事件类型

设置API (settings/route.ts):
- 支持秘塔API Key的加密存储和清除
- 更新默认工具列表包含秘塔工具

消息API (messages/route.ts):
- 支持搜索图片数据的追加保存
2025-12-22 12:22:34 +08:00
gaoziman
f0cc0eb996 refactor(类型): 添加工具使用和图片搜索相关类型
- Message 接口添加 usedTools 和 searchImages 字段
- 新增 SearchImageData 接口定义搜索图片数据结构
2025-12-22 12:21:47 +08:00
gaoziman
97d89f44ac feat(工具): 实现秘塔AI搜索和网页读取工具
秘塔搜索 (mita_search):
- 支持网页搜索和图片搜索两种模式
- 图片搜索包含智能验证和动态回填机制
- 自动过滤无效图片URL,确保返回有效结果

秘塔阅读 (mita_reader):
- 将网页内容转换为结构化Markdown格式
- 支持URL格式验证和错误处理

工具执行器更新:
- 添加 metasoApiKey 选项支持
- 集成秘塔搜索和阅读工具到执行流程
- 返回搜索图片数据供前端展示
2025-12-22 12:21:17 +08:00
gaoziman
baf27ceca6 feat(数据库): 添加秘塔AI工具配置和搜索图片支持
- 在 userSettings 表添加 metasoApiKey 和 metasoApiKeyConfigured 字段
- 更新默认工具列表,添加 mita_search 和 mita_reader
- 在 messages 表添加 usedTools 和 searchImages 字段
- 新增 SearchImageData 接口定义图片搜索结果类型
- 添加秘塔搜索和秘塔阅读器工具的种子数据
- 更新数据库名称配置从 lioncode_ui 到 cchcode_ui
2025-12-22 12:01:36 +08:00
25 changed files with 5275 additions and 214 deletions

6
.gitignore vendored
View File

@ -42,3 +42,9 @@ next-env.d.ts
# docs
/docs
# IDE
.idea/
# Other projects
cchcode-ui/

View File

@ -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,
},
});

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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}

View File

@ -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 配置"

View File

@ -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)]">

View 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)}
/>
</>
);
}

View File

@ -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>

View 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;

View File

@ -0,0 +1 @@
ALTER TABLE "messages" ADD COLUMN "used_tools" jsonb DEFAULT '[]'::jsonb;

View File

@ -0,0 +1 @@
ALTER TABLE "messages" ADD COLUMN "search_images" jsonb;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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
}
]
}

View File

@ -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;

View File

@ -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) {

View File

@ -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);

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' | '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;

View File

@ -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: '📄',
},
];
}

View 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}`;
}

View 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;
}

View File

@ -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;
}
// 工具调用记录