diff --git a/package.json b/package.json index 521f222..1c55d4b 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "commander": "^12.0.0", "crypto-js": "^4.2.0", "dotenv": "^16.4.5", + "form-data": "^4.0.5", "inquirer": "^9.2.16", "openai": "^4.52.0", "p-timeout": "^6.0.0", diff --git a/src/kimi/__test__.js b/src/kimi/__test__.js index 2920a69..1417f92 100644 --- a/src/kimi/__test__.js +++ b/src/kimi/__test__.js @@ -1,8 +1,246 @@ -import { getKimiReply } from './index.js' +import { getKimiReply, uploadFile, listFiles, getFile, deleteFile, getFileContent, chatWithFile, chatWithMultipleFiles, ErrorCodes } from './index.js' -// 测试 open ai api +// 测试 Kimi API async function test() { - const message = await getKimiReply('你好!') - console.log('🌸🌸🌸 / message: ', message) + console.log('========== 测试 Kimi 基础对话 ==========') + const result = await getKimiReply('你好!') + if (result.success) { + console.log('🌸🌸🌸 / message: ', result.data) + } else { + console.log('❌ 错误:', result.error) + } + return result } -test() + +// 测试文件上传功能 +async function testFileUpload() { + console.log('\n========== 测试文件上传 ==========') + + // 创建一个临时测试文件 + const testFilePath = './test-upload-file.txt' + const fs = await import('fs') + fs.writeFileSync(testFilePath, '这是一个测试文件的内容,用于测试 Kimi 文件上传功能。\n\n测试时间: ' + new Date().toISOString()) + + const result = await uploadFile(testFilePath) + if (result.success) { + console.log('✅ 上传成功:', result.data) + } else { + console.log('❌ 上传失败:', result.error) + } + + // 清理临时文件 + fs.unlinkSync(testFilePath) + + return result +} + +// 测试获取文件列表 +async function testListFiles() { + console.log('\n========== 测试获取文件列表 ==========') + const result = await listFiles() + if (result.success) { + console.log('✅ 文件列表:', result.data.length, '个文件') + console.log(result.data) + } else { + console.log('❌ 获取失败:', result.error) + } + return result +} + +// 测试获取文件信息 +async function testGetFile(fileId) { + console.log('\n========== 测试获取文件信息 ==========') + const result = await getFile(fileId) + if (result.success) { + console.log('✅ 文件信息:', result.data) + } else { + console.log('❌ 获取失败:', result.error) + } + return result +} + +// 测试获取文件内容 +async function testGetFileContent(fileId) { + console.log('\n========== 测试获取文件内容 ==========') + const result = await getFileContent(fileId) + if (result.success) { + console.log('✅ 文件内容:', result.data) + } else { + console.log('❌ 获取失败:', result.error) + } + return result +} + +// 测试删除文件 +async function testDeleteFile(fileId) { + console.log('\n========== 测试删除文件 ==========') + const result = await deleteFile(fileId) + if (result.success) { + console.log('✅ 删除成功:', result.data) + } else { + console.log('❌ 删除失败:', result.error) + } + return result +} + +// 测试文件验证(空文件) +async function testEmptyFile() { + console.log('\n========== 测试空文件验证 ==========') + const testFilePath = './test-empty-file.txt' + const fs = await import('fs') + fs.writeFileSync(testFilePath, '') // 空文件 + + const result = await uploadFile(testFilePath) + if (!result.success && result.error.code === ErrorCodes.FILE_EMPTY) { + console.log('✅ 正确检测到空文件') + } else { + console.log('❌ 未正确检测空文件:', result) + } + + fs.unlinkSync(testFilePath) + return result +} + +// 测试文件验证(大文件 - 模拟) +async function testLargeFileValidation() { + console.log('\n========== 测试大文件验证 ==========') + // 不实际创建大文件,只测试逻辑 + const result = { success: true } // 跳过实际大文件测试 + console.log('⏭️ 跳过大文件测试(需要实际大文件)') + return result +} + +// 测试不存在的文件 +async function testNonExistentFile() { + console.log('\n========== 测试不存在的文件 ==========') + const result = await uploadFile('./non-existent-file.txt') + if (!result.success && result.error.code === ErrorCodes.FILE_NOT_FOUND) { + console.log('✅ 正确检测到文件不存在') + } else { + console.log('❌ 未正确检测文件不存在:', result) + } + return result +} + +// 测试无效参数 +async function testInvalidParams() { + console.log('\n========== 测试无效参数 ==========') + + // 测试空 fileId + const result1 = await getFile(null) + if (!result1.success && result1.error.code === ErrorCodes.INVALID_PARAMS) { + console.log('✅ 正确检测到无效参数 (getFile)') + } else { + console.log('❌ 未正确检测无效参数:', result1) + } + + // 测试空文件列表 + const result2 = await chatWithMultipleFiles([], 'test') + if (!result2.success && result2.error.code === ErrorCodes.INVALID_PARAMS) { + console.log('✅ 正确检测到无效参数 (chatWithMultipleFiles)') + } else { + console.log('❌ 未正确检测无效参数:', result2) + } +} + +// 测试带文件的对话 +async function testChatWithFile() { + console.log('\n========== 测试带文件的对话 ==========') + + // 创建一个临时测试文件 + const testFilePath = './test-chat-file.txt' + const fs = await import('fs') + fs.writeFileSync(testFilePath, '这是测试文档的内容。\n\n产品名称:测试产品\n版本:1.0.0\n功能:这是一个用于测试文件对话功能的示例文档。') + + const result = await chatWithFile(testFilePath, '请总结一下这个文件的内容') + if (result.success) { + console.log('✅ 对话结果:', result.data) + } else { + console.log('❌ 对话失败:', result.error) + } + + // 清理临时文件 + fs.unlinkSync(testFilePath) + + return result +} + +// 测试多文件对话 +async function testChatWithMultipleFiles() { + console.log('\n========== 测试多文件对话 ==========') + + const fs = await import('fs') + + // 创建多个测试文件 + const files = [ + { path: './test-file-1.txt', content: '第一个文件:产品介绍 - 这是一个优秀的产品。' }, + { path: './test-file-2.txt', content: '第二个文件:技术规格 - CPU: 8核, RAM: 16GB' }, + ] + + files.forEach((f) => fs.writeFileSync(f.path, f.content)) + + const result = await chatWithMultipleFiles( + files.map((f) => f.path), + '请对比总结这两个文件的内容' + ) + if (result.success) { + console.log('✅ 多文件对话结果:', result.data) + } else { + console.log('❌ 多文件对话失败:', result.error) + } + + // 清理临时文件 + files.forEach((f) => fs.unlinkSync(f.path)) + + return result +} + +// 运行所有测试 +async function runAllTests() { + console.log('🚀 开始运行 Kimi 文件上传功能测试...\n') + console.log('注意:请确保已配置 KIMI_API_KEY 环境变量\n') + + try { + // 测试基础对话 + await test() + + // 测试文件验证 + await testNonExistentFile() + await testEmptyFile() + await testLargeFileValidation() + await testInvalidParams() + + // 测试文件上传 + const uploadResult = await testFileUpload() + + if (uploadResult.success && uploadResult.data?.id) { + const fileId = uploadResult.data.id + // 等待文件处理完成 + await new Promise((resolve) => setTimeout(resolve, 3000)) + + // 测试获取文件信息 + await testGetFile(fileId) + + // 测试获取文件内容 + await testGetFileContent(fileId) + + // 测试删除文件 + await testDeleteFile(fileId) + } + + // 测试获取文件列表 + await testListFiles() + + console.log('\n✅ 所有基础测试完成!') + + // 可选:测试带文件的对话(需要更长时间) + // await testChatWithFile() + // await testChatWithMultipleFiles() + + } catch (error) { + console.error('❌ 测试出错:', error) + } +} + +// 运行测试 +runAllTests() diff --git a/src/kimi/__test_file_upload__.js b/src/kimi/__test_file_upload__.js new file mode 100644 index 0000000..134d518 --- /dev/null +++ b/src/kimi/__test_file_upload__.js @@ -0,0 +1,699 @@ +/** + * Kimi 文件上传功能测试用例 + * + * API 端点: POST https://api.moonshot.cn/v1/files + * 文档参考: https://platform.moonshot.cn/docs/api-reference + */ + +import axios from 'axios' +import dotenv from 'dotenv' +import fs from 'fs' +import path from 'path' + +const env = dotenv.config().parsed + +const KIMI_API_BASE = 'https://api.moonshot.cn' + +// ============================================ +// 测试工具函数 +// ============================================ + +/** + * 上传文件到 Kimi + * @param {string} filePath - 文件路径 + * @param {string} purpose - 用途,默认 'file-extract' + * @returns {Promise} 上传结果 + */ +export async function uploadFile(filePath, purpose = 'file-extract') { + const fileStream = fs.createReadStream(filePath) + const fileName = path.basename(filePath) + + try { + const response = await axios.post( + `${KIMI_API_BASE}/v1/files`, + { + file: fileStream, + purpose: purpose, + }, + { + headers: { + Authorization: `Bearer ${env.KIMI_API_KEY}`, + 'Content-Type': 'multipart/form-data', + }, + timeout: 120000, + } + ) + return { success: true, data: response.data } + } catch (error) { + return { + success: false, + error: error.response?.data || error.message, + statusCode: error.response?.status, + } + } +} + +/** + * 获取文件列表 + * @returns {Promise} 文件列表 + */ +export async function listFiles() { + try { + const response = await axios.get(`${KIMI_API_BASE}/v1/files`, { + headers: { + Authorization: `Bearer ${env.KIMI_API_KEY}`, + }, + }) + return { success: true, data: response.data } + } catch (error) { + return { success: false, error: error.message } + } +} + +/** + * 获取文件信息 + * @param {string} fileId - 文件ID + * @returns {Promise} 文件信息 + */ +export async function getFile(fileId) { + try { + const response = await axios.get(`${KIMI_API_BASE}/v1/files/${fileId}`, { + headers: { + Authorization: `Bearer ${env.KIMI_API_KEY}`, + }, + }) + return { success: true, data: response.data } + } catch (error) { + return { success: false, error: error.message } + } +} + +/** + * 删除文件 + * @param {string} fileId - 文件ID + * @returns {Promise} 删除结果 + */ +export async function deleteFile(fileId) { + try { + const response = await axios.delete(`${KIMI_API_BASE}/v1/files/${fileId}`, { + headers: { + Authorization: `Bearer ${env.KIMI_API_KEY}`, + }, + }) + return { success: true, data: response.data } + } catch (error) { + return { success: false, error: error.message } + } +} + +/** + * 获取文件内容 + * @param {string} fileId - 文件ID + * @returns {Promise} 文件内容 + */ +export async function getFileContent(fileId) { + try { + const response = await axios.get(`${KIMI_API_BASE}/v1/files/${fileId}/content`, { + headers: { + Authorization: `Bearer ${env.KIMI_API_KEY}`, + }, + }) + return { success: true, data: response.data } + } catch (error) { + return { success: false, error: error.message } + } +} + +/** + * 带文件的对话 + * @param {string} prompt - 用户问题 + * @param {string[]} fileIds - 文件ID数组 + * @returns {Promise} 对话结果 + */ +export async function getKimiReplyWithFiles(prompt, fileIds = []) { + try { + // 构建消息内容,包含文件引用 + const content = [] + + // 添加文件引用 + for (const fileId of fileIds) { + content.push({ + type: 'file', + file_id: fileId, + }) + } + + // 添加文本问题 + content.push({ + type: 'text', + text: prompt, + }) + + const response = await axios.post( + `${KIMI_API_BASE}/v1/chat/completions`, + { + model: 'moonshot-v1-128k', + messages: [ + { + role: 'user', + content: content, + }, + ], + temperature: 0.3, + }, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${env.KIMI_API_KEY}`, + }, + timeout: 120000, + } + ) + + return { success: true, data: response.data } + } catch (error) { + return { + success: false, + error: error.response?.data || error.message, + statusCode: error.response?.status, + } + } +} + +// ============================================ +// 测试用例 +// ============================================ + +const testCases = { + /** + * TC-01: 正常上传 PDF 文件 + */ + 'TC-01': { + name: '正常上传 PDF 文件', + description: '验证 PDF 文件可以正常上传', + category: '正向测试', + priority: 'P0', + steps: [ + '准备一个有效的 PDF 文件 (大小 < 100MB)', + '调用 uploadFile API 上传文件', + '验证返回状态码为 200', + '验证返回数据包含 file_id', + '验证文件状态为 processed', + ], + expected: { + statusCode: 200, + hasFileId: true, + status: 'processed', + }, + }, + + /** + * TC-02: 上传 TXT 文本文件 + */ + 'TC-02': { + name: '上传 TXT 文本文件', + description: '验证 TXT 文件可以正常上传', + category: '正向测试', + priority: 'P0', + steps: [ + '准备一个有效的 TXT 文件', + '调用 uploadFile API 上传文件', + '验证上传成功', + ], + expected: { statusCode: 200, hasFileId: true }, + }, + + /** + * TC-03: 上传 DOCX 文档文件 + */ + 'TC-03': { + name: '上传 DOCX 文档文件', + description: '验证 DOCX 文件可以正常上传', + category: '正向测试', + priority: 'P1', + steps: [ + '准备一个有效的 DOCX 文件', + '调用 uploadFile API 上传文件', + '验证上传成功', + ], + expected: { statusCode: 200, hasFileId: true }, + }, + + /** + * TC-04: 上传 Markdown 文件 + */ + 'TC-04': { + name: '上传 Markdown 文件', + description: '验证 MD 文件可以正常上传', + category: '正向测试', + priority: 'P1', + steps: [ + '准备一个有效的 .md 文件', + '调用 uploadFile API 上传文件', + '验证上传成功', + ], + expected: { statusCode: 200, hasFileId: true }, + }, + + /** + * TC-05: 上传超大文件 + */ + 'TC-05': { + name: '上传超大文件 (>50MB)', + description: '验证超过大小限制的文件被正确拒绝 (限制为 50MB)', + category: '边界测试', + priority: 'P0', + steps: [ + '准备一个超过 50MB 的文件', + '调用 uploadFile API 上传文件', + '验证返回错误码', + ], + expected: { + statusCode: 400, + errorCode: 'file_too_large', + }, + }, + + /** + * TC-06: 上传空文件 + */ + 'TC-06': { + name: '上传空文件', + description: '验证空文件的处理', + category: '边界测试', + priority: 'P1', + steps: [ + '准备一个 0 字节的空文件', + '调用 uploadFile API 上传文件', + '验证返回结果', + ], + expected: { + statusCode: 400, + errorCode: 'invalid_file', + }, + }, + + /** + * TC-07: 上传不支持的文件格式 + */ + 'TC-07': { + name: '上传不支持的文件格式', + description: '验证不支持的格式被正确拒绝', + category: '负向测试', + priority: 'P0', + steps: [ + '准备一个 .exe 或 .zip 文件', + '调用 uploadFile API 上传文件', + '验证返回错误码', + ], + expected: { + statusCode: 400, + errorCode: 'unsupported_file_type', + }, + }, + + /** + * TC-08: 无 API Key 上传 + */ + 'TC-08': { + name: '无认证上传文件', + description: '验证缺少认证信息的请求被拒绝', + category: '安全测试', + priority: 'P0', + steps: [ + '不携带 Authorization header', + '调用 uploadFile API', + '验证返回 401 错误', + ], + expected: { + statusCode: 401, + errorCode: 'unauthorized', + }, + }, + + /** + * TC-09: 无效 API Key 上传 + */ + 'TC-09': { + name: '无效 API Key 上传', + description: '验证无效认证信息被拒绝', + category: '安全测试', + priority: 'P0', + steps: [ + '使用无效的 API Key', + '调用 uploadFile API', + '验证返回 401 错误', + ], + expected: { + statusCode: 401, + errorCode: 'invalid_api_key', + }, + }, + + /** + * TC-10: 连续上传多个文件 + */ + 'TC-10': { + name: '连续上传多个文件', + description: '验证批量上传功能', + category: '功能测试', + priority: 'P1', + steps: [ + '准备 5 个不同格式的有效文件', + '依次调用 uploadFile API', + '验证所有文件都上传成功', + '验证返回的 file_id 各不相同', + ], + expected: { + allSuccess: true, + uniqueFileIds: true, + }, + }, + + /** + * TC-11: 获取文件列表 + */ + 'TC-11': { + name: '获取文件列表', + description: '验证文件列表接口', + category: '功能测试', + priority: 'P0', + steps: [ + '调用 listFiles API', + '验证返回数据格式正确', + '验证包含已上传的文件', + ], + expected: { + statusCode: 200, + hasData: true, + hasFileList: true, + }, + }, + + /** + * TC-12: 获取单个文件信息 + */ + 'TC-12': { + name: '获取单个文件信息', + description: '验证文件详情接口', + category: '功能测试', + priority: 'P0', + steps: [ + '上传一个文件获取 file_id', + '调用 getFile API', + '验证返回文件信息正确', + ], + expected: { + statusCode: 200, + hasFileInfo: true, + }, + }, + + /** + * TC-13: 删除文件 + */ + 'TC-13': { + name: '删除文件', + description: '验证文件删除功能', + category: '功能测试', + priority: 'P0', + steps: [ + '上传一个文件', + '调用 deleteFile API', + '验证删除成功', + '再次获取该文件验证已删除', + ], + expected: { + deleteStatusCode: 200, + getAfterDelete: 404, + }, + }, + + /** + * TC-14: 获取不存在的文件 + */ + 'TC-14': { + name: '获取不存在的文件', + description: '验证无效文件 ID 的处理', + category: '负向测试', + priority: 'P1', + steps: [ + '使用不存在的 file_id', + '调用 getFile API', + '验证返回 404 错误', + ], + expected: { + statusCode: 404, + errorCode: 'file_not_found', + }, + }, + + /** + * TC-15: 上传同名文件 + */ + 'TC-15': { + name: '上传同名文件', + description: '验证同名文件的处理', + category: '功能测试', + priority: 'P1', + steps: [ + '上传文件 A.txt', + '再次上传同名文件 A.txt', + '验证两个文件都有独立的 file_id', + ], + expected: { + bothSuccess: true, + differentFileIds: true, + }, + }, + + /** + * TC-16: 网络超时重试 + */ + 'TC-16': { + name: '网络超时重试', + description: '验证超时后的重试机制', + category: '可靠性测试', + priority: 'P2', + steps: [ + '模拟网络超时场景', + '验证是否有重试机制', + '验证最终结果正确', + ], + expected: { + hasRetry: true, + eventualSuccess: true, + }, + }, + + /** + * TC-17: 上传损坏的文件 + */ + 'TC-17': { + name: '上传损坏的文件', + description: '验证损坏文件的处理', + category: '负向测试', + priority: 'P2', + steps: [ + '准备一个损坏的 PDF 文件', + '调用 uploadFile API', + '验证错误处理', + ], + expected: { + statusCode: 400, + errorCode: 'invalid_file', + }, + }, + + /** + * TC-18: 特殊字符文件名 + */ + 'TC-18': { + name: '特殊字符文件名', + description: '验证特殊字符文件名的处理', + category: '边界测试', + priority: 'P2', + steps: [ + '准备文件名包含特殊字符的文件', + '调用 uploadFile API', + '验证上传结果', + ], + expected: { + handled: true, + noError: true, + }, + }, + + /** + * TC-19: 中文文件名 + */ + 'TC-19': { + name: '中文文件名', + description: '验证中文文件名的处理', + category: '边界测试', + priority: 'P1', + steps: [ + '准备中文命名的文件', + '调用 uploadFile API', + '验证上传成功', + ], + expected: { statusCode: 200, hasFileId: true }, + }, + + /** + * TC-20: 并发上传 + */ + 'TC-20': { + name: '并发上传多个文件', + description: '验证并发上传的稳定性', + category: '性能测试', + priority: 'P2', + steps: [ + '准备 10 个文件', + '同时发起 10 个上传请求', + '验证所有请求都正确处理', + ], + expected: { + allHandled: true, + noErrors: true, + }, + }, + + /** + * TC-21: 单文件对话 + */ + 'TC-21': { + name: '单文件对话', + description: '验证上传文件后可以进行对话', + category: '功能测试', + priority: 'P0', + steps: [ + '上传一个 PDF 文件', + '获取 file_id', + '调用 getKimiReplyWithFiles 传入 file_id', + '提问关于文件内容的问题', + '验证 AI 回答与文件内容相关', + ], + expected: { + uploadSuccess: true, + replyRelevant: true, + }, + }, + + /** + * TC-22: 多文件引用对话 + */ + 'TC-22': { + name: '多文件引用对话', + description: '验证多个文件可以同时作为对话上下文', + category: '功能测试', + priority: 'P1', + steps: [ + '上传 2-3 个不同文件', + '调用 getKimiReplyWithFiles 传入多个 file_id', + '提问需要综合多个文件的问题', + '验证 AI 能正确引用多个文件内容', + ], + expected: { + multiFileSuccess: true, + correctReference: true, + }, + }, + + /** + * TC-23: 无效 file_id 对话 + */ + 'TC-23': { + name: '无效 file_id 对话', + description: '验证使用无效 file_id 进行对话的错误处理', + category: '负向测试', + priority: 'P0', + steps: [ + '使用不存在的 file_id', + '调用 getKimiReplyWithFiles', + '验证返回错误信息', + ], + expected: { + statusCode: 404, + errorCode: 'file_not_found', + }, + }, +} + +// ============================================ +// 测试运行器 +// ============================================ + +/** + * 运行单个测试 + */ +async function runTest(testId) { + const testCase = testCases[testId] + if (!testCase) { + console.log(`测试用例 ${testId} 不存在`) + return + } + + console.log(`\n========== ${testId}: ${testCase.name} ==========`) + console.log(`分类: ${testCase.category}`) + console.log(`优先级: ${testCase.priority}`) + console.log(`描述: ${testCase.description}`) + console.log(`预期结果:`, testCase.expected) + console.log('测试步骤:') + testCase.steps.forEach((step, index) => { + console.log(` ${index + 1}. ${step}`) + }) +} + +/** + * 运行所有测试用例 + */ +async function runAllTests() { + console.log('========================================') + console.log('Kimi 文件上传功能 - 测试用例设计') + console.log('========================================') + console.log(`总用例数: ${Object.keys(testCases).length}`) + + // 统计 + const stats = { + P0: 0, + P1: 0, + P2: 0, + 正向测试: 0, + 负向测试: 0, + 边界测试: 0, + 安全测试: 0, + 功能测试: 0, + 性能测试: 0, + 可靠性测试: 0, + } + + Object.values(testCases).forEach((tc) => { + stats[tc.priority]++ + stats[tc.category]++ + }) + + console.log('\n优先级分布:') + console.log(` P0 (核心): ${stats.P0} 个`) + console.log(` P1 (重要): ${stats.P1} 个`) + console.log(` P2 (一般): ${stats.P2} 个`) + + console.log('\n类型分布:') + console.log(` 正向测试: ${stats['正向测试']} 个`) + console.log(` 负向测试: ${stats['负向测试']} 个`) + console.log(` 边界测试: ${stats['边界测试']} 个`) + console.log(` 安全测试: ${stats['安全测试']} 个`) + console.log(` 功能测试: ${stats['功能测试']} 个`) + console.log(` 性能测试: ${stats['性能测试']} 个`) + console.log(` 可靠性测试: ${stats['可靠性测试']} 个`) + + // 打印所有测试用例 + for (const testId of Object.keys(testCases)) { + await runTest(testId) + } +} + +// 执行测试 +runAllTests() + +export { testCases } diff --git a/src/kimi/index.js b/src/kimi/index.js index 2a238bb..9c726ae 100644 --- a/src/kimi/index.js +++ b/src/kimi/index.js @@ -1,5 +1,9 @@ import axios from 'axios' import dotenv from 'dotenv' +import fs from 'fs' +import path from 'path' +import FormData from 'form-data' + const env = dotenv.config().parsed // 环境参数 const domain = 'https://api.moonshot.cn' @@ -8,46 +12,89 @@ const server = { models: `${domain}/v1/models`, files: `${domain}/v1/files`, token: `${domain}/v1/tokenizers/estimate-token-count`, - // 这块还可以实现上传文件让 kimi 读取并交互等操作 - // 具体参考文档: https://platform.moonshot.cn/docs/api-reference#api-%E8%AF%B4%E6%98%8E - // 由于我近期非常忙碌,这块欢迎感兴趣的同学提 PR ,我会很快合并 } +// 默认请求头配置 +const getHeaders = (contentType = 'application/json') => ({ + 'Content-Type': contentType, + Authorization: `Bearer ${env.KIMI_API_KEY}`, +}) + +// ==================== 统一返回格式 ==================== + +/** + * 创建成功响应 + * @param {any} data - 返回数据 + * @returns {Object} - 统一格式响应 + */ +function success(data) { + return { success: true, data, error: null } +} + +/** + * 创建失败响应 + * @param {string} code - 错误代码 + * @param {string} message - 错误信息 + * @returns {Object} - 统一格式响应 + */ +function failure(code, message) { + return { success: false, data: null, error: { code, message } } +} + +// 错误代码常量 +export const ErrorCodes = { + FILE_NOT_FOUND: 'FILE_NOT_FOUND', + FILE_TOO_LARGE: 'FILE_TOO_LARGE', + FILE_EMPTY: 'FILE_EMPTY', + INVALID_PARAMS: 'INVALID_PARAMS', + UPLOAD_FAILED: 'UPLOAD_FAILED', + API_ERROR: 'API_ERROR', + AUTH_ERROR: 'AUTH_ERROR', + RATE_LIMIT: 'RATE_LIMIT', + NETWORK_ERROR: 'NETWORK_ERROR', + TIMEOUT: 'TIMEOUT', + PROCESSING_FAILED: 'PROCESSING_FAILED', +} + +// 文件大小限制 (50MB) +const MAX_FILE_SIZE = 50 * 1024 * 1024 + const configuration = { // 参数详情请参考 https://platform.moonshot.cn/docs/api-reference#%E5%AD%97%E6%AE%B5%E8%AF%B4%E6%98%8E - /* + /* Model ID, 可以通过 List Models 获取 目前可选 moonshot-v1-8k | moonshot-v1-32k | moonshot-v1-128k */ model: 'moonshot-v1-8k', - /* + /* 使用什么采样温度,介于 0 和 1 之间。较高的值(如 0.7)将使输出更加随机,而较低的值(如 0.2)将使其更加集中和确定性。 如果设置,值域须为 [0, 1] 我们推荐 0.3,以达到较合适的效果。 */ temperature: 0.3, - /* + /* 聊天完成时生成的最大 token 数。如果到生成了最大 token 数个结果仍然没有结束,finish reason 会是 "length", 否则会是 "stop" 这个值建议按需给个合理的值,如果不给的话,我们会给一个不错的整数比如 1024。特别要注意的是,这个 max_tokens 是指您期待我们返回的 token 长度,而不是输入 + 输出的总长度。 比如对一个 moonshot-v1-8k 模型,它的最大输入 + 输出总长度是 8192,当输入 messages 总长度为 4096 的时候,您最多只能设置为 4096, - 否则我们服务会返回不合法的输入参数( invalid_request_error ),并拒绝回答。如果您希望获得“输入的精确 token 数”,可以使用下面的“计算 Token” API 使用我们的计算器获得计数。 + 否则我们服务会返回不合法的输入参数( invalid_request_error ),并拒绝回答。如果您希望获得"输入的精确 token 数",可以使用下面的"计算 Token" API 使用我们的计算器获得计数。 */ max_tokens: 5000, - /* + /* 是否流式返回, 默认 false, 可选 true */ stream: true, } +/** + * 获取 Kimi 回复 + * @param {string} prompt - 用户输入 + * @returns {Promise} - 统一格式响应 { success, data, error } + */ export async function getKimiReply(prompt) { try { const res = await axios.post( server.chat, - Object.assign(configuration, { - /* - 包含迄今为止对话的消息列表。 - 要保持对话的上下文,需要将之前的对话历史并入到该数组 - 这是一个结构体的列表,每个元素类似如下:{"role": "user", "content": "你好"} role 只支持 system,user,assistant 其一,content 不得为空 - */ + { + ...configuration, messages: [ { role: 'user', @@ -55,21 +102,18 @@ export async function getKimiReply(prompt) { }, ], model: 'moonshot-v1-128k', - }), + }, { timeout: 120000, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.KIMI_API_KEY}`, }, - // pass a http proxy agent - // proxy: { - // host: 'localhost', - // port: 7890, - // } }, ) - if (!configuration.stream) return res.data.choices[0].message.content + if (!configuration.stream) { + return success(res.data.choices[0].message.content) + } let result = '' const lines = res.data.split('\n').filter((line) => line.trim() !== '') @@ -83,12 +127,386 @@ export async function getKimiReply(prompt) { } } } - return result + return success(result) + } catch (error) { + return parseError(error) + } +} + +// ==================== 文件操作 API ==================== + +/** + * 验证文件是否有效 + * @param {string} filePath - 文件路径 + * @returns {Object|null} - 验证失败返回错误对象,成功返回 null + */ +function validateFile(filePath) { + if (!fs.existsSync(filePath)) { + return failure(ErrorCodes.FILE_NOT_FOUND, `文件不存在: ${filePath}`) + } + + const stats = fs.statSync(filePath) + if (stats.size === 0) { + return failure(ErrorCodes.FILE_EMPTY, `文件为空: ${filePath}`) + } + + if (stats.size > MAX_FILE_SIZE) { + const sizeMB = (stats.size / 1024 / 1024).toFixed(2) + return failure(ErrorCodes.FILE_TOO_LARGE, `文件大小 ${sizeMB}MB 超过限制 50MB: ${filePath}`) + } + + return null +} + +/** + * 上传文件到 Kimi 服务器 + * @param {string} filePath - 本地文件路径 + * @param {string} purpose - 文件用途,默认为 'file-extract'(文件内容提取),可选 'retrieval'(检索增强) + * @returns {Promise} - 统一格式响应 { success, data, error } + */ +export async function uploadFile(filePath, purpose = 'file-extract') { + try { + // 客户端文件验证 + const validationError = validateFile(filePath) + if (validationError) return validationError + + const formData = new FormData() + formData.append('file', fs.createReadStream(filePath)) + formData.append('purpose', purpose) + + const res = await axios.post(server.files, formData, { + headers: { + ...formData.getHeaders(), + Authorization: `Bearer ${env.KIMI_API_KEY}`, + }, + timeout: 60000, + }) + + console.log('📤 文件上传成功:', res.data.id) + return success(res.data) + } catch (error) { + return parseError(error, ErrorCodes.UPLOAD_FAILED) + } +} + +/** + * 获取文件列表 + * @param {string} purpose - 筛选文件用途,可选 'file-extract' 或 'retrieval' + * @returns {Promise} - 统一格式响应 { success, data, error } + */ +export async function listFiles(purpose = null) { + try { + const params = purpose ? { purpose } : {} + const res = await axios.get(server.files, { + params, + headers: getHeaders(), + timeout: 30000, + }) + + const files = res.data.data || [] + console.log('📋 获取文件列表成功,共', files.length, '个文件') + return success(files) + } catch (error) { + return parseError(error) + } +} + +/** + * 获取文件信息 + * @param {string} fileId - 文件ID + * @returns {Promise} - 统一格式响应 { success, data, error } + */ +export async function getFile(fileId) { + try { + if (!fileId) { + return failure(ErrorCodes.INVALID_PARAMS, 'fileId 不能为空') + } + + const res = await axios.get(`${server.files}/${fileId}`, { + headers: getHeaders(), + timeout: 30000, + }) + + return success(res.data) + } catch (error) { + return parseError(error) + } +} + +/** + * 删除文件 + * @param {string} fileId - 文件ID + * @returns {Promise} - 统一格式响应 { success, data, error } + */ +export async function deleteFile(fileId) { + try { + if (!fileId) { + return failure(ErrorCodes.INVALID_PARAMS, 'fileId 不能为空') + } + + const res = await axios.delete(`${server.files}/${fileId}`, { + headers: getHeaders(), + timeout: 30000, + }) + + console.log('🗑️ 文件删除成功:', fileId) + return success(res.data.deleted || true) + } catch (error) { + return parseError(error) + } +} + +/** + * 获取文件内容 + * @param {string} fileId - 文件ID + * @returns {Promise} - 统一格式响应 { success, data, error } + */ +export async function getFileContent(fileId) { + try { + if (!fileId) { + return failure(ErrorCodes.INVALID_PARAMS, 'fileId 不能为空') + } + + const res = await axios.get(`${server.files}/${fileId}/content`, { + headers: getHeaders(), + timeout: 30000, + }) + + return success(res.data.content || res.data) + } catch (error) { + return parseError(error) + } +} + +// ==================== 带文件的对话功能 ==================== + +/** + * 上传文件并进行对话 + * @param {string} filePath - 本地文件路径 + * @param {string} prompt - 用户提问 + * @param {Object} options - 可选配置 + * @returns {Promise} - 统一格式响应 { success, data, error } + */ +export async function chatWithFile(filePath, prompt, options = {}) { + try { + // 1. 上传文件 + const uploadResult = await uploadFile(filePath, 'file-extract') + if (!uploadResult.success) { + return uploadResult + } + const fileInfo = uploadResult.data + + // 2. 等待文件处理完成(轮询检查状态) + let fileStatus = fileInfo.status + let attempts = 0 + const maxAttempts = options.maxAttempts || 30 + const pollInterval = options.pollInterval || 2000 + + while (fileStatus !== 'processed' && attempts < maxAttempts) { + await sleep(pollInterval) + const fileResult = await getFile(fileInfo.id) + if (!fileResult.success) { + return failure(ErrorCodes.PROCESSING_FAILED, '获取文件状态失败') + } + fileStatus = fileResult.data.status + attempts++ + console.log(`⏳ 文件处理中... 状态: ${fileStatus}, 尝试: ${attempts}/${maxAttempts}`) + } + + if (fileStatus !== 'processed') { + return failure(ErrorCodes.TIMEOUT, '文件处理超时') + } + + // 3. 发起带文件的对话请求 + const res = await axios.post( + server.chat, + { + model: options.model || configuration.model, + messages: [ + { + role: 'system', + content: '你是一个有帮助的助手,可以根据用户上传的文件内容回答问题。', + }, + { + role: 'user', + content: [ + { type: 'text', text: prompt }, + { type: 'file', file_id: fileInfo.id }, + ], + }, + ], + temperature: options.temperature || configuration.temperature, + max_tokens: options.max_tokens || configuration.max_tokens, + stream: false, + }, + { + headers: getHeaders(), + timeout: 120000, + }, + ) + + const result = res.data.choices[0].message.content + + // 4. 清理:删除上传的文件(可选) + if (options.autoDelete !== false) { + await deleteFile(fileInfo.id) + } + + return success(result) + } catch (error) { + return parseError(error) + } +} + +/** + * 批量上传多个文件并对话 + * @param {Array} filePaths - 文件路径数组 + * @param {string} prompt - 用户提问 + * @param {Object} options - 可选配置 + * @returns {Promise} - 统一格式响应 { success, data, error } + */ +export async function chatWithMultipleFiles(filePaths, prompt, options = {}) { + try { + if (!filePaths || filePaths.length === 0) { + return failure(ErrorCodes.INVALID_PARAMS, '文件路径列表不能为空') + } + + // 1. 批量上传文件 + const uploadPromises = filePaths.map((filePath) => uploadFile(filePath, 'file-extract')) + const uploadResults = await Promise.all(uploadPromises) + + const failedUploads = uploadResults.filter((result) => !result.success) + if (failedUploads.length > 0) { + console.warn(`⚠️ ${failedUploads.length} 个文件上传失败`) + } + + const successfulUploads = uploadResults.filter((result) => result.success) + if (successfulUploads.length === 0) { + return failure(ErrorCodes.UPLOAD_FAILED, '所有文件上传失败') + } + + // 2. 等待所有文件处理完成 + const fileIds = successfulUploads.map((result) => result.data.id) + const waitResult = await waitForFilesProcessed(fileIds, options) + if (!waitResult.success) { + return waitResult + } + + // 3. 构建消息内容 + const content = [{ type: 'text', text: prompt }] + fileIds.forEach((fileId) => { + content.push({ type: 'file', file_id: fileId }) + }) + + // 4. 发起对话请求 + const res = await axios.post( + server.chat, + { + model: options.model || configuration.model, + messages: [ + { + role: 'system', + content: '你是一个有帮助的助手,可以根据用户上传的多个文件内容回答问题。', + }, + { role: 'user', content }, + ], + temperature: options.temperature || configuration.temperature, + max_tokens: options.max_tokens || configuration.max_tokens, + stream: false, + }, + { + headers: getHeaders(), + timeout: 120000, + }, + ) + + const result = res.data.choices[0].message.content + + // 5. 清理:删除上传的文件 + if (options.autoDelete !== false) { + await Promise.all(fileIds.map((fileId) => deleteFile(fileId))) + } + + return success(result) } catch (error) { - console.log('Kimi 错误对应详情可参考官网: https://platform.moonshot.cn/docs/api-reference#%E9%94%99%E8%AF%AF%E8%AF%B4%E6%98%8E') - console.log('常见的 401 一般意味着你鉴权失败, 请检查你的 API_KEY 是否正确。') - console.log('常见的 429 一般意味着你被限制了请求频次,请求频率过高,或 kimi 服务器过载,可以适当调整请求频率,或者等待一段时间再试。') - console.error(error.code) - console.error(error.message) + return parseError(error) + } +} + +// ==================== 辅助函数 ==================== + +/** + * 等待文件处理完成 + * @param {Array} fileIds - 文件ID数组 + * @param {Object} options - 配置选项 + * @returns {Promise} - 统一格式响应 + */ +async function waitForFilesProcessed(fileIds, options = {}) { + const maxAttempts = options.maxAttempts || 30 + const pollInterval = options.pollInterval || 2000 + + for (const fileId of fileIds) { + let status = 'pending' + let attempts = 0 + + while (status !== 'processed' && attempts < maxAttempts) { + await sleep(pollInterval) + const fileResult = await getFile(fileId) + if (fileResult.success) { + status = fileResult.data.status + } + attempts++ + } + + if (status !== 'processed') { + return failure(ErrorCodes.TIMEOUT, `文件 ${fileId} 处理超时`) + } } + + return success(true) +} + +/** + * 延迟函数 + * @param {number} ms - 延迟毫秒数 + */ +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** + * 解析错误并返回统一格式 + * @param {Error} error - 错误对象 + * @param {string} defaultCode - 默认错误代码 + * @returns {Object} - 统一格式错误响应 + */ +function parseError(error, defaultCode = ErrorCodes.API_ERROR) { + let code = defaultCode + let message = error.message || '未知错误' + + // 根据 HTTP 状态码映射错误类型 + if (error.response) { + const status = error.response.status + if (status === 401) { + code = ErrorCodes.AUTH_ERROR + message = '鉴权失败,请检查 API_KEY 是否正确' + } else if (status === 429) { + code = ErrorCodes.RATE_LIMIT + message = '请求频率过高,请稍后重试' + } else if (status === 408 || error.code === 'ECONNABORTED') { + code = ErrorCodes.TIMEOUT + message = '请求超时' + } else if (error.response.data?.error?.message) { + message = error.response.data.error.message + } + } else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') { + code = ErrorCodes.NETWORK_ERROR + message = '网络连接失败' + } else if (error.code === 'ECONNABORTED') { + code = ErrorCodes.TIMEOUT + message = '请求超时' + } + + console.error(`[Kimi Error] ${code}: ${message}`) + return failure(code, message) }