feat(API): 集成秘塔AI工具和工具追踪功能

聊天API (chat/route.ts):
- 添加秘塔API Key解密和传递
- 集成工具使用追踪,记录每次对话使用的工具
- 支持图片搜索结果的流式返回
- 添加 tool_used 和 tool_search_images 事件类型

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

消息API (messages/route.ts):
- 支持搜索图片数据的追加保存
This commit is contained in:
gaoziman 2025-12-22 12:22:34 +08:00
parent f0cc0eb996
commit 5a6a147bd8
3 changed files with 238 additions and 6 deletions

View File

@ -233,6 +233,9 @@ export async function POST(request: Request) {
// 解密 API Key // 解密 API Key
const decryptedApiKey = decryptApiKey(settings.cchApiKey); const decryptedApiKey = decryptApiKey(settings.cchApiKey);
// 解密秘塔 API Key如果已配置
const decryptedMetasoApiKey = settings.metasoApiKey ? decryptApiKey(settings.metasoApiKey) : undefined;
// 获取对话信息 // 获取对话信息
const conversation = await db.query.conversations.findFirst({ const conversation = await db.query.conversations.findFirst({
where: eq(conversations.conversationId, conversationId), where: eq(conversations.conversationId, conversationId),
@ -307,6 +310,7 @@ export async function POST(request: Request) {
let thinkingContent = ''; let thinkingContent = '';
let totalInputTokens = 0; let totalInputTokens = 0;
let totalOutputTokens = 0; let totalOutputTokens = 0;
let usedTools: string[] = []; // 收集使用过的工具名称
// 【重要】处理器选择优先级说明: // 【重要】处理器选择优先级说明:
// 1. 首先检查 apiFormat === 'openai':如果用户选择了 "OpenAI 兼容" 格式, // 1. 首先检查 apiFormat === 'openai':如果用户选择了 "OpenAI 兼容" 格式,
@ -332,12 +336,14 @@ export async function POST(request: Request) {
controller, controller,
encoder, encoder,
images, images,
metasoApiKey: decryptedMetasoApiKey,
}); });
fullContent = result.fullContent; fullContent = result.fullContent;
thinkingContent = result.thinkingContent; thinkingContent = result.thinkingContent;
totalInputTokens = result.inputTokens; totalInputTokens = result.inputTokens;
totalOutputTokens = result.outputTokens; totalOutputTokens = result.outputTokens;
usedTools = result.usedTools;
} else if (isCodex) { } else if (isCodex) {
// ==================== Codex 模型处理(使用 Codex Response API ==================== // ==================== Codex 模型处理(使用 Codex Response API ====================
// 仅当使用 Claude 原生格式 + Codex 模型时,才使用 /v1/responses 端点 // 仅当使用 Claude 原生格式 + Codex 模型时,才使用 /v1/responses 端点
@ -355,11 +361,13 @@ export async function POST(request: Request) {
controller, controller,
encoder, encoder,
images, // 传递用户上传的图片 images, // 传递用户上传的图片
metasoApiKey: decryptedMetasoApiKey,
}); });
fullContent = result.fullContent; fullContent = result.fullContent;
totalInputTokens = result.inputTokens; totalInputTokens = result.inputTokens;
totalOutputTokens = result.outputTokens; totalOutputTokens = result.outputTokens;
usedTools = result.usedTools;
} else { } else {
// ==================== Claude 原生格式处理 ==================== // ==================== Claude 原生格式处理 ====================
console.log('[API/chat] 使用 Claude 原生格式 (/v1/messages)'); console.log('[API/chat] 使用 Claude 原生格式 (/v1/messages)');
@ -376,12 +384,14 @@ export async function POST(request: Request) {
controller, controller,
encoder, encoder,
images, // 传递用户上传的图片 images, // 传递用户上传的图片
metasoApiKey: decryptedMetasoApiKey,
}); });
fullContent = result.fullContent; fullContent = result.fullContent;
thinkingContent = result.thinkingContent; thinkingContent = result.thinkingContent;
totalInputTokens = result.inputTokens; totalInputTokens = result.inputTokens;
totalOutputTokens = result.outputTokens; totalOutputTokens = result.outputTokens;
usedTools = result.usedTools;
} }
// 保存 AI 回复到数据库 // 保存 AI 回复到数据库
@ -391,6 +401,7 @@ export async function POST(request: Request) {
role: 'assistant', role: 'assistant',
content: fullContent, content: fullContent,
thinkingContent: thinkingContent || null, thinkingContent: thinkingContent || null,
usedTools: usedTools.length > 0 ? usedTools : null,
inputTokens: totalInputTokens, inputTokens: totalInputTokens,
outputTokens: totalOutputTokens, outputTokens: totalOutputTokens,
status: 'completed', status: 'completed',
@ -417,6 +428,7 @@ export async function POST(request: Request) {
messageId: assistantMessageId, messageId: assistantMessageId,
inputTokens: totalInputTokens, inputTokens: totalInputTokens,
outputTokens: totalOutputTokens, outputTokens: totalOutputTokens,
usedTools: usedTools.length > 0 ? usedTools : undefined,
})}\n\n`)); })}\n\n`));
controller.close(); controller.close();
@ -466,6 +478,8 @@ interface CodexChatParams {
media_type: string; media_type: string;
data: string; data: string;
}[]; }[];
// 秘塔 API Key
metasoApiKey?: string;
} }
// Codex Response API 的输入项类型 // Codex Response API 的输入项类型
@ -491,6 +505,7 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
fullContent: string; fullContent: string;
inputTokens: number; inputTokens: number;
outputTokens: number; outputTokens: number;
usedTools: string[];
}> { }> {
const { const {
cchUrl, cchUrl,
@ -504,6 +519,7 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
controller, controller,
encoder, encoder,
images, images,
metasoApiKey,
} = params; } = params;
// 构建 Codex Response API 格式的输入(过滤空内容的消息) // 构建 Codex Response API 格式的输入(过滤空内容的消息)
@ -568,6 +584,7 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
let fullContent = ''; let fullContent = '';
let totalInputTokens = 0; let totalInputTokens = 0;
let totalOutputTokens = 0; let totalOutputTokens = 0;
const usedTools: string[] = []; // 收集使用过的工具名称
let loopCount = 0; let loopCount = 0;
const maxLoops = 10; const maxLoops = 10;
@ -727,6 +744,16 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
// 执行所有工具并收集结果 // 执行所有工具并收集结果
for (const fc of functionCalls) { 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({ controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_execution_start', 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({ controller.enqueue(encoder.encode(`data: ${JSON.stringify({
@ -755,6 +782,16 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
images: result.images, images: result.images,
})}\n\n`)); })}\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`; const toolDisplayText = `\n\n${result.displayResult}\n\n`;
fullContent += toolDisplayText; fullContent += toolDisplayText;
@ -783,6 +820,7 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
fullContent, fullContent,
inputTokens: totalInputTokens, inputTokens: totalInputTokens,
outputTokens: totalOutputTokens, outputTokens: totalOutputTokens,
usedTools,
}; };
} }
@ -805,6 +843,8 @@ interface ClaudeChatParams {
media_type: string; media_type: string;
data: string; data: string;
}[]; }[];
// 秘塔 API Key
metasoApiKey?: string;
} }
async function handleClaudeChat(params: ClaudeChatParams): Promise<{ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
@ -812,6 +852,7 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
thinkingContent: string; thinkingContent: string;
inputTokens: number; inputTokens: number;
outputTokens: number; outputTokens: number;
usedTools: string[];
}> { }> {
const { const {
cchUrl, cchUrl,
@ -826,6 +867,7 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
controller, controller,
encoder, encoder,
images, images,
metasoApiKey,
} = params; } = params;
// 构建消息历史(过滤空内容的消息) // 构建消息历史(过滤空内容的消息)
@ -890,6 +932,7 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
let thinkingContent = ''; let thinkingContent = '';
let totalInputTokens = 0; let totalInputTokens = 0;
let totalOutputTokens = 0; let totalOutputTokens = 0;
const usedTools: string[] = []; // 收集使用过的工具名称
let loopCount = 0; let loopCount = 0;
const maxLoops = 10; const maxLoops = 10;
let hasToolResults = false; let hasToolResults = false;
@ -1092,13 +1135,23 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
const toolResults: ContentBlock[] = []; const toolResults: ContentBlock[] = [];
for (const tc of toolCalls) { 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({ controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_execution_start', type: 'tool_execution_start',
id: tc.id, id: tc.id,
name: tc.name, name: tc.name,
})}\n\n`)); })}\n\n`));
const result = await executeTool(tc.name, tc.input); const result = await executeTool(tc.name, tc.input, { metasoApiKey });
if (result.requiresPyodide) { if (result.requiresPyodide) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ controller.enqueue(encoder.encode(`data: ${JSON.stringify({
@ -1126,6 +1179,16 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
images: result.images, images: result.images,
})}\n\n`)); })}\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`; const toolDisplayText = `\n\n${result.displayResult}\n\n`;
fullContent += toolDisplayText; fullContent += toolDisplayText;
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ controller.enqueue(encoder.encode(`data: ${JSON.stringify({
@ -1157,6 +1220,7 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
thinkingContent, thinkingContent,
inputTokens: totalInputTokens, inputTokens: totalInputTokens,
outputTokens: totalOutputTokens, outputTokens: totalOutputTokens,
usedTools,
}; };
} }
@ -1177,6 +1241,8 @@ interface OpenAICompatibleChatParams {
media_type: string; media_type: string;
data: string; data: string;
}[]; }[];
// 秘塔 API Key
metasoApiKey?: string;
} }
// OpenAI 消息格式 // OpenAI 消息格式
@ -1207,6 +1273,7 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
thinkingContent: string; thinkingContent: string;
inputTokens: number; inputTokens: number;
outputTokens: number; outputTokens: number;
usedTools: string[];
}> { }> {
const { const {
cchUrl, cchUrl,
@ -1220,6 +1287,7 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
controller, controller,
encoder, encoder,
images, images,
metasoApiKey,
} = params; } = params;
// 构建 OpenAI 格式的消息历史(过滤空内容的消息) // 构建 OpenAI 格式的消息历史(过滤空内容的消息)
@ -1280,6 +1348,7 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
let thinkingContent = ''; // 用于收集 <think> 标签中的思考内容 let thinkingContent = ''; // 用于收集 <think> 标签中的思考内容
let totalInputTokens = 0; let totalInputTokens = 0;
let totalOutputTokens = 0; let totalOutputTokens = 0;
const usedTools: string[] = []; // 收集使用过的工具名称
let loopCount = 0; let loopCount = 0;
const maxLoops = 10; const maxLoops = 10;
@ -1558,6 +1627,16 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
// 执行所有工具并收集结果 // 执行所有工具并收集结果
for (const tc of toolCalls) { 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({ controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_execution_start', type: 'tool_execution_start',
id: tc.id, 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({ controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_execution_result', type: 'tool_execution_result',
@ -1584,6 +1663,16 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
images: result.images, images: result.images,
})}\n\n`)); })}\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`; const toolDisplayText = `\n\n${result.displayResult}\n\n`;
fullContent += toolDisplayText; fullContent += toolDisplayText;
@ -1613,6 +1702,7 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
thinkingContent, thinkingContent,
inputTokens: totalInputTokens, inputTokens: totalInputTokens,
outputTokens: totalOutputTokens, outputTokens: totalOutputTokens,
usedTools,
}; };
} }
@ -1647,6 +1737,43 @@ function buildClaudeToolDefinitions(toolIds: string[]) {
required: ['url'], 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 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 return toolIds
@ -1731,6 +1901,45 @@ function buildCodexToolDefinitions(toolIds: string[]) {
required: ['url'], 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 return toolIds

View File

@ -1,6 +1,6 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db } from '@/drizzle/db'; import { db } from '@/drizzle/db';
import { messages } from '@/drizzle/schema'; import { messages, type SearchImageData } from '@/drizzle/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
interface RouteParams { interface RouteParams {
@ -9,7 +9,7 @@ interface RouteParams {
/** /**
* PATCH /api/messages/[messageId] - * PATCH /api/messages/[messageId] -
* Pyodide * Pyodide
*/ */
export async function PATCH(request: Request, { params }: RouteParams) { export async function PATCH(request: Request, { params }: RouteParams) {
try { try {
@ -39,6 +39,7 @@ export async function PATCH(request: Request, { params }: RouteParams) {
// 构建更新数据 // 构建更新数据
const updateData: { const updateData: {
images?: string[]; images?: string[];
searchImages?: SearchImageData[];
content?: string; content?: string;
updatedAt: Date; updatedAt: Date;
} = { } = {
@ -51,6 +52,12 @@ export async function PATCH(request: Request, { params }: RouteParams) {
updateData.images = [...existingImages, ...body.images]; 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) { if (body.content !== undefined) {
updateData.content = body.content; updateData.content = body.content;

View File

@ -9,9 +9,10 @@ import { encryptApiKey } from '@/lib/crypto';
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
cchUrl: process.env.CCH_DEFAULT_URL || 'https://claude.leocoder.cn/', cchUrl: process.env.CCH_DEFAULT_URL || 'https://claude.leocoder.cn/',
cchApiKeyConfigured: false, cchApiKeyConfigured: false,
metasoApiKeyConfigured: false,
apiFormat: 'claude' as 'claude' | 'openai', // API 格式claude原生| openai兼容 apiFormat: 'claude' as 'claude' | 'openai', // API 格式claude原生| openai兼容
defaultModel: 'claude-sonnet-4-5-20250929', defaultModel: 'claude-sonnet-4-5-20250929',
defaultTools: ['web_search', 'web_fetch'], defaultTools: ['web_search', 'web_fetch', 'mita_search', 'mita_reader'],
systemPrompt: '', systemPrompt: '',
temperature: '0.7', temperature: '0.7',
theme: 'light', theme: 'light',
@ -30,6 +31,7 @@ function formatSettingsResponse(settings: typeof userSettings.$inferSelect | nul
return { return {
cchUrl: settings.cchUrl || DEFAULT_SETTINGS.cchUrl, cchUrl: settings.cchUrl || DEFAULT_SETTINGS.cchUrl,
cchApiKeyConfigured: settings.cchApiKeyConfigured || false, cchApiKeyConfigured: settings.cchApiKeyConfigured || false,
metasoApiKeyConfigured: settings.metasoApiKeyConfigured || false,
apiFormat: (settings.apiFormat as 'claude' | 'openai') || DEFAULT_SETTINGS.apiFormat, apiFormat: (settings.apiFormat as 'claude' | 'openai') || DEFAULT_SETTINGS.apiFormat,
defaultModel: settings.defaultModel || DEFAULT_SETTINGS.defaultModel, defaultModel: settings.defaultModel || DEFAULT_SETTINGS.defaultModel,
defaultTools: settings.defaultTools || DEFAULT_SETTINGS.defaultTools, defaultTools: settings.defaultTools || DEFAULT_SETTINGS.defaultTools,
@ -104,6 +106,7 @@ export async function PUT(request: Request) {
const { const {
cchUrl, cchUrl,
cchApiKey, cchApiKey,
metasoApiKey,
apiFormat, apiFormat,
defaultModel, defaultModel,
defaultTools, 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 格式类型 // API 格式类型
if (apiFormat !== undefined) { if (apiFormat !== undefined) {
updateData.apiFormat = apiFormat; updateData.apiFormat = apiFormat;