mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-02 18:39:06 +08:00
Merge branch 'feat/agents-new' of github.com:CherryHQ/cherry-studio into feat/agents-new
This commit is contained in:
commit
c19659daa5
22
.github/workflows/delete-branch.yml
vendored
Normal file
22
.github/workflows/delete-branch.yml
vendored
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
name: Delete merged branch
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- closed
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
delete-branch:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
if: github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
steps:
|
||||||
|
- name: Delete merged branch
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
github.rest.git.deleteRef({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
ref: `heads/${context.payload.pull_request.head.ref}`,
|
||||||
|
})
|
||||||
2
.github/workflows/pr-ci.yml
vendored
2
.github/workflows/pr-ci.yml
vendored
@ -10,12 +10,14 @@ on:
|
|||||||
- main
|
- main
|
||||||
- develop
|
- develop
|
||||||
- v2
|
- v2
|
||||||
|
types: [ready_for_review, synchronize, opened]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
PRCI: true
|
PRCI: true
|
||||||
|
if: github.event.pull_request.draft == false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
|
|||||||
36
.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch
vendored
Normal file
36
.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch
vendored
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||||
|
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..91d0f336b318833c6cee9599fe91370c0ff75323 100644
|
||||||
|
--- a/dist/index.mjs
|
||||||
|
+++ b/dist/index.mjs
|
||||||
|
@@ -447,7 +447,10 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/get-model-path.ts
|
||||||
|
-function getModelPath(modelId) {
|
||||||
|
+function getModelPath(modelId, baseURL) {
|
||||||
|
+ if (baseURL?.includes('cherryin')) {
|
||||||
|
+ return `models/${modelId}`;
|
||||||
|
+ }
|
||||||
|
return modelId.includes("/") ? modelId : `models/${modelId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -856,7 +859,8 @@ var GoogleGenerativeAILanguageModel = class {
|
||||||
|
rawValue: rawResponse
|
||||||
|
} = await postJsonToApi2({
|
||||||
|
url: `${this.config.baseURL}/${getModelPath(
|
||||||
|
- this.modelId
|
||||||
|
+ this.modelId,
|
||||||
|
+ this.config.baseURL
|
||||||
|
)}:generateContent`,
|
||||||
|
headers: mergedHeaders,
|
||||||
|
body: args,
|
||||||
|
@@ -962,7 +966,8 @@ var GoogleGenerativeAILanguageModel = class {
|
||||||
|
);
|
||||||
|
const { responseHeaders, value: response } = await postJsonToApi2({
|
||||||
|
url: `${this.config.baseURL}/${getModelPath(
|
||||||
|
- this.modelId
|
||||||
|
+ this.modelId,
|
||||||
|
+ this.config.baseURL
|
||||||
|
)}:streamGenerateContent?alt=sse`,
|
||||||
|
headers,
|
||||||
|
body: args,
|
||||||
@ -125,16 +125,59 @@ afterSign: scripts/notarize.js
|
|||||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||||
releaseInfo:
|
releaseInfo:
|
||||||
releaseNotes: |
|
releaseNotes: |
|
||||||
✨ 新功能:
|
<!--LANG:en-->
|
||||||
- 新增 CherryIN 服务商
|
🚀 New Features:
|
||||||
- 新增 AiOnly AI 服务商
|
- Refactored AI core engine for more efficient and stable content generation
|
||||||
- 更新 MCP 服务器卡片布局和样式,改为列表视图
|
- Added support for multiple AI model providers: CherryIN, AiOnly
|
||||||
|
- Added API server functionality for external application integration
|
||||||
|
- Added PaddleOCR document recognition for enhanced document processing
|
||||||
|
- Added Anthropic OAuth authentication support
|
||||||
|
- Added data storage space limit notifications
|
||||||
|
- Added font settings for global and code fonts customization
|
||||||
|
- Added auto-copy feature after translation completion
|
||||||
|
- Added keyboard shortcuts: rename topic, edit last message, etc.
|
||||||
|
- Added text attachment preview for viewing file contents in messages
|
||||||
|
- Added custom window control buttons (minimize, maximize, close)
|
||||||
|
- Support for Qwen long-text (qwen-long) and document analysis (qwen-doc) models with native file uploads
|
||||||
|
- Support for Qwen image recognition models (Qwen-Image)
|
||||||
|
- Added iFlow CLI support
|
||||||
|
- Converted knowledge base and web search to tool-calling approach for better flexibility
|
||||||
|
|
||||||
🐛 问题修复:
|
🎨 UI Improvements & Bug Fixes:
|
||||||
- 修复 QwenMT 模型的翻译内容处理逻辑
|
- Integrated HeroUI and Tailwind CSS framework
|
||||||
- 修复无法将外部笔记添加到知识库的问题
|
- Optimized message notification styles with unified toast component
|
||||||
|
- Moved free models to bottom with fixed position for easier access
|
||||||
|
- Refactored quick panel and input bar tools for smoother operation
|
||||||
|
- Optimized responsive design for navbar and sidebar
|
||||||
|
- Improved scrollbar component with horizontal scrolling support
|
||||||
|
- Fixed multiple translation issues: paste handling, file processing, state management
|
||||||
|
- Various UI optimizations and bug fixes
|
||||||
|
<!--LANG:zh-CN-->
|
||||||
|
🚀 新功能:
|
||||||
|
- 重构 AI 核心引擎,提供更高效稳定的内容生成
|
||||||
|
- 新增多个 AI 模型提供商支持:CherryIN、AiOnly
|
||||||
|
- 新增 API 服务器功能,支持外部应用集成
|
||||||
|
- 新增 PaddleOCR 文档识别,增强文档处理能力
|
||||||
|
- 新增 Anthropic OAuth 认证支持
|
||||||
|
- 新增数据存储空间限制提醒
|
||||||
|
- 新增字体设置,支持全局字体和代码字体自定义
|
||||||
|
- 新增翻译完成后自动复制功能
|
||||||
|
- 新增键盘快捷键:重命名主题、编辑最后一条消息等
|
||||||
|
- 新增文本附件预览,可查看消息中的文件内容
|
||||||
|
- 新增自定义窗口控制按钮(最小化、最大化、关闭)
|
||||||
|
- 支持通义千问长文本(qwen-long)和文档分析(qwen-doc)模型,原生文件上传
|
||||||
|
- 支持通义千问图像识别模型(Qwen-Image)
|
||||||
|
- 新增 iFlow CLI 支持
|
||||||
|
- 知识库和网页搜索转换为工具调用方式,提升灵活性
|
||||||
|
|
||||||
|
🎨 界面改进与问题修复:
|
||||||
|
- 集成 HeroUI 和 Tailwind CSS 框架
|
||||||
|
- 优化消息通知样式,统一 toast 组件
|
||||||
|
- 免费模型移至底部固定位置,便于访问
|
||||||
|
- 重构快捷面板和输入栏工具,操作更流畅
|
||||||
|
- 优化导航栏和侧边栏响应式设计
|
||||||
|
- 改进滚动条组件,支持水平滚动
|
||||||
|
- 修复多个翻译问题:粘贴处理、文件处理、状态管理
|
||||||
|
- 各种界面优化和问题修复
|
||||||
|
<!--LANG:END-->
|
||||||
|
|
||||||
🚀 性能优化:
|
|
||||||
- 提升输入框响应速度
|
|
||||||
- 优化模型切换性能
|
|
||||||
- 改进翻译功能的引用和邮件格式处理
|
|
||||||
|
|||||||
@ -380,7 +380,8 @@
|
|||||||
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
|
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
|
||||||
"undici": "6.21.2",
|
"undici": "6.21.2",
|
||||||
"vite": "npm:rolldown-vite@latest",
|
"vite": "npm:rolldown-vite@latest",
|
||||||
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch"
|
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
||||||
|
"@ai-sdk/google@npm:2.0.14": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch"
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@4.9.1",
|
"packageManager": "yarn@4.9.1",
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
|||||||
@ -39,7 +39,7 @@
|
|||||||
"@ai-sdk/anthropic": "^2.0.17",
|
"@ai-sdk/anthropic": "^2.0.17",
|
||||||
"@ai-sdk/azure": "^2.0.30",
|
"@ai-sdk/azure": "^2.0.30",
|
||||||
"@ai-sdk/deepseek": "^1.0.17",
|
"@ai-sdk/deepseek": "^1.0.17",
|
||||||
"@ai-sdk/google": "^2.0.14",
|
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch",
|
||||||
"@ai-sdk/openai": "^2.0.30",
|
"@ai-sdk/openai": "^2.0.30",
|
||||||
"@ai-sdk/openai-compatible": "^1.0.17",
|
"@ai-sdk/openai-compatible": "^1.0.17",
|
||||||
"@ai-sdk/provider": "^2.0.0",
|
"@ai-sdk/provider": "^2.0.0",
|
||||||
|
|||||||
@ -368,16 +368,27 @@ export const WINDOWS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Helper function to escape strings for AppleScript
|
||||||
|
const escapeForAppleScript = (str: string): string => {
|
||||||
|
// In AppleScript strings, backslashes and double quotes need to be escaped
|
||||||
|
// When passed through osascript -e with single quotes, we need:
|
||||||
|
// 1. Backslash: \ -> \\
|
||||||
|
// 2. Double quote: " -> \"
|
||||||
|
return str
|
||||||
|
.replace(/\\/g, '\\\\') // Escape backslashes first
|
||||||
|
.replace(/"/g, '\\"') // Then escape double quotes
|
||||||
|
}
|
||||||
|
|
||||||
export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||||
{
|
{
|
||||||
id: terminalApps.systemDefault,
|
id: terminalApps.systemDefault,
|
||||||
name: 'Terminal',
|
name: 'Terminal',
|
||||||
bundleId: 'com.apple.Terminal',
|
bundleId: 'com.apple.Terminal',
|
||||||
command: (directory: string, fullCommand: string) => ({
|
command: (_directory: string, fullCommand: string) => ({
|
||||||
command: 'sh',
|
command: 'sh',
|
||||||
args: [
|
args: [
|
||||||
'-c',
|
'-c',
|
||||||
`open -na Terminal && sleep 0.5 && osascript -e 'tell application "Terminal" to activate' -e 'tell application "Terminal" to do script "cd '${directory.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}' && clear && ${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}" in front window'`
|
`open -na Terminal && sleep 0.5 && osascript -e 'tell application "Terminal" to activate' -e 'tell application "Terminal" to do script "${escapeForAppleScript(fullCommand)}" in front window'`
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -385,11 +396,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
|||||||
id: terminalApps.iterm2,
|
id: terminalApps.iterm2,
|
||||||
name: 'iTerm2',
|
name: 'iTerm2',
|
||||||
bundleId: 'com.googlecode.iterm2',
|
bundleId: 'com.googlecode.iterm2',
|
||||||
command: (directory: string, fullCommand: string) => ({
|
command: (_directory: string, fullCommand: string) => ({
|
||||||
command: 'sh',
|
command: 'sh',
|
||||||
args: [
|
args: [
|
||||||
'-c',
|
'-c',
|
||||||
`open -na iTerm && sleep 0.8 && osascript -e 'on waitUntilRunning()\n repeat 50 times\n tell application "System Events"\n if (exists process "iTerm2") then exit repeat\n end tell\n delay 0.1\n end repeat\nend waitUntilRunning\n\nwaitUntilRunning()\n\ntell application "iTerm2"\n if (count of windows) = 0 then\n create window with default profile\n delay 0.3\n else\n tell current window\n create tab with default profile\n end tell\n delay 0.3\n end if\n tell current session of current window to write text "cd '${directory.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}' && clear && ${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"\n activate\nend tell'`
|
`open -na iTerm && sleep 0.8 && osascript -e 'on waitUntilRunning()\n repeat 50 times\n tell application "System Events"\n if (exists process "iTerm2") then exit repeat\n end tell\n delay 0.1\n end repeat\nend waitUntilRunning\n\nwaitUntilRunning()\n\ntell application "iTerm2"\n if (count of windows) = 0 then\n create window with default profile\n delay 0.3\n else\n tell current window\n create tab with default profile\n end tell\n delay 0.3\n end if\n tell current session of current window to write text "${escapeForAppleScript(fullCommand)}"\n activate\nend tell'`
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -397,11 +408,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
|||||||
id: terminalApps.kitty,
|
id: terminalApps.kitty,
|
||||||
name: 'kitty',
|
name: 'kitty',
|
||||||
bundleId: 'net.kovidgoyal.kitty',
|
bundleId: 'net.kovidgoyal.kitty',
|
||||||
command: (directory: string, fullCommand: string) => ({
|
command: (_directory: string, fullCommand: string) => ({
|
||||||
command: 'sh',
|
command: 'sh',
|
||||||
args: [
|
args: [
|
||||||
'-c',
|
'-c',
|
||||||
`cd "${directory}" && open -na kitty --args --directory="${directory}" sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "kitty" to activate'`
|
`cd "${_directory}" && open -na kitty --args --directory="${_directory}" sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "kitty" to activate'`
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -409,11 +420,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
|||||||
id: terminalApps.alacritty,
|
id: terminalApps.alacritty,
|
||||||
name: 'Alacritty',
|
name: 'Alacritty',
|
||||||
bundleId: 'org.alacritty',
|
bundleId: 'org.alacritty',
|
||||||
command: (directory: string, fullCommand: string) => ({
|
command: (_directory: string, fullCommand: string) => ({
|
||||||
command: 'sh',
|
command: 'sh',
|
||||||
args: [
|
args: [
|
||||||
'-c',
|
'-c',
|
||||||
`open -na Alacritty --args --working-directory "${directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Alacritty" to activate'`
|
`open -na Alacritty --args --working-directory "${_directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Alacritty" to activate'`
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -421,11 +432,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
|||||||
id: terminalApps.wezterm,
|
id: terminalApps.wezterm,
|
||||||
name: 'WezTerm',
|
name: 'WezTerm',
|
||||||
bundleId: 'com.github.wez.wezterm',
|
bundleId: 'com.github.wez.wezterm',
|
||||||
command: (directory: string, fullCommand: string) => ({
|
command: (_directory: string, fullCommand: string) => ({
|
||||||
command: 'sh',
|
command: 'sh',
|
||||||
args: [
|
args: [
|
||||||
'-c',
|
'-c',
|
||||||
`open -na WezTerm --args start --new-tab --cwd "${directory}" -- sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "WezTerm" to activate'`
|
`open -na WezTerm --args start --new-tab --cwd "${_directory}" -- sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "WezTerm" to activate'`
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -433,11 +444,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
|||||||
id: terminalApps.ghostty,
|
id: terminalApps.ghostty,
|
||||||
name: 'Ghostty',
|
name: 'Ghostty',
|
||||||
bundleId: 'com.mitchellh.ghostty',
|
bundleId: 'com.mitchellh.ghostty',
|
||||||
command: (directory: string, fullCommand: string) => ({
|
command: (_directory: string, fullCommand: string) => ({
|
||||||
command: 'sh',
|
command: 'sh',
|
||||||
args: [
|
args: [
|
||||||
'-c',
|
'-c',
|
||||||
`cd "${directory}" && open -na Ghostty --args --working-directory="${directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Ghostty" to activate'`
|
`cd "${_directory}" && open -na Ghostty --args --working-directory="${_directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Ghostty" to activate'`
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -445,7 +456,7 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
|||||||
id: terminalApps.tabby,
|
id: terminalApps.tabby,
|
||||||
name: 'Tabby',
|
name: 'Tabby',
|
||||||
bundleId: 'org.tabby',
|
bundleId: 'org.tabby',
|
||||||
command: (directory: string, fullCommand: string) => ({
|
command: (_directory: string, fullCommand: string) => ({
|
||||||
command: 'sh',
|
command: 'sh',
|
||||||
args: [
|
args: [
|
||||||
'-c',
|
'-c',
|
||||||
@ -453,7 +464,7 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
|||||||
open -na Tabby --args open && sleep 0.3
|
open -na Tabby --args open && sleep 0.3
|
||||||
else
|
else
|
||||||
open -na Tabby --args open && sleep 2
|
open -na Tabby --args open && sleep 2
|
||||||
fi && osascript -e 'tell application "Tabby" to activate' -e 'set the clipboard to "cd \\"${directory.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}\\" && clear && ${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"' -e 'tell application "System Events" to tell process "Tabby" to keystroke "v" using {command down}' -e 'tell application "System Events" to key code 36'`
|
fi && osascript -e 'tell application "Tabby" to activate' -e 'set the clipboard to "${escapeForAppleScript(fullCommand)}"' -e 'tell application "System Events" to tell process "Tabby" to keystroke "v" using {command down}' -e 'tell application "System Events" to key code 36'`
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,13 @@ import { modelsRoutes } from './routes/models'
|
|||||||
|
|
||||||
const logger = loggerService.withContext('ApiServer')
|
const logger = loggerService.withContext('ApiServer')
|
||||||
|
|
||||||
|
const LONG_POLL_TIMEOUT_MS = 120 * 60_000 // 120 minutes
|
||||||
|
const extendMessagesTimeout: express.RequestHandler = (req, res, next) => {
|
||||||
|
req.setTimeout(LONG_POLL_TIMEOUT_MS)
|
||||||
|
res.setTimeout(LONG_POLL_TIMEOUT_MS)
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
app.use(
|
app.use(
|
||||||
express.json({
|
express.json({
|
||||||
@ -26,7 +33,12 @@ app.use((req, res, next) => {
|
|||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
res.on('finish', () => {
|
res.on('finish', () => {
|
||||||
const duration = Date.now() - start
|
const duration = Date.now() - start
|
||||||
logger.info(`${req.method} ${req.path} - ${res.statusCode} - ${duration}ms`)
|
logger.info('API request completed', {
|
||||||
|
method: req.method,
|
||||||
|
path: req.path,
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
durationMs: duration
|
||||||
|
})
|
||||||
})
|
})
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
@ -113,12 +125,11 @@ app.get('/', (_req, res) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Provider-specific API routes with auth (must be before /v1 to avoid conflicts)
|
// Setup OpenAPI documentation before protected routes so docs remain public
|
||||||
const providerRouter = express.Router({ mergeParams: true })
|
setupOpenAPIDocumentation(app)
|
||||||
providerRouter.use(authMiddleware)
|
|
||||||
// Mount provider-specific messages route
|
// Provider-specific messages route requires authentication
|
||||||
providerRouter.use('/v1/messages', messagesProviderRoutes)
|
app.use('/:provider/v1/messages', authMiddleware, extendMessagesTimeout, messagesProviderRoutes)
|
||||||
app.use('/:provider', providerRouter)
|
|
||||||
|
|
||||||
// API v1 routes with auth
|
// API v1 routes with auth
|
||||||
const apiRouter = express.Router()
|
const apiRouter = express.Router()
|
||||||
@ -126,14 +137,11 @@ apiRouter.use(authMiddleware)
|
|||||||
// Mount routes
|
// Mount routes
|
||||||
apiRouter.use('/chat', chatRoutes)
|
apiRouter.use('/chat', chatRoutes)
|
||||||
apiRouter.use('/mcps', mcpRoutes)
|
apiRouter.use('/mcps', mcpRoutes)
|
||||||
apiRouter.use('/messages', messagesRoutes)
|
apiRouter.use('/messages', extendMessagesTimeout, messagesRoutes)
|
||||||
apiRouter.use('/models', modelsRoutes)
|
apiRouter.use('/models', modelsRoutes)
|
||||||
apiRouter.use('/agents', agentsRoutes)
|
apiRouter.use('/agents', agentsRoutes)
|
||||||
app.use('/v1', apiRouter)
|
app.use('/v1', apiRouter)
|
||||||
|
|
||||||
// Setup OpenAPI documentation
|
|
||||||
setupOpenAPIDocumentation(app)
|
|
||||||
|
|
||||||
// Error handling (must be last)
|
// Error handling (must be last)
|
||||||
app.use(errorHandler)
|
app.use(errorHandler)
|
||||||
|
|
||||||
|
|||||||
@ -36,7 +36,7 @@ class ConfigManager {
|
|||||||
}
|
}
|
||||||
return this._config
|
return this._config
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.warn('Failed to load config from Redux, using defaults:', error)
|
logger.warn('Failed to load config from Redux, using defaults', { error })
|
||||||
this._config = {
|
this._config = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
port: defaultPort,
|
port: defaultPort,
|
||||||
|
|||||||
@ -6,7 +6,7 @@ const logger = loggerService.withContext('ApiServerErrorHandler')
|
|||||||
|
|
||||||
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
|
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
export const errorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
export const errorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
logger.error('API Server Error:', err)
|
logger.error('API server error', { error: err })
|
||||||
|
|
||||||
// Don't expose internal errors in production
|
// Don't expose internal errors in production
|
||||||
const isDev = process.env.NODE_ENV === 'development'
|
const isDev = process.env.NODE_ENV === 'development'
|
||||||
|
|||||||
@ -197,10 +197,11 @@ export function setupOpenAPIDocumentation(app: Express) {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info('OpenAPI documentation setup complete')
|
logger.info('OpenAPI documentation ready', {
|
||||||
logger.info('Documentation available at /api-docs')
|
docsPath: '/api-docs',
|
||||||
logger.info('OpenAPI spec available at /api-docs.json')
|
specPath: '/api-docs.json'
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to setup OpenAPI documentation:', error as Error)
|
logger.error('Failed to setup OpenAPI documentation', { error })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,18 +51,18 @@ const modelValidationErrorBody = (error: AgentModelValidationError) => ({
|
|||||||
*/
|
*/
|
||||||
export const createAgent = async (req: Request, res: Response): Promise<Response> => {
|
export const createAgent = async (req: Request, res: Response): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
logger.info('Creating new agent')
|
logger.debug('Creating agent')
|
||||||
logger.debug('Agent data:', req.body)
|
logger.debug('Agent payload', { body: req.body })
|
||||||
|
|
||||||
const agent = await agentService.createAgent(req.body)
|
const agent = await agentService.createAgent(req.body)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info(`Agent created successfully: ${agent.id}`)
|
logger.info('Agent created', { agentId: agent.id })
|
||||||
logger.info(`Creating default session for new agent: ${agent.id}`)
|
logger.debug('Creating default session for agent', { agentId: agent.id })
|
||||||
|
|
||||||
await sessionService.createSession(agent.id, {})
|
await sessionService.createSession(agent.id, {})
|
||||||
|
|
||||||
logger.info(`Default session created for agent: ${agent.id}`)
|
logger.info('Default session created for agent', { agentId: agent.id })
|
||||||
return res.status(201).json(agent)
|
return res.status(201).json(agent)
|
||||||
} catch (sessionError: any) {
|
} catch (sessionError: any) {
|
||||||
logger.error('Failed to create default session for new agent, rolling back agent creation', {
|
logger.error('Failed to create default session for new agent, rolling back agent creation', {
|
||||||
@ -89,7 +89,7 @@ export const createAgent = async (req: Request, res: Response): Promise<Response
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error instanceof AgentModelValidationError) {
|
if (error instanceof AgentModelValidationError) {
|
||||||
logger.warn('Agent model validation error during create:', {
|
logger.warn('Agent model validation error during create', {
|
||||||
agentType: error.context.agentType,
|
agentType: error.context.agentType,
|
||||||
field: error.context.field,
|
field: error.context.field,
|
||||||
model: error.context.model,
|
model: error.context.model,
|
||||||
@ -98,7 +98,7 @@ export const createAgent = async (req: Request, res: Response): Promise<Response
|
|||||||
return res.status(400).json(modelValidationErrorBody(error))
|
return res.status(400).json(modelValidationErrorBody(error))
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error('Error creating agent:', error)
|
logger.error('Error creating agent', { error })
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: `Failed to create agent: ${error.message}`,
|
message: `Failed to create agent: ${error.message}`,
|
||||||
@ -171,11 +171,16 @@ export const listAgents = async (req: Request, res: Response): Promise<Response>
|
|||||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20
|
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20
|
||||||
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0
|
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0
|
||||||
|
|
||||||
logger.info(`Listing agents with limit=${limit}, offset=${offset}`)
|
logger.debug('Listing agents', { limit, offset })
|
||||||
|
|
||||||
const result = await agentService.listAgents({ limit, offset })
|
const result = await agentService.listAgents({ limit, offset })
|
||||||
|
|
||||||
logger.info(`Retrieved ${result.agents.length} agents (total: ${result.total})`)
|
logger.info('Agents listed', {
|
||||||
|
returned: result.agents.length,
|
||||||
|
total: result.total,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
})
|
||||||
return res.json({
|
return res.json({
|
||||||
data: result.agents,
|
data: result.agents,
|
||||||
total: result.total,
|
total: result.total,
|
||||||
@ -183,7 +188,7 @@ export const listAgents = async (req: Request, res: Response): Promise<Response>
|
|||||||
offset
|
offset
|
||||||
} satisfies ListAgentsResponse)
|
} satisfies ListAgentsResponse)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error listing agents:', error)
|
logger.error('Error listing agents', { error })
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Failed to list agents',
|
message: 'Failed to list agents',
|
||||||
@ -231,12 +236,12 @@ export const listAgents = async (req: Request, res: Response): Promise<Response>
|
|||||||
export const getAgent = async (req: Request, res: Response): Promise<Response> => {
|
export const getAgent = async (req: Request, res: Response): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const { agentId } = req.params
|
const { agentId } = req.params
|
||||||
logger.info(`Getting agent: ${agentId}`)
|
logger.debug('Getting agent', { agentId })
|
||||||
|
|
||||||
const agent = await agentService.getAgent(agentId)
|
const agent = await agentService.getAgent(agentId)
|
||||||
|
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
logger.warn(`Agent not found: ${agentId}`)
|
logger.warn('Agent not found', { agentId })
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Agent not found',
|
message: 'Agent not found',
|
||||||
@ -246,10 +251,10 @@ export const getAgent = async (req: Request, res: Response): Promise<Response> =
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Agent retrieved successfully: ${agentId}`)
|
logger.info('Agent retrieved', { agentId })
|
||||||
return res.json(agent)
|
return res.json(agent)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error getting agent:', error)
|
logger.error('Error getting agent', { error, agentId: req.params.agentId })
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Failed to get agent',
|
message: 'Failed to get agent',
|
||||||
@ -309,8 +314,8 @@ export const getAgent = async (req: Request, res: Response): Promise<Response> =
|
|||||||
export const updateAgent = async (req: Request, res: Response): Promise<Response> => {
|
export const updateAgent = async (req: Request, res: Response): Promise<Response> => {
|
||||||
const { agentId } = req.params
|
const { agentId } = req.params
|
||||||
try {
|
try {
|
||||||
logger.info(`Updating agent: ${agentId}`)
|
logger.debug('Updating agent', { agentId })
|
||||||
logger.debug('Update data:', req.body)
|
logger.debug('Replace payload', { body: req.body })
|
||||||
|
|
||||||
const { validatedBody } = req as ValidationRequest
|
const { validatedBody } = req as ValidationRequest
|
||||||
const replacePayload = (validatedBody ?? {}) as ReplaceAgentRequest
|
const replacePayload = (validatedBody ?? {}) as ReplaceAgentRequest
|
||||||
@ -318,7 +323,7 @@ export const updateAgent = async (req: Request, res: Response): Promise<Response
|
|||||||
const agent = await agentService.updateAgent(agentId, replacePayload, { replace: true })
|
const agent = await agentService.updateAgent(agentId, replacePayload, { replace: true })
|
||||||
|
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
logger.warn(`Agent not found for update: ${agentId}`)
|
logger.warn('Agent not found for update', { agentId })
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Agent not found',
|
message: 'Agent not found',
|
||||||
@ -328,11 +333,11 @@ export const updateAgent = async (req: Request, res: Response): Promise<Response
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Agent updated successfully: ${agentId}`)
|
logger.info('Agent updated', { agentId })
|
||||||
return res.json(agent)
|
return res.json(agent)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error instanceof AgentModelValidationError) {
|
if (error instanceof AgentModelValidationError) {
|
||||||
logger.warn('Agent model validation error during update:', {
|
logger.warn('Agent model validation error during update', {
|
||||||
agentId,
|
agentId,
|
||||||
agentType: error.context.agentType,
|
agentType: error.context.agentType,
|
||||||
field: error.context.field,
|
field: error.context.field,
|
||||||
@ -342,7 +347,7 @@ export const updateAgent = async (req: Request, res: Response): Promise<Response
|
|||||||
return res.status(400).json(modelValidationErrorBody(error))
|
return res.status(400).json(modelValidationErrorBody(error))
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error('Error updating agent:', error)
|
logger.error('Error updating agent', { error, agentId })
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Failed to update agent: ' + error.message,
|
message: 'Failed to update agent: ' + error.message,
|
||||||
@ -455,8 +460,8 @@ export const updateAgent = async (req: Request, res: Response): Promise<Response
|
|||||||
export const patchAgent = async (req: Request, res: Response): Promise<Response> => {
|
export const patchAgent = async (req: Request, res: Response): Promise<Response> => {
|
||||||
const { agentId } = req.params
|
const { agentId } = req.params
|
||||||
try {
|
try {
|
||||||
logger.info(`Partially updating agent: ${agentId}`)
|
logger.debug('Partially updating agent', { agentId })
|
||||||
logger.debug('Partial update data:', req.body)
|
logger.debug('Patch payload', { body: req.body })
|
||||||
|
|
||||||
const { validatedBody } = req as ValidationRequest
|
const { validatedBody } = req as ValidationRequest
|
||||||
const updatePayload = (validatedBody ?? {}) as UpdateAgentRequest
|
const updatePayload = (validatedBody ?? {}) as UpdateAgentRequest
|
||||||
@ -464,7 +469,7 @@ export const patchAgent = async (req: Request, res: Response): Promise<Response>
|
|||||||
const agent = await agentService.updateAgent(agentId, updatePayload)
|
const agent = await agentService.updateAgent(agentId, updatePayload)
|
||||||
|
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
logger.warn(`Agent not found for partial update: ${agentId}`)
|
logger.warn('Agent not found for partial update', { agentId })
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Agent not found',
|
message: 'Agent not found',
|
||||||
@ -474,11 +479,11 @@ export const patchAgent = async (req: Request, res: Response): Promise<Response>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Agent partially updated successfully: ${agentId}`)
|
logger.info('Agent patched', { agentId })
|
||||||
return res.json(agent)
|
return res.json(agent)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error instanceof AgentModelValidationError) {
|
if (error instanceof AgentModelValidationError) {
|
||||||
logger.warn('Agent model validation error during partial update:', {
|
logger.warn('Agent model validation error during partial update', {
|
||||||
agentId,
|
agentId,
|
||||||
agentType: error.context.agentType,
|
agentType: error.context.agentType,
|
||||||
field: error.context.field,
|
field: error.context.field,
|
||||||
@ -488,7 +493,7 @@ export const patchAgent = async (req: Request, res: Response): Promise<Response>
|
|||||||
return res.status(400).json(modelValidationErrorBody(error))
|
return res.status(400).json(modelValidationErrorBody(error))
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error('Error partially updating agent:', error)
|
logger.error('Error partially updating agent', { error, agentId })
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: `Failed to partially update agent: ${error.message}`,
|
message: `Failed to partially update agent: ${error.message}`,
|
||||||
@ -532,12 +537,12 @@ export const patchAgent = async (req: Request, res: Response): Promise<Response>
|
|||||||
export const deleteAgent = async (req: Request, res: Response): Promise<Response> => {
|
export const deleteAgent = async (req: Request, res: Response): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const { agentId } = req.params
|
const { agentId } = req.params
|
||||||
logger.info(`Deleting agent: ${agentId}`)
|
logger.debug('Deleting agent', { agentId })
|
||||||
|
|
||||||
const deleted = await agentService.deleteAgent(agentId)
|
const deleted = await agentService.deleteAgent(agentId)
|
||||||
|
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
logger.warn(`Agent not found for deletion: ${agentId}`)
|
logger.warn('Agent not found for deletion', { agentId })
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Agent not found',
|
message: 'Agent not found',
|
||||||
@ -547,10 +552,10 @@ export const deleteAgent = async (req: Request, res: Response): Promise<Response
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Agent deleted successfully: ${agentId}`)
|
logger.info('Agent deleted', { agentId })
|
||||||
return res.status(204).send()
|
return res.status(204).send()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error deleting agent:', error)
|
logger.error('Error deleting agent', { error, agentId: req.params.agentId })
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Failed to delete agent',
|
message: 'Failed to delete agent',
|
||||||
|
|||||||
@ -32,8 +32,8 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
|
|||||||
|
|
||||||
const messageData = req.body
|
const messageData = req.body
|
||||||
|
|
||||||
logger.info(`Creating streaming message for session: ${sessionId}`)
|
logger.info('Creating streaming message', { agentId, sessionId })
|
||||||
logger.debug('Streaming message data:', messageData)
|
logger.debug('Streaming message payload', { messageData })
|
||||||
|
|
||||||
// Set SSE headers
|
// Set SSE headers
|
||||||
res.setHeader('Content-Type', 'text/event-stream')
|
res.setHeader('Content-Type', 'text/event-stream')
|
||||||
@ -68,7 +68,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
|
|||||||
// res.write('data: {"type":"finish"}\n\n')
|
// res.write('data: {"type":"finish"}\n\n')
|
||||||
res.write('data: [DONE]\n\n')
|
res.write('data: [DONE]\n\n')
|
||||||
} catch (writeError) {
|
} catch (writeError) {
|
||||||
logger.error('Error writing final sentinel to SSE stream:', { error: writeError as Error })
|
logger.error('Error writing final sentinel to SSE stream', { error: writeError as Error })
|
||||||
}
|
}
|
||||||
res.end()
|
res.end()
|
||||||
}
|
}
|
||||||
@ -94,7 +94,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
|
|||||||
*/
|
*/
|
||||||
const handleDisconnect = () => {
|
const handleDisconnect = () => {
|
||||||
if (responseEnded) return
|
if (responseEnded) return
|
||||||
logger.info(`Client disconnected from streaming message for session: ${sessionId}`)
|
logger.info('Streaming client disconnected', { agentId, sessionId })
|
||||||
responseEnded = true
|
responseEnded = true
|
||||||
abortController.abort('Client disconnected')
|
abortController.abort('Client disconnected')
|
||||||
reader.cancel('Client disconnected').catch(() => {})
|
reader.cancel('Client disconnected').catch(() => {})
|
||||||
@ -119,7 +119,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
|
|||||||
finalizeResponse()
|
finalizeResponse()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (responseEnded) return
|
if (responseEnded) return
|
||||||
logger.error('Error reading agent stream:', { error })
|
logger.error('Error reading agent stream', { error })
|
||||||
try {
|
try {
|
||||||
res.write(
|
res.write(
|
||||||
`data: ${JSON.stringify({
|
`data: ${JSON.stringify({
|
||||||
@ -132,7 +132,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
|
|||||||
})}\n\n`
|
})}\n\n`
|
||||||
)
|
)
|
||||||
} catch (writeError) {
|
} catch (writeError) {
|
||||||
logger.error('Error writing stream error to SSE:', { error: writeError })
|
logger.error('Error writing stream error to SSE', { error: writeError })
|
||||||
}
|
}
|
||||||
responseEnded = true
|
responseEnded = true
|
||||||
res.end()
|
res.end()
|
||||||
@ -140,7 +140,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
pumpStream().catch((error) => {
|
pumpStream().catch((error) => {
|
||||||
logger.error('Pump stream failure:', { error })
|
logger.error('Pump stream failure', { error })
|
||||||
})
|
})
|
||||||
|
|
||||||
completion
|
completion
|
||||||
@ -150,7 +150,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (responseEnded) return
|
if (responseEnded) return
|
||||||
logger.error(`Streaming message error for session: ${sessionId}:`, error)
|
logger.error('Streaming message error', { agentId, sessionId, error })
|
||||||
try {
|
try {
|
||||||
res.write(
|
res.write(
|
||||||
`data: ${JSON.stringify({
|
`data: ${JSON.stringify({
|
||||||
@ -163,7 +163,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
|
|||||||
})}\n\n`
|
})}\n\n`
|
||||||
)
|
)
|
||||||
} catch (writeError) {
|
} catch (writeError) {
|
||||||
logger.error('Error writing completion error to SSE stream:', { error: writeError })
|
logger.error('Error writing completion error to SSE stream', { error: writeError })
|
||||||
}
|
}
|
||||||
responseEnded = true
|
responseEnded = true
|
||||||
res.end()
|
res.end()
|
||||||
@ -173,7 +173,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
|
|||||||
const timeout = setTimeout(
|
const timeout = setTimeout(
|
||||||
() => {
|
() => {
|
||||||
if (!responseEnded) {
|
if (!responseEnded) {
|
||||||
logger.error(`Streaming message timeout for session: ${sessionId}`)
|
logger.error('Streaming message timeout', { agentId, sessionId })
|
||||||
try {
|
try {
|
||||||
res.write(
|
res.write(
|
||||||
`data: ${JSON.stringify({
|
`data: ${JSON.stringify({
|
||||||
@ -186,7 +186,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
|
|||||||
})}\n\n`
|
})}\n\n`
|
||||||
)
|
)
|
||||||
} catch (writeError) {
|
} catch (writeError) {
|
||||||
logger.error('Error writing timeout to SSE stream:', { error: writeError })
|
logger.error('Error writing timeout to SSE stream', { error: writeError })
|
||||||
}
|
}
|
||||||
abortController.abort('stream timeout')
|
abortController.abort('stream timeout')
|
||||||
reader.cancel('stream timeout').catch(() => {})
|
reader.cancel('stream timeout').catch(() => {})
|
||||||
@ -201,7 +201,11 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
|
|||||||
res.on('close', () => clearTimeout(timeout))
|
res.on('close', () => clearTimeout(timeout))
|
||||||
res.on('finish', () => clearTimeout(timeout))
|
res.on('finish', () => clearTimeout(timeout))
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error in streaming message handler:', error)
|
logger.error('Error in streaming message handler', {
|
||||||
|
error,
|
||||||
|
agentId: req.params.agentId,
|
||||||
|
sessionId: req.params.sessionId
|
||||||
|
})
|
||||||
|
|
||||||
// Send error as SSE if possible
|
// Send error as SSE if possible
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
@ -222,7 +226,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
|
|||||||
|
|
||||||
res.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
|
res.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
|
||||||
} catch (writeError) {
|
} catch (writeError) {
|
||||||
logger.error('Error writing initial error to SSE stream:', { error: writeError })
|
logger.error('Error writing initial error to SSE stream', { error: writeError })
|
||||||
}
|
}
|
||||||
|
|
||||||
res.end()
|
res.end()
|
||||||
@ -239,7 +243,7 @@ export const deleteMessage = async (req: Request, res: Response): Promise<Respon
|
|||||||
const deleted = await sessionMessageService.deleteSessionMessage(sessionId, messageId)
|
const deleted = await sessionMessageService.deleteSessionMessage(sessionId, messageId)
|
||||||
|
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
logger.warn(`Message ${messageId} not found for session ${sessionId}`)
|
logger.warn('Session message not found', { agentId, sessionId, messageId })
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Message not found for this session',
|
message: 'Message not found for this session',
|
||||||
@ -249,7 +253,7 @@ export const deleteMessage = async (req: Request, res: Response): Promise<Respon
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Message ${messageId} deleted successfully for session ${sessionId}`)
|
logger.info('Session message deleted', { agentId, sessionId, messageId })
|
||||||
return res.status(204).send()
|
return res.status(204).send()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.status === 404) {
|
if (error?.status === 404) {
|
||||||
@ -268,7 +272,12 @@ export const deleteMessage = async (req: Request, res: Response): Promise<Respon
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error('Error deleting session message:', error)
|
logger.error('Error deleting session message', {
|
||||||
|
error,
|
||||||
|
agentId: req.params.agentId,
|
||||||
|
sessionId: req.params.sessionId,
|
||||||
|
messageId: Number(req.params.messageId)
|
||||||
|
})
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Failed to delete session message',
|
message: 'Failed to delete session message',
|
||||||
|
|||||||
@ -20,16 +20,16 @@ export const createSession = async (req: Request, res: Response): Promise<Respon
|
|||||||
try {
|
try {
|
||||||
const sessionData = req.body
|
const sessionData = req.body
|
||||||
|
|
||||||
logger.info(`Creating new session for agent: ${agentId}`)
|
logger.debug('Creating new session', { agentId })
|
||||||
logger.debug('Session data:', sessionData)
|
logger.debug('Session payload', { sessionData })
|
||||||
|
|
||||||
const session = await sessionService.createSession(agentId, sessionData)
|
const session = await sessionService.createSession(agentId, sessionData)
|
||||||
|
|
||||||
logger.info(`Session created successfully: ${session?.id}`)
|
logger.info('Session created', { agentId, sessionId: session?.id })
|
||||||
return res.status(201).json(session)
|
return res.status(201).json(session)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error instanceof AgentModelValidationError) {
|
if (error instanceof AgentModelValidationError) {
|
||||||
logger.warn('Session model validation error during create:', {
|
logger.warn('Session model validation error during create', {
|
||||||
agentId,
|
agentId,
|
||||||
agentType: error.context.agentType,
|
agentType: error.context.agentType,
|
||||||
field: error.context.field,
|
field: error.context.field,
|
||||||
@ -39,7 +39,7 @@ export const createSession = async (req: Request, res: Response): Promise<Respon
|
|||||||
return res.status(400).json(modelValidationErrorBody(error))
|
return res.status(400).json(modelValidationErrorBody(error))
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error('Error creating session:', error)
|
logger.error('Error creating session', { error, agentId })
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: `Failed to create session: ${error.message}`,
|
message: `Failed to create session: ${error.message}`,
|
||||||
@ -51,17 +51,23 @@ export const createSession = async (req: Request, res: Response): Promise<Respon
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const listSessions = async (req: Request, res: Response): Promise<Response> => {
|
export const listSessions = async (req: Request, res: Response): Promise<Response> => {
|
||||||
|
const { agentId } = req.params
|
||||||
try {
|
try {
|
||||||
const { agentId } = req.params
|
|
||||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20
|
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20
|
||||||
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0
|
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0
|
||||||
const status = req.query.status as any
|
const status = req.query.status as any
|
||||||
|
|
||||||
logger.info(`Listing sessions for agent: ${agentId} with limit=${limit}, offset=${offset}, status=${status}`)
|
logger.debug('Listing agent sessions', { agentId, limit, offset, status })
|
||||||
|
|
||||||
const result = await sessionService.listSessions(agentId, { limit, offset })
|
const result = await sessionService.listSessions(agentId, { limit, offset })
|
||||||
|
|
||||||
logger.info(`Retrieved ${result.sessions.length} sessions (total: ${result.total}) for agent: ${agentId}`)
|
logger.info('Agent sessions listed', {
|
||||||
|
agentId,
|
||||||
|
returned: result.sessions.length,
|
||||||
|
total: result.total,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
})
|
||||||
return res.json({
|
return res.json({
|
||||||
data: result.sessions,
|
data: result.sessions,
|
||||||
total: result.total,
|
total: result.total,
|
||||||
@ -69,7 +75,7 @@ export const listSessions = async (req: Request, res: Response): Promise<Respons
|
|||||||
offset
|
offset
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error listing sessions:', error)
|
logger.error('Error listing sessions', { error, agentId })
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Failed to list sessions',
|
message: 'Failed to list sessions',
|
||||||
@ -83,12 +89,12 @@ export const listSessions = async (req: Request, res: Response): Promise<Respons
|
|||||||
export const getSession = async (req: Request, res: Response): Promise<Response> => {
|
export const getSession = async (req: Request, res: Response): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const { agentId, sessionId } = req.params
|
const { agentId, sessionId } = req.params
|
||||||
logger.info(`Getting session: ${sessionId} for agent: ${agentId}`)
|
logger.debug('Getting session', { agentId, sessionId })
|
||||||
|
|
||||||
const session = await sessionService.getSession(agentId, sessionId)
|
const session = await sessionService.getSession(agentId, sessionId)
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
logger.warn(`Session not found: ${sessionId}`)
|
logger.warn('Session not found', { agentId, sessionId })
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Session not found',
|
message: 'Session not found',
|
||||||
@ -110,7 +116,7 @@ export const getSession = async (req: Request, res: Response): Promise<Response>
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
// Fetch session messages
|
// Fetch session messages
|
||||||
logger.info(`Fetching messages for session: ${sessionId}`)
|
logger.debug('Fetching session messages', { sessionId })
|
||||||
const { messages } = await sessionMessageService.listSessionMessages(sessionId)
|
const { messages } = await sessionMessageService.listSessionMessages(sessionId)
|
||||||
|
|
||||||
// Add messages to session
|
// Add messages to session
|
||||||
@ -119,10 +125,10 @@ export const getSession = async (req: Request, res: Response): Promise<Response>
|
|||||||
messages: messages
|
messages: messages
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Session retrieved successfully: ${sessionId} with ${messages.length} messages`)
|
logger.info('Session retrieved', { agentId, sessionId, messageCount: messages.length })
|
||||||
return res.json(sessionWithMessages)
|
return res.json(sessionWithMessages)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error getting session:', error)
|
logger.error('Error getting session', { error, agentId: req.params.agentId, sessionId: req.params.sessionId })
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Failed to get session',
|
message: 'Failed to get session',
|
||||||
@ -136,13 +142,13 @@ export const getSession = async (req: Request, res: Response): Promise<Response>
|
|||||||
export const updateSession = async (req: Request, res: Response): Promise<Response> => {
|
export const updateSession = async (req: Request, res: Response): Promise<Response> => {
|
||||||
const { agentId, sessionId } = req.params
|
const { agentId, sessionId } = req.params
|
||||||
try {
|
try {
|
||||||
logger.info(`Updating session: ${sessionId} for agent: ${agentId}`)
|
logger.debug('Updating session', { agentId, sessionId })
|
||||||
logger.debug('Update data:', req.body)
|
logger.debug('Replace payload', { body: req.body })
|
||||||
|
|
||||||
// First check if session exists and belongs to agent
|
// First check if session exists and belongs to agent
|
||||||
const existingSession = await sessionService.getSession(agentId, sessionId)
|
const existingSession = await sessionService.getSession(agentId, sessionId)
|
||||||
if (!existingSession || existingSession.agent_id !== agentId) {
|
if (!existingSession || existingSession.agent_id !== agentId) {
|
||||||
logger.warn(`Session ${sessionId} not found for agent ${agentId}`)
|
logger.warn('Session not found for update', { agentId, sessionId })
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Session not found for this agent',
|
message: 'Session not found for this agent',
|
||||||
@ -158,7 +164,7 @@ export const updateSession = async (req: Request, res: Response): Promise<Respon
|
|||||||
const session = await sessionService.updateSession(agentId, sessionId, replacePayload)
|
const session = await sessionService.updateSession(agentId, sessionId, replacePayload)
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
logger.warn(`Session not found for update: ${sessionId}`)
|
logger.warn('Session missing during update', { agentId, sessionId })
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Session not found',
|
message: 'Session not found',
|
||||||
@ -168,11 +174,11 @@ export const updateSession = async (req: Request, res: Response): Promise<Respon
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Session updated successfully: ${sessionId}`)
|
logger.info('Session updated', { agentId, sessionId })
|
||||||
return res.json(session satisfies UpdateSessionResponse)
|
return res.json(session satisfies UpdateSessionResponse)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error instanceof AgentModelValidationError) {
|
if (error instanceof AgentModelValidationError) {
|
||||||
logger.warn('Session model validation error during update:', {
|
logger.warn('Session model validation error during update', {
|
||||||
agentId,
|
agentId,
|
||||||
sessionId,
|
sessionId,
|
||||||
agentType: error.context.agentType,
|
agentType: error.context.agentType,
|
||||||
@ -183,7 +189,7 @@ export const updateSession = async (req: Request, res: Response): Promise<Respon
|
|||||||
return res.status(400).json(modelValidationErrorBody(error))
|
return res.status(400).json(modelValidationErrorBody(error))
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error('Error updating session:', error)
|
logger.error('Error updating session', { error, agentId, sessionId })
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: `Failed to update session: ${error.message}`,
|
message: `Failed to update session: ${error.message}`,
|
||||||
@ -197,13 +203,13 @@ export const updateSession = async (req: Request, res: Response): Promise<Respon
|
|||||||
export const patchSession = async (req: Request, res: Response): Promise<Response> => {
|
export const patchSession = async (req: Request, res: Response): Promise<Response> => {
|
||||||
const { agentId, sessionId } = req.params
|
const { agentId, sessionId } = req.params
|
||||||
try {
|
try {
|
||||||
logger.info(`Patching session: ${sessionId} for agent: ${agentId}`)
|
logger.debug('Patching session', { agentId, sessionId })
|
||||||
logger.debug('Patch data:', req.body)
|
logger.debug('Patch payload', { body: req.body })
|
||||||
|
|
||||||
// First check if session exists and belongs to agent
|
// First check if session exists and belongs to agent
|
||||||
const existingSession = await sessionService.getSession(agentId, sessionId)
|
const existingSession = await sessionService.getSession(agentId, sessionId)
|
||||||
if (!existingSession || existingSession.agent_id !== agentId) {
|
if (!existingSession || existingSession.agent_id !== agentId) {
|
||||||
logger.warn(`Session ${sessionId} not found for agent ${agentId}`)
|
logger.warn('Session not found for patch', { agentId, sessionId })
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Session not found for this agent',
|
message: 'Session not found for this agent',
|
||||||
@ -217,7 +223,7 @@ export const patchSession = async (req: Request, res: Response): Promise<Respons
|
|||||||
const session = await sessionService.updateSession(agentId, sessionId, updateSession)
|
const session = await sessionService.updateSession(agentId, sessionId, updateSession)
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
logger.warn(`Session not found for patch: ${sessionId}`)
|
logger.warn('Session missing while patching', { agentId, sessionId })
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Session not found',
|
message: 'Session not found',
|
||||||
@ -227,11 +233,11 @@ export const patchSession = async (req: Request, res: Response): Promise<Respons
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Session patched successfully: ${sessionId}`)
|
logger.info('Session patched', { agentId, sessionId })
|
||||||
return res.json(session)
|
return res.json(session)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error instanceof AgentModelValidationError) {
|
if (error instanceof AgentModelValidationError) {
|
||||||
logger.warn('Session model validation error during patch:', {
|
logger.warn('Session model validation error during patch', {
|
||||||
agentId,
|
agentId,
|
||||||
sessionId,
|
sessionId,
|
||||||
agentType: error.context.agentType,
|
agentType: error.context.agentType,
|
||||||
@ -242,7 +248,7 @@ export const patchSession = async (req: Request, res: Response): Promise<Respons
|
|||||||
return res.status(400).json(modelValidationErrorBody(error))
|
return res.status(400).json(modelValidationErrorBody(error))
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error('Error patching session:', error)
|
logger.error('Error patching session', { error, agentId, sessionId })
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: `Failed to patch session, ${error.message}`,
|
message: `Failed to patch session, ${error.message}`,
|
||||||
@ -256,12 +262,12 @@ export const patchSession = async (req: Request, res: Response): Promise<Respons
|
|||||||
export const deleteSession = async (req: Request, res: Response): Promise<Response> => {
|
export const deleteSession = async (req: Request, res: Response): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const { agentId, sessionId } = req.params
|
const { agentId, sessionId } = req.params
|
||||||
logger.info(`Deleting session: ${sessionId} for agent: ${agentId}`)
|
logger.debug('Deleting session', { agentId, sessionId })
|
||||||
|
|
||||||
// First check if session exists and belongs to agent
|
// First check if session exists and belongs to agent
|
||||||
const existingSession = await sessionService.getSession(agentId, sessionId)
|
const existingSession = await sessionService.getSession(agentId, sessionId)
|
||||||
if (!existingSession || existingSession.agent_id !== agentId) {
|
if (!existingSession || existingSession.agent_id !== agentId) {
|
||||||
logger.warn(`Session ${sessionId} not found for agent ${agentId}`)
|
logger.warn('Session not found for deletion', { agentId, sessionId })
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Session not found for this agent',
|
message: 'Session not found for this agent',
|
||||||
@ -274,7 +280,7 @@ export const deleteSession = async (req: Request, res: Response): Promise<Respon
|
|||||||
const deleted = await sessionService.deleteSession(agentId, sessionId)
|
const deleted = await sessionService.deleteSession(agentId, sessionId)
|
||||||
|
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
logger.warn(`Session not found for deletion: ${sessionId}`)
|
logger.warn('Session missing during delete', { agentId, sessionId })
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Session not found',
|
message: 'Session not found',
|
||||||
@ -284,15 +290,15 @@ export const deleteSession = async (req: Request, res: Response): Promise<Respon
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Session deleted successfully: ${sessionId}`)
|
logger.info('Session deleted', { agentId, sessionId })
|
||||||
|
|
||||||
const { total } = await sessionService.listSessions(agentId, { limit: 1 })
|
const { total } = await sessionService.listSessions(agentId, { limit: 1 })
|
||||||
|
|
||||||
if (total === 0) {
|
if (total === 0) {
|
||||||
logger.info(`No remaining sessions for agent ${agentId}, creating default session`)
|
logger.info('No remaining sessions, creating default', { agentId })
|
||||||
try {
|
try {
|
||||||
const fallbackSession = await sessionService.createSession(agentId, {})
|
const fallbackSession = await sessionService.createSession(agentId, {})
|
||||||
logger.info('Default session created after deleting last session', {
|
logger.info('Default session created after delete', {
|
||||||
agentId,
|
agentId,
|
||||||
sessionId: fallbackSession?.id
|
sessionId: fallbackSession?.id
|
||||||
})
|
})
|
||||||
@ -313,7 +319,7 @@ export const deleteSession = async (req: Request, res: Response): Promise<Respon
|
|||||||
|
|
||||||
return res.status(204).send()
|
return res.status(204).send()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error deleting session:', error)
|
logger.error('Error deleting session', { error, agentId: req.params.agentId, sessionId: req.params.sessionId })
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Failed to delete session',
|
message: 'Failed to delete session',
|
||||||
@ -331,11 +337,16 @@ export const listAllSessions = async (req: Request, res: Response): Promise<Resp
|
|||||||
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0
|
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0
|
||||||
const status = req.query.status as any
|
const status = req.query.status as any
|
||||||
|
|
||||||
logger.info(`Listing all sessions with limit=${limit}, offset=${offset}, status=${status}`)
|
logger.debug('Listing all sessions', { limit, offset, status })
|
||||||
|
|
||||||
const result = await sessionService.listSessions(undefined, { limit, offset })
|
const result = await sessionService.listSessions(undefined, { limit, offset })
|
||||||
|
|
||||||
logger.info(`Retrieved ${result.sessions.length} sessions (total: ${result.total})`)
|
logger.info('Sessions listed', {
|
||||||
|
returned: result.sessions.length,
|
||||||
|
total: result.total,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
})
|
||||||
return res.json({
|
return res.json({
|
||||||
data: result.sessions,
|
data: result.sessions,
|
||||||
total: result.total,
|
total: result.total,
|
||||||
@ -343,7 +354,7 @@ export const listAllSessions = async (req: Request, res: Response): Promise<Resp
|
|||||||
offset
|
offset
|
||||||
} satisfies ListAgentSessionsResponse)
|
} satisfies ListAgentSessionsResponse)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error listing all sessions:', error)
|
logger.error('Error listing all sessions', { error })
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Failed to list sessions',
|
message: 'Failed to list sessions',
|
||||||
|
|||||||
@ -29,7 +29,10 @@ export const checkAgentExists = async (req: Request, res: Response, next: any):
|
|||||||
|
|
||||||
next()
|
next()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error checking agent existence:', error as Error)
|
logger.error('Error checking agent existence', {
|
||||||
|
error: error as Error,
|
||||||
|
agentId: req.params.agentId
|
||||||
|
})
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Failed to validate agent',
|
message: 'Failed to validate agent',
|
||||||
|
|||||||
@ -22,7 +22,7 @@ interface ErrorResponseBody {
|
|||||||
|
|
||||||
const mapChatCompletionError = (error: unknown): { status: number; body: ErrorResponseBody } => {
|
const mapChatCompletionError = (error: unknown): { status: number; body: ErrorResponseBody } => {
|
||||||
if (error instanceof ChatCompletionValidationError) {
|
if (error instanceof ChatCompletionValidationError) {
|
||||||
logger.warn('Chat completion validation error:', {
|
logger.warn('Chat completion validation error', {
|
||||||
errors: error.errors
|
errors: error.errors
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -39,7 +39,7 @@ const mapChatCompletionError = (error: unknown): { status: number; body: ErrorRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof ChatCompletionModelError) {
|
if (error instanceof ChatCompletionModelError) {
|
||||||
logger.warn('Chat completion model error:', error.error)
|
logger.warn('Chat completion model error', error.error)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
@ -72,7 +72,7 @@ const mapChatCompletionError = (error: unknown): { status: number; body: ErrorRe
|
|||||||
errorCode = 'upstream_error'
|
errorCode = 'upstream_error'
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error('Chat completion error:', { error })
|
logger.error('Chat completion error', { error })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: statusCode,
|
status: statusCode,
|
||||||
@ -86,7 +86,7 @@ const mapChatCompletionError = (error: unknown): { status: number; body: ErrorRe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error('Chat completion unknown error:', { error })
|
logger.error('Chat completion unknown error', { error })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 500,
|
status: 500,
|
||||||
@ -193,7 +193,7 @@ router.post('/completions', async (req: Request, res: Response) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Chat completion request:', {
|
logger.debug('Chat completion request', {
|
||||||
model: request.model,
|
model: request.model,
|
||||||
messageCount: request.messages?.length || 0,
|
messageCount: request.messages?.length || 0,
|
||||||
stream: request.stream,
|
stream: request.stream,
|
||||||
@ -217,7 +217,7 @@ router.post('/completions', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
res.write('data: [DONE]\n\n')
|
res.write('data: [DONE]\n\n')
|
||||||
} catch (streamError: any) {
|
} catch (streamError: any) {
|
||||||
logger.error('Stream error:', streamError)
|
logger.error('Stream error', { error: streamError })
|
||||||
res.write(
|
res.write(
|
||||||
`data: ${JSON.stringify({
|
`data: ${JSON.stringify({
|
||||||
error: {
|
error: {
|
||||||
|
|||||||
@ -43,14 +43,14 @@ const router = express.Router()
|
|||||||
*/
|
*/
|
||||||
router.get('/', async (req: Request, res: Response) => {
|
router.get('/', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
logger.info('Get all MCP servers request received')
|
logger.debug('Listing MCP servers')
|
||||||
const servers = await mcpApiService.getAllServers(req)
|
const servers = await mcpApiService.getAllServers(req)
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: servers
|
data: servers
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error fetching MCP servers:', error)
|
logger.error('Error fetching MCP servers', { error })
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
@ -103,10 +103,12 @@ router.get('/', async (req: Request, res: Response) => {
|
|||||||
*/
|
*/
|
||||||
router.get('/:server_id', async (req: Request, res: Response) => {
|
router.get('/:server_id', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
logger.info('Get MCP server info request received')
|
logger.debug('Get MCP server info request received', {
|
||||||
|
serverId: req.params.server_id
|
||||||
|
})
|
||||||
const server = await mcpApiService.getServerInfo(req.params.server_id)
|
const server = await mcpApiService.getServerInfo(req.params.server_id)
|
||||||
if (!server) {
|
if (!server) {
|
||||||
logger.warn('MCP server not found')
|
logger.warn('MCP server not found', { serverId: req.params.server_id })
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
@ -121,7 +123,7 @@ router.get('/:server_id', async (req: Request, res: Response) => {
|
|||||||
data: server
|
data: server
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error fetching MCP server info:', error)
|
logger.error('Error fetching MCP server info', { error, serverId: req.params.server_id })
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
@ -137,7 +139,7 @@ router.get('/:server_id', async (req: Request, res: Response) => {
|
|||||||
router.all('/:server_id/mcp', async (req: Request, res: Response) => {
|
router.all('/:server_id/mcp', async (req: Request, res: Response) => {
|
||||||
const server = await mcpApiService.getServerById(req.params.server_id)
|
const server = await mcpApiService.getServerById(req.params.server_id)
|
||||||
if (!server) {
|
if (!server) {
|
||||||
logger.warn('MCP server not found')
|
logger.warn('MCP server not found', { serverId: req.params.server_id })
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
|
|||||||
@ -12,7 +12,6 @@ const providerRouter = express.Router({ mergeParams: true })
|
|||||||
|
|
||||||
// Helper functions for shared logic
|
// Helper functions for shared logic
|
||||||
async function validateRequestBody(req: Request): Promise<{ valid: boolean; error?: any }> {
|
async function validateRequestBody(req: Request): Promise<{ valid: boolean; error?: any }> {
|
||||||
logger.info('Validating request body', { body: req.body })
|
|
||||||
const request: MessageCreateParams = req.body
|
const request: MessageCreateParams = req.body
|
||||||
|
|
||||||
if (!request) {
|
if (!request) {
|
||||||
@ -43,14 +42,30 @@ async function handleStreamingResponse(
|
|||||||
res.setHeader('Connection', 'keep-alive')
|
res.setHeader('Connection', 'keep-alive')
|
||||||
res.setHeader('X-Accel-Buffering', 'no')
|
res.setHeader('X-Accel-Buffering', 'no')
|
||||||
res.flushHeaders()
|
res.flushHeaders()
|
||||||
|
const flushableResponse = res as Response & { flush?: () => void }
|
||||||
|
const flushStream = () => {
|
||||||
|
if (typeof flushableResponse.flush !== 'function') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flushableResponse.flush()
|
||||||
|
} catch (flushError: unknown) {
|
||||||
|
logger.warn('Failed to flush streaming response', {
|
||||||
|
error: flushError
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const chunk of messagesService.processStreamingMessage(request, provider)) {
|
for await (const chunk of messagesService.processStreamingMessage(request, provider)) {
|
||||||
|
res.write(`event: ${chunk.type}\n`)
|
||||||
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||||
|
flushStream()
|
||||||
}
|
}
|
||||||
res.write('data: [DONE]\n\n')
|
res.write('data: [DONE]\n\n')
|
||||||
|
flushStream()
|
||||||
} catch (streamError: any) {
|
} catch (streamError: any) {
|
||||||
logger.error('Stream error:', streamError)
|
logger.error('Stream error', { error: streamError })
|
||||||
res.write(
|
res.write(
|
||||||
`data: ${JSON.stringify({
|
`data: ${JSON.stringify({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
@ -66,7 +81,7 @@ async function handleStreamingResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleErrorResponse(res: Response, error: any, logger: any): Response {
|
function handleErrorResponse(res: Response, error: any, logger: any): Response {
|
||||||
logger.error('Message processing error:', error)
|
logger.error('Message processing error', { error })
|
||||||
|
|
||||||
let statusCode = 500
|
let statusCode = 500
|
||||||
let errorType = 'api_error'
|
let errorType = 'api_error'
|
||||||
@ -303,7 +318,10 @@ router.post('/', async (req: Request, res: Response) => {
|
|||||||
const modelValidation = await validateModelId(request.model)
|
const modelValidation = await validateModelId(request.model)
|
||||||
if (!modelValidation.valid) {
|
if (!modelValidation.valid) {
|
||||||
const error = modelValidation.error!
|
const error = modelValidation.error!
|
||||||
logger.warn(`Model validation failed for '${request.model}':`, error)
|
logger.warn('Model validation failed', {
|
||||||
|
model: request.model,
|
||||||
|
error
|
||||||
|
})
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
error: {
|
error: {
|
||||||
|
|||||||
@ -75,13 +75,13 @@ const router = express
|
|||||||
*/
|
*/
|
||||||
.get('/', async (req: Request, res: Response) => {
|
.get('/', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
logger.info('Models list request received', { query: req.query })
|
logger.debug('Models list request received', { query: req.query })
|
||||||
|
|
||||||
// Validate query parameters using Zod schema
|
// Validate query parameters using Zod schema
|
||||||
const filterResult = ApiModelsFilterSchema.safeParse(req.query)
|
const filterResult = ApiModelsFilterSchema.safeParse(req.query)
|
||||||
|
|
||||||
if (!filterResult.success) {
|
if (!filterResult.success) {
|
||||||
logger.warn('Invalid query parameters:', filterResult.error.issues)
|
logger.warn('Invalid model query parameters', { issues: filterResult.error.issues })
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Invalid query parameters',
|
message: 'Invalid query parameters',
|
||||||
@ -99,24 +99,20 @@ const router = express
|
|||||||
const response = await modelsService.getModels(filter)
|
const response = await modelsService.getModels(filter)
|
||||||
|
|
||||||
if (response.data.length === 0) {
|
if (response.data.length === 0) {
|
||||||
logger.warn(
|
logger.warn('No models available from providers', { filter })
|
||||||
'No models available from providers. This may be because no OpenAI/Anthropic providers are configured or enabled.',
|
|
||||||
{ filter }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Returning ${response.data.length} models`, {
|
logger.info('Models response ready', {
|
||||||
filter,
|
filter,
|
||||||
total: response.total
|
total: response.total
|
||||||
})
|
})
|
||||||
logger.debug(
|
logger.debug('Model IDs returned', {
|
||||||
'Model IDs:',
|
modelIds: response.data.map((m) => m.id)
|
||||||
response.data.map((m) => m.id)
|
})
|
||||||
)
|
|
||||||
|
|
||||||
return res.json(response satisfies ApiModelsResponse)
|
return res.json(response satisfies ApiModelsResponse)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error fetching models:', error)
|
logger.error('Error fetching models', { error })
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Failed to retrieve models from available providers',
|
message: 'Failed to retrieve models from available providers',
|
||||||
|
|||||||
@ -7,6 +7,10 @@ import { config } from './config'
|
|||||||
|
|
||||||
const logger = loggerService.withContext('ApiServer')
|
const logger = loggerService.withContext('ApiServer')
|
||||||
|
|
||||||
|
const GLOBAL_REQUEST_TIMEOUT_MS = 5 * 60_000
|
||||||
|
const GLOBAL_HEADERS_TIMEOUT_MS = GLOBAL_REQUEST_TIMEOUT_MS + 5_000
|
||||||
|
const GLOBAL_KEEPALIVE_TIMEOUT_MS = 60_000
|
||||||
|
|
||||||
export class ApiServer {
|
export class ApiServer {
|
||||||
private server: ReturnType<typeof createServer> | null = null
|
private server: ReturnType<typeof createServer> | null = null
|
||||||
|
|
||||||
@ -17,21 +21,21 @@ export class ApiServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load config
|
// Load config
|
||||||
const { port, host, apiKey } = await config.load()
|
const { port, host } = await config.load()
|
||||||
|
|
||||||
// Initialize AgentService
|
// Initialize AgentService
|
||||||
logger.info('Initializing AgentService...')
|
logger.info('Initializing AgentService')
|
||||||
await agentService.initialize()
|
await agentService.initialize()
|
||||||
logger.info('AgentService initialized successfully')
|
logger.info('AgentService initialized')
|
||||||
|
|
||||||
// Create server with Express app
|
// Create server with Express app
|
||||||
this.server = createServer(app)
|
this.server = createServer(app)
|
||||||
|
this.applyServerTimeouts(this.server)
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.server!.listen(port, host, () => {
|
this.server!.listen(port, host, () => {
|
||||||
logger.info(`API Server started at http://${host}:${port}`)
|
logger.info('API server started', { host, port })
|
||||||
logger.info(`API Key: ${apiKey}`)
|
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -39,12 +43,19 @@ export class ApiServer {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private applyServerTimeouts(server: ReturnType<typeof createServer>): void {
|
||||||
|
server.requestTimeout = GLOBAL_REQUEST_TIMEOUT_MS
|
||||||
|
server.headersTimeout = Math.max(GLOBAL_HEADERS_TIMEOUT_MS, server.requestTimeout + 1_000)
|
||||||
|
server.keepAliveTimeout = GLOBAL_KEEPALIVE_TIMEOUT_MS
|
||||||
|
server.setTimeout(0)
|
||||||
|
}
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
if (!this.server) return
|
if (!this.server) return
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
this.server!.close(() => {
|
this.server!.close(() => {
|
||||||
logger.info('API Server stopped')
|
logger.info('API server stopped')
|
||||||
this.server = null
|
this.server = null
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
@ -62,7 +73,7 @@ export class ApiServer {
|
|||||||
const isListening = this.server?.listening || false
|
const isListening = this.server?.listening || false
|
||||||
const result = hasServer && isListening
|
const result = hasServer && isListening
|
||||||
|
|
||||||
logger.debug('isRunning check:', { hasServer, isListening, result })
|
logger.debug('isRunning check', { hasServer, isListening, result })
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@ -98,7 +98,7 @@ export class ChatCompletionService {
|
|||||||
|
|
||||||
const { provider, modelId, client } = providerContext
|
const { provider, modelId, client } = providerContext
|
||||||
|
|
||||||
logger.info('Model validation successful:', {
|
logger.debug('Model validation successful', {
|
||||||
provider: provider.id,
|
provider: provider.id,
|
||||||
providerType: provider.type,
|
providerType: provider.type,
|
||||||
modelId,
|
modelId,
|
||||||
@ -160,7 +160,7 @@ export class ChatCompletionService {
|
|||||||
response: OpenAI.Chat.Completions.ChatCompletion
|
response: OpenAI.Chat.Completions.ChatCompletion
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
logger.info('Processing chat completion request:', {
|
logger.debug('Processing chat completion request', {
|
||||||
model: request.model,
|
model: request.model,
|
||||||
messageCount: request.messages.length,
|
messageCount: request.messages.length,
|
||||||
stream: request.stream
|
stream: request.stream
|
||||||
@ -177,7 +177,7 @@ export class ChatCompletionService {
|
|||||||
|
|
||||||
const { provider, modelId, client, providerRequest } = preparation
|
const { provider, modelId, client, providerRequest } = preparation
|
||||||
|
|
||||||
logger.debug('Sending request to provider:', {
|
logger.debug('Sending request to provider', {
|
||||||
provider: provider.id,
|
provider: provider.id,
|
||||||
model: modelId,
|
model: modelId,
|
||||||
apiHost: provider.apiHost
|
apiHost: provider.apiHost
|
||||||
@ -185,14 +185,20 @@ export class ChatCompletionService {
|
|||||||
|
|
||||||
const response = (await client.chat.completions.create(providerRequest)) as OpenAI.Chat.Completions.ChatCompletion
|
const response = (await client.chat.completions.create(providerRequest)) as OpenAI.Chat.Completions.ChatCompletion
|
||||||
|
|
||||||
logger.info('Successfully processed chat completion')
|
logger.info('Chat completion processed', {
|
||||||
|
modelId,
|
||||||
|
provider: provider.id
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
provider,
|
provider,
|
||||||
modelId,
|
modelId,
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error processing chat completion:', error)
|
logger.error('Error processing chat completion', {
|
||||||
|
error,
|
||||||
|
model: request.model
|
||||||
|
})
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -203,7 +209,7 @@ export class ChatCompletionService {
|
|||||||
stream: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>
|
stream: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
logger.info('Processing streaming chat completion request:', {
|
logger.debug('Processing streaming chat completion request', {
|
||||||
model: request.model,
|
model: request.model,
|
||||||
messageCount: request.messages.length
|
messageCount: request.messages.length
|
||||||
})
|
})
|
||||||
@ -219,7 +225,7 @@ export class ChatCompletionService {
|
|||||||
|
|
||||||
const { provider, modelId, client, providerRequest } = preparation
|
const { provider, modelId, client, providerRequest } = preparation
|
||||||
|
|
||||||
logger.debug('Sending streaming request to provider:', {
|
logger.debug('Sending streaming request to provider', {
|
||||||
provider: provider.id,
|
provider: provider.id,
|
||||||
model: modelId,
|
model: modelId,
|
||||||
apiHost: provider.apiHost
|
apiHost: provider.apiHost
|
||||||
@ -230,14 +236,20 @@ export class ChatCompletionService {
|
|||||||
streamRequest
|
streamRequest
|
||||||
)) as AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>
|
)) as AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>
|
||||||
|
|
||||||
logger.info('Successfully started streaming chat completion')
|
logger.info('Streaming chat completion started', {
|
||||||
|
modelId,
|
||||||
|
provider: provider.id
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
provider,
|
provider,
|
||||||
modelId,
|
modelId,
|
||||||
stream
|
stream
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error processing streaming chat completion:', error)
|
logger.error('Error processing streaming chat completion', {
|
||||||
|
error,
|
||||||
|
model: request.model
|
||||||
|
})
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,7 +49,7 @@ class MCPApiService extends EventEmitter {
|
|||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.initMcpServer()
|
this.initMcpServer()
|
||||||
logger.silly('MCPApiService initialized')
|
logger.debug('MCPApiService initialized')
|
||||||
}
|
}
|
||||||
|
|
||||||
private initMcpServer() {
|
private initMcpServer() {
|
||||||
@ -60,7 +60,7 @@ class MCPApiService extends EventEmitter {
|
|||||||
async getAllServers(req: Request): Promise<McpServersResp> {
|
async getAllServers(req: Request): Promise<McpServersResp> {
|
||||||
try {
|
try {
|
||||||
const servers = await getMCPServersFromRedux()
|
const servers = await getMCPServersFromRedux()
|
||||||
logger.silly(`Returning ${servers.length} servers`)
|
logger.debug('Returning servers from Redux', { count: servers.length })
|
||||||
const resp: McpServersResp = {
|
const resp: McpServersResp = {
|
||||||
servers: {}
|
servers: {}
|
||||||
}
|
}
|
||||||
@ -77,7 +77,7 @@ class MCPApiService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
return resp
|
return resp
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to get all servers:', error)
|
logger.error('Failed to get all servers', { error })
|
||||||
throw new Error('Failed to retrieve servers')
|
throw new Error('Failed to retrieve servers')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -85,17 +85,17 @@ class MCPApiService extends EventEmitter {
|
|||||||
// get server by id
|
// get server by id
|
||||||
async getServerById(id: string): Promise<MCPServer | null> {
|
async getServerById(id: string): Promise<MCPServer | null> {
|
||||||
try {
|
try {
|
||||||
logger.silly(`getServerById called with id: ${id}`)
|
logger.debug('getServerById called', { id })
|
||||||
const servers = await getMCPServersFromRedux()
|
const servers = await getMCPServersFromRedux()
|
||||||
const server = servers.find((s) => s.id === id)
|
const server = servers.find((s) => s.id === id)
|
||||||
if (!server) {
|
if (!server) {
|
||||||
logger.warn(`Server with id ${id} not found`)
|
logger.warn('Server not found', { id })
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
logger.silly(`Returning server with id ${id}`)
|
logger.debug('Returning server', { id })
|
||||||
return server
|
return server
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`Failed to get server with id ${id}:`, error)
|
logger.error('Failed to get server', { id, error })
|
||||||
throw new Error('Failed to retrieve server')
|
throw new Error('Failed to retrieve server')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -104,7 +104,7 @@ class MCPApiService extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
const server = await this.getServerById(id)
|
const server = await this.getServerById(id)
|
||||||
if (!server) {
|
if (!server) {
|
||||||
logger.warn(`Server with id ${id} not found`)
|
logger.warn('Server not found while fetching info', { id })
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,14 +118,14 @@ class MCPApiService extends EventEmitter {
|
|||||||
tools: tools.tools
|
tools: tools.tools
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`Failed to get server info with id ${id}:`, error)
|
logger.error('Failed to get server info', { id, error })
|
||||||
throw new Error('Failed to retrieve server info')
|
throw new Error('Failed to retrieve server info')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleRequest(req: Request, res: Response, server: MCPServer) {
|
async handleRequest(req: Request, res: Response, server: MCPServer) {
|
||||||
const sessionId = req.headers['mcp-session-id'] as string | undefined
|
const sessionId = req.headers['mcp-session-id'] as string | undefined
|
||||||
logger.silly(`Handling request for server with sessionId ${sessionId}`)
|
logger.debug('Handling MCP request', { sessionId, serverId: server.id })
|
||||||
let transport: StreamableHTTPServerTransport
|
let transport: StreamableHTTPServerTransport
|
||||||
if (sessionId && transports[sessionId]) {
|
if (sessionId && transports[sessionId]) {
|
||||||
transport = transports[sessionId]
|
transport = transports[sessionId]
|
||||||
@ -138,7 +138,7 @@ class MCPApiService extends EventEmitter {
|
|||||||
})
|
})
|
||||||
|
|
||||||
transport.onclose = () => {
|
transport.onclose = () => {
|
||||||
logger.info(`Transport for sessionId ${sessionId} closed`)
|
logger.info('Transport closed', { sessionId })
|
||||||
if (transport.sessionId) {
|
if (transport.sessionId) {
|
||||||
delete transports[transport.sessionId]
|
delete transports[transport.sessionId]
|
||||||
}
|
}
|
||||||
@ -173,12 +173,15 @@ class MCPApiService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Request body`, { rawBody: req.body, messages: JSON.stringify(messages) })
|
logger.debug('Dispatching MCP request', {
|
||||||
|
sessionId: transport.sessionId ?? sessionId,
|
||||||
|
messageCount: messages.length
|
||||||
|
})
|
||||||
await transport.handleRequest(req as IncomingMessage, res as ServerResponse, messages)
|
await transport.handleRequest(req as IncomingMessage, res as ServerResponse, messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
private onMessage(message: JSONRPCMessage, extra?: MessageExtraInfo) {
|
private onMessage(message: JSONRPCMessage, extra?: MessageExtraInfo) {
|
||||||
logger.info(`Received message: ${JSON.stringify(message)}`, extra)
|
logger.debug('Received MCP message', { message, extra })
|
||||||
// Handle message here
|
// Handle message here
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,7 +58,11 @@ export class ModelsService {
|
|||||||
logger.debug(`Applied offset: offset=${offset}, showing ${modelData.length} of ${total} models`)
|
logger.debug(`Applied offset: offset=${offset}, showing ${modelData.length} of ${total} models`)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Successfully retrieved ${modelData.length} models from ${models.length} total models`)
|
logger.info('Models retrieved', {
|
||||||
|
returned: modelData.length,
|
||||||
|
discovered: models.length,
|
||||||
|
filter
|
||||||
|
})
|
||||||
|
|
||||||
if (models.length > total) {
|
if (models.length > total) {
|
||||||
logger.debug(`Filtered out ${models.length - total} models after deduplication and filtering`)
|
logger.debug(`Filtered out ${models.length - total} models after deduplication and filtering`)
|
||||||
@ -80,7 +84,7 @@ export class ModelsService {
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error getting models:', error)
|
logger.error('Error getting models', { error, filter })
|
||||||
return {
|
return {
|
||||||
object: 'list',
|
object: 'list',
|
||||||
data: []
|
data: []
|
||||||
|
|||||||
@ -14,14 +14,16 @@ export async function getAvailableProviders(): Promise<Provider[]> {
|
|||||||
// Try to get from cache first (faster)
|
// Try to get from cache first (faster)
|
||||||
const cachedSupportedProviders = CacheService.get<Provider[]>(PROVIDERS_CACHE_KEY)
|
const cachedSupportedProviders = CacheService.get<Provider[]>(PROVIDERS_CACHE_KEY)
|
||||||
if (cachedSupportedProviders) {
|
if (cachedSupportedProviders) {
|
||||||
logger.debug(`Found ${cachedSupportedProviders.length} supported providers (from cache)`)
|
logger.debug('Providers resolved from cache', {
|
||||||
|
count: cachedSupportedProviders.length
|
||||||
|
})
|
||||||
return cachedSupportedProviders
|
return cachedSupportedProviders
|
||||||
}
|
}
|
||||||
|
|
||||||
// If cache is not available, get fresh data from Redux
|
// If cache is not available, get fresh data from Redux
|
||||||
const providers = await reduxService.select('state.llm.providers')
|
const providers = await reduxService.select('state.llm.providers')
|
||||||
if (!providers || !Array.isArray(providers)) {
|
if (!providers || !Array.isArray(providers)) {
|
||||||
logger.warn('No providers found in Redux store, returning empty array')
|
logger.warn('No providers found in Redux store')
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,11 +35,14 @@ export async function getAvailableProviders(): Promise<Provider[]> {
|
|||||||
// Cache the filtered results
|
// Cache the filtered results
|
||||||
CacheService.set(PROVIDERS_CACHE_KEY, supportedProviders, PROVIDERS_CACHE_TTL)
|
CacheService.set(PROVIDERS_CACHE_KEY, supportedProviders, PROVIDERS_CACHE_TTL)
|
||||||
|
|
||||||
logger.info(`Filtered to ${supportedProviders.length} supported providers from ${providers.length} total providers`)
|
logger.info('Providers filtered', {
|
||||||
|
supported: supportedProviders.length,
|
||||||
|
total: providers.length
|
||||||
|
})
|
||||||
|
|
||||||
return supportedProviders
|
return supportedProviders
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to get providers from Redux store:', error)
|
logger.error('Failed to get providers from Redux store', { error })
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -47,7 +52,7 @@ export async function listAllAvailableModels(): Promise<Model[]> {
|
|||||||
const providers = await getAvailableProviders()
|
const providers = await getAvailableProviders()
|
||||||
return providers.map((p: Provider) => p.models || []).flat()
|
return providers.map((p: Provider) => p.models || []).flat()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to list available models:', error)
|
logger.error('Failed to list available models', { error })
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -55,15 +60,13 @@ export async function listAllAvailableModels(): Promise<Model[]> {
|
|||||||
export async function getProviderByModel(model: string): Promise<Provider | undefined> {
|
export async function getProviderByModel(model: string): Promise<Provider | undefined> {
|
||||||
try {
|
try {
|
||||||
if (!model || typeof model !== 'string') {
|
if (!model || typeof model !== 'string') {
|
||||||
logger.warn(`Invalid model parameter: ${model}`)
|
logger.warn('Invalid model parameter', { model })
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate model format first
|
// Validate model format first
|
||||||
if (!model.includes(':')) {
|
if (!model.includes(':')) {
|
||||||
logger.warn(
|
logger.warn('Invalid model format missing separator', { model })
|
||||||
`Invalid model format, must contain ':' separator. Expected format "provider:model_id", got: ${model}`
|
|
||||||
)
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,7 +74,7 @@ export async function getProviderByModel(model: string): Promise<Provider | unde
|
|||||||
const modelInfo = model.split(':')
|
const modelInfo = model.split(':')
|
||||||
|
|
||||||
if (modelInfo.length < 2 || modelInfo[0].length === 0 || modelInfo[1].length === 0) {
|
if (modelInfo.length < 2 || modelInfo[0].length === 0 || modelInfo[1].length === 0) {
|
||||||
logger.warn(`Invalid model format, expected "provider:model_id" with non-empty parts, got: ${model}`)
|
logger.warn('Invalid model format with empty parts', { model })
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,16 +82,17 @@ export async function getProviderByModel(model: string): Promise<Provider | unde
|
|||||||
const provider = providers.find((p: Provider) => p.id === providerId)
|
const provider = providers.find((p: Provider) => p.id === providerId)
|
||||||
|
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
logger.warn(
|
logger.warn('Provider not found for model', {
|
||||||
`Provider '${providerId}' not found or not enabled. Available providers: ${providers.map((p) => p.id).join(', ')}`
|
providerId,
|
||||||
)
|
available: providers.map((p) => p.id)
|
||||||
|
})
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`Found provider '${providerId}' for model: ${model}`)
|
logger.debug('Provider resolved for model', { providerId, model })
|
||||||
return provider
|
return provider
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to get provider by model:', error)
|
logger.error('Failed to get provider by model', { error, model })
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -176,7 +180,7 @@ export async function validateModelId(
|
|||||||
modelId
|
modelId
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error validating model ID:', error)
|
logger.error('Error validating model ID', { error, model })
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: {
|
error: {
|
||||||
@ -207,7 +211,7 @@ export function transformModelToOpenAI(model: Model, providers: Provider[]): Api
|
|||||||
export async function getProviderById(providerId: string): Promise<Provider | undefined> {
|
export async function getProviderById(providerId: string): Promise<Provider | undefined> {
|
||||||
try {
|
try {
|
||||||
if (!providerId || typeof providerId !== 'string') {
|
if (!providerId || typeof providerId !== 'string') {
|
||||||
logger.warn(`Invalid provider ID parameter: ${providerId}`)
|
logger.warn('Invalid provider ID parameter', { providerId })
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -215,16 +219,17 @@ export async function getProviderById(providerId: string): Promise<Provider | un
|
|||||||
const provider = providers.find((p: Provider) => p.id === providerId)
|
const provider = providers.find((p: Provider) => p.id === providerId)
|
||||||
|
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
logger.warn(
|
logger.warn('Provider not found by ID', {
|
||||||
`Provider '${providerId}' not found or not enabled. Available providers: ${providers.map((p) => p.id).join(', ')}`
|
providerId,
|
||||||
)
|
available: providers.map((p) => p.id)
|
||||||
|
})
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`Found provider '${providerId}'`)
|
logger.debug('Provider found by ID', { providerId })
|
||||||
return provider
|
return provider
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to get provider by ID:', error)
|
logger.error('Failed to get provider by ID', { error, providerId })
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -237,7 +242,7 @@ export function validateProvider(provider: Provider): boolean {
|
|||||||
|
|
||||||
// Check required fields
|
// Check required fields
|
||||||
if (!provider.id || !provider.type || !provider.apiKey || !provider.apiHost) {
|
if (!provider.id || !provider.type || !provider.apiKey || !provider.apiHost) {
|
||||||
logger.warn('Provider missing required fields:', {
|
logger.warn('Provider missing required fields', {
|
||||||
id: !!provider.id,
|
id: !!provider.id,
|
||||||
type: !!provider.type,
|
type: !!provider.type,
|
||||||
apiKey: !!provider.apiKey,
|
apiKey: !!provider.apiKey,
|
||||||
@ -248,21 +253,22 @@ export function validateProvider(provider: Provider): boolean {
|
|||||||
|
|
||||||
// Check if provider is enabled
|
// Check if provider is enabled
|
||||||
if (!provider.enabled) {
|
if (!provider.enabled) {
|
||||||
logger.debug(`Provider is disabled: ${provider.id}`)
|
logger.debug('Provider is disabled', { providerId: provider.id })
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Support OpenAI and Anthropic type providers
|
// Support OpenAI and Anthropic type providers
|
||||||
if (provider.type !== 'openai' && provider.type !== 'anthropic') {
|
if (provider.type !== 'openai' && provider.type !== 'anthropic') {
|
||||||
logger.debug(
|
logger.debug('Provider type not supported', {
|
||||||
`Provider type '${provider.type}' not supported, only 'openai' and 'anthropic' types are currently supported: ${provider.id}`
|
providerId: provider.id,
|
||||||
)
|
providerType: provider.type
|
||||||
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error validating provider:', error)
|
logger.error('Error validating provider', { error, providerId: provider?.id })
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,12 +47,12 @@ async function getMcpServerConfigById(id: string): Promise<MCPServer | undefined
|
|||||||
*/
|
*/
|
||||||
export async function getMCPServersFromRedux(): Promise<MCPServer[]> {
|
export async function getMCPServersFromRedux(): Promise<MCPServer[]> {
|
||||||
try {
|
try {
|
||||||
logger.silly('Getting servers from Redux store')
|
logger.debug('Getting servers from Redux store')
|
||||||
|
|
||||||
// Try to get from cache first (faster)
|
// Try to get from cache first (faster)
|
||||||
const cachedServers = CacheService.get<MCPServer[]>(MCP_SERVERS_CACHE_KEY)
|
const cachedServers = CacheService.get<MCPServer[]>(MCP_SERVERS_CACHE_KEY)
|
||||||
if (cachedServers) {
|
if (cachedServers) {
|
||||||
logger.silly(`Found ${cachedServers.length} servers (from cache)`)
|
logger.debug('MCP servers resolved from cache', { count: cachedServers.length })
|
||||||
return cachedServers
|
return cachedServers
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,10 +63,10 @@ export async function getMCPServersFromRedux(): Promise<MCPServer[]> {
|
|||||||
// Cache the results
|
// Cache the results
|
||||||
CacheService.set(MCP_SERVERS_CACHE_KEY, serverList, MCP_SERVERS_CACHE_TTL)
|
CacheService.set(MCP_SERVERS_CACHE_KEY, serverList, MCP_SERVERS_CACHE_TTL)
|
||||||
|
|
||||||
logger.silly(`Fetched ${serverList.length} servers from Redux store`)
|
logger.debug('Fetched servers from Redux store', { count: serverList.length })
|
||||||
return serverList
|
return serverList
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to get servers from Redux:', error)
|
logger.error('Failed to get servers from Redux', { error })
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -91,6 +91,6 @@ export async function getMcpServerById(id: string): Promise<Server> {
|
|||||||
cachedServers[id] = newServer
|
cachedServers[id] = newServer
|
||||||
return newServer
|
return newServer
|
||||||
}
|
}
|
||||||
logger.silly('getMcpServer ', { server: server })
|
logger.debug('Returning cached MCP server', { id, hasHandlers: Boolean(server) })
|
||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,13 @@ import { windowService } from './WindowService'
|
|||||||
|
|
||||||
const logger = loggerService.withContext('AppUpdater')
|
const logger = loggerService.withContext('AppUpdater')
|
||||||
|
|
||||||
|
// Language markers constants for multi-language release notes
|
||||||
|
const LANG_MARKERS = {
|
||||||
|
EN_START: '<!--LANG:en-->',
|
||||||
|
ZH_CN_START: '<!--LANG:zh-CN-->',
|
||||||
|
END: '<!--LANG:END-->'
|
||||||
|
} as const
|
||||||
|
|
||||||
export default class AppUpdater {
|
export default class AppUpdater {
|
||||||
autoUpdater: _AppUpdater = autoUpdater
|
autoUpdater: _AppUpdater = autoUpdater
|
||||||
private releaseInfo: UpdateInfo | undefined
|
private releaseInfo: UpdateInfo | undefined
|
||||||
@ -41,7 +48,8 @@ export default class AppUpdater {
|
|||||||
|
|
||||||
autoUpdater.on('update-available', (releaseInfo: UpdateInfo) => {
|
autoUpdater.on('update-available', (releaseInfo: UpdateInfo) => {
|
||||||
logger.info('update available', releaseInfo)
|
logger.info('update available', releaseInfo)
|
||||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateAvailable, releaseInfo)
|
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
|
||||||
|
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateAvailable, processedReleaseInfo)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 检测到不需要更新时
|
// 检测到不需要更新时
|
||||||
@ -56,9 +64,10 @@ export default class AppUpdater {
|
|||||||
|
|
||||||
// 当需要更新的内容下载完成后
|
// 当需要更新的内容下载完成后
|
||||||
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
|
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
|
||||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, releaseInfo)
|
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
|
||||||
this.releaseInfo = releaseInfo
|
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
|
||||||
logger.info('update downloaded', releaseInfo)
|
this.releaseInfo = processedReleaseInfo
|
||||||
|
logger.info('update downloaded', processedReleaseInfo)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isWin) {
|
if (isWin) {
|
||||||
@ -271,16 +280,99 @@ export default class AppUpdater {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if release notes contain multi-language markers
|
||||||
|
*/
|
||||||
|
private hasMultiLanguageMarkers(releaseNotes: string): boolean {
|
||||||
|
return releaseNotes.includes(LANG_MARKERS.EN_START)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse multi-language release notes and return the appropriate language version
|
||||||
|
* @param releaseNotes - Release notes string with language markers
|
||||||
|
* @returns Parsed release notes for the user's language
|
||||||
|
*
|
||||||
|
* Expected format:
|
||||||
|
* <!--LANG:en-->English content<!--LANG:zh-CN-->Chinese content<!--LANG:END-->
|
||||||
|
*/
|
||||||
|
private parseMultiLangReleaseNotes(releaseNotes: string): string {
|
||||||
|
try {
|
||||||
|
const language = configManager.getLanguage()
|
||||||
|
const isChineseUser = language === 'zh-CN' || language === 'zh-TW'
|
||||||
|
|
||||||
|
// Create regex patterns using constants
|
||||||
|
const enPattern = new RegExp(
|
||||||
|
`${LANG_MARKERS.EN_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\S]*?)${LANG_MARKERS.ZH_CN_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`
|
||||||
|
)
|
||||||
|
const zhPattern = new RegExp(
|
||||||
|
`${LANG_MARKERS.ZH_CN_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\S]*?)${LANG_MARKERS.END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Extract language sections
|
||||||
|
const enMatch = releaseNotes.match(enPattern)
|
||||||
|
const zhMatch = releaseNotes.match(zhPattern)
|
||||||
|
|
||||||
|
// Return appropriate language version with proper fallback
|
||||||
|
if (isChineseUser && zhMatch) {
|
||||||
|
return zhMatch[1].trim()
|
||||||
|
} else if (enMatch) {
|
||||||
|
return enMatch[1].trim()
|
||||||
|
} else {
|
||||||
|
// Clean fallback: remove all language markers
|
||||||
|
logger.warn('Failed to extract language-specific release notes, using cleaned fallback')
|
||||||
|
return releaseNotes
|
||||||
|
.replace(new RegExp(`${LANG_MARKERS.EN_START}|${LANG_MARKERS.ZH_CN_START}|${LANG_MARKERS.END}`, 'g'), '')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to parse multi-language release notes', error as Error)
|
||||||
|
// Return original notes as safe fallback
|
||||||
|
return releaseNotes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process release info to handle multi-language release notes
|
||||||
|
* @param releaseInfo - Original release info from updater
|
||||||
|
* @returns Processed release info with localized release notes
|
||||||
|
*/
|
||||||
|
private processReleaseInfo(releaseInfo: UpdateInfo): UpdateInfo {
|
||||||
|
const processedInfo = { ...releaseInfo }
|
||||||
|
|
||||||
|
// Handle multi-language release notes in string format
|
||||||
|
if (releaseInfo.releaseNotes && typeof releaseInfo.releaseNotes === 'string') {
|
||||||
|
// Check if it contains multi-language markers
|
||||||
|
if (this.hasMultiLanguageMarkers(releaseInfo.releaseNotes)) {
|
||||||
|
processedInfo.releaseNotes = this.parseMultiLangReleaseNotes(releaseInfo.releaseNotes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return processedInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format release notes for display
|
||||||
|
* @param releaseNotes - Release notes in various formats
|
||||||
|
* @returns Formatted string for display
|
||||||
|
*/
|
||||||
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
|
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
|
||||||
if (!releaseNotes) {
|
if (!releaseNotes) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof releaseNotes === 'string') {
|
if (typeof releaseNotes === 'string') {
|
||||||
|
// Check if it contains multi-language markers
|
||||||
|
if (this.hasMultiLanguageMarkers(releaseNotes)) {
|
||||||
|
return this.parseMultiLangReleaseNotes(releaseNotes)
|
||||||
|
}
|
||||||
return releaseNotes
|
return releaseNotes
|
||||||
}
|
}
|
||||||
|
|
||||||
return releaseNotes.map((note) => note.note).join('\n')
|
if (Array.isArray(releaseNotes)) {
|
||||||
|
return releaseNotes.map((note) => note.note).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
interface GithubReleaseInfo {
|
interface GithubReleaseInfo {
|
||||||
|
|||||||
@ -666,7 +666,7 @@ class CodeToolsService {
|
|||||||
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
|
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
|
||||||
|
|
||||||
// Combine directory change with the main command to ensure they execute in the same shell session
|
// Combine directory change with the main command to ensure they execute in the same shell session
|
||||||
const fullCommand = `cd '${directory.replace(/'/g, "\\'")}' && clear && ${command}`
|
const fullCommand = `cd "${directory.replace(/"/g, '\\"')}" && clear && ${command}`
|
||||||
|
|
||||||
const terminalConfig = await this.getTerminalConfig(options.terminal)
|
const terminalConfig = await this.getTerminalConfig(options.terminal)
|
||||||
logger.info(`Using terminal: ${terminalConfig.name} (${terminalConfig.id})`)
|
logger.info(`Using terminal: ${terminalConfig.name} (${terminalConfig.id})`)
|
||||||
|
|||||||
319
src/main/services/__tests__/AppUpdater.test.ts
Normal file
319
src/main/services/__tests__/AppUpdater.test.ts
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
import { UpdateInfo } from 'builder-util-runtime'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('@logger', () => ({
|
||||||
|
loggerService: {
|
||||||
|
withContext: () => ({
|
||||||
|
info: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../ConfigManager', () => ({
|
||||||
|
configManager: {
|
||||||
|
getLanguage: vi.fn(),
|
||||||
|
getAutoUpdate: vi.fn(() => false),
|
||||||
|
getTestPlan: vi.fn(() => false),
|
||||||
|
getTestChannel: vi.fn(),
|
||||||
|
getClientId: vi.fn(() => 'test-client-id')
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../WindowService', () => ({
|
||||||
|
windowService: {
|
||||||
|
getMainWindow: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@main/constant', () => ({
|
||||||
|
isWin: false
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@main/utils/ipService', () => ({
|
||||||
|
getIpCountry: vi.fn(() => 'US')
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@main/utils/locales', () => ({
|
||||||
|
locales: {
|
||||||
|
en: { translation: { update: {} } },
|
||||||
|
'zh-CN': { translation: { update: {} } }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@main/utils/systemInfo', () => ({
|
||||||
|
generateUserAgent: vi.fn(() => 'test-user-agent')
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
app: {
|
||||||
|
isPackaged: true,
|
||||||
|
getVersion: vi.fn(() => '1.0.0'),
|
||||||
|
getPath: vi.fn(() => '/test/path')
|
||||||
|
},
|
||||||
|
dialog: {
|
||||||
|
showMessageBox: vi.fn()
|
||||||
|
},
|
||||||
|
BrowserWindow: vi.fn(),
|
||||||
|
net: {
|
||||||
|
fetch: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('electron-updater', () => ({
|
||||||
|
autoUpdater: {
|
||||||
|
logger: null,
|
||||||
|
forceDevUpdateConfig: false,
|
||||||
|
autoDownload: false,
|
||||||
|
autoInstallOnAppQuit: false,
|
||||||
|
requestHeaders: {},
|
||||||
|
on: vi.fn(),
|
||||||
|
setFeedURL: vi.fn(),
|
||||||
|
checkForUpdates: vi.fn(),
|
||||||
|
downloadUpdate: vi.fn(),
|
||||||
|
quitAndInstall: vi.fn(),
|
||||||
|
channel: '',
|
||||||
|
allowDowngrade: false,
|
||||||
|
disableDifferentialDownload: false,
|
||||||
|
currentVersion: '1.0.0'
|
||||||
|
},
|
||||||
|
Logger: vi.fn(),
|
||||||
|
NsisUpdater: vi.fn(),
|
||||||
|
AppUpdater: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Import after mocks
|
||||||
|
import AppUpdater from '../AppUpdater'
|
||||||
|
import { configManager } from '../ConfigManager'
|
||||||
|
|
||||||
|
describe('AppUpdater', () => {
|
||||||
|
let appUpdater: AppUpdater
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
appUpdater = new AppUpdater()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseMultiLangReleaseNotes', () => {
|
||||||
|
const sampleReleaseNotes = `<!--LANG:en-->
|
||||||
|
🚀 New Features:
|
||||||
|
- Feature A
|
||||||
|
- Feature B
|
||||||
|
|
||||||
|
🎨 UI Improvements:
|
||||||
|
- Improvement A
|
||||||
|
<!--LANG:zh-CN-->
|
||||||
|
🚀 新功能:
|
||||||
|
- 功能 A
|
||||||
|
- 功能 B
|
||||||
|
|
||||||
|
🎨 界面改进:
|
||||||
|
- 改进 A
|
||||||
|
<!--LANG:END-->`
|
||||||
|
|
||||||
|
it('should return Chinese notes for zh-CN users', () => {
|
||||||
|
vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN')
|
||||||
|
|
||||||
|
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
|
||||||
|
|
||||||
|
expect(result).toContain('新功能')
|
||||||
|
expect(result).toContain('功能 A')
|
||||||
|
expect(result).not.toContain('New Features')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return Chinese notes for zh-TW users', () => {
|
||||||
|
vi.mocked(configManager.getLanguage).mockReturnValue('zh-TW')
|
||||||
|
|
||||||
|
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
|
||||||
|
|
||||||
|
expect(result).toContain('新功能')
|
||||||
|
expect(result).toContain('功能 A')
|
||||||
|
expect(result).not.toContain('New Features')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return English notes for non-Chinese users', () => {
|
||||||
|
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
|
||||||
|
|
||||||
|
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
|
||||||
|
|
||||||
|
expect(result).toContain('New Features')
|
||||||
|
expect(result).toContain('Feature A')
|
||||||
|
expect(result).not.toContain('新功能')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return English notes for other language users', () => {
|
||||||
|
vi.mocked(configManager.getLanguage).mockReturnValue('ru-RU')
|
||||||
|
|
||||||
|
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
|
||||||
|
|
||||||
|
expect(result).toContain('New Features')
|
||||||
|
expect(result).not.toContain('新功能')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle missing language sections gracefully', () => {
|
||||||
|
const malformedNotes = 'Simple release notes without markers'
|
||||||
|
|
||||||
|
const result = (appUpdater as any).parseMultiLangReleaseNotes(malformedNotes)
|
||||||
|
|
||||||
|
expect(result).toBe('Simple release notes without markers')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle malformed markers', () => {
|
||||||
|
const malformedNotes = `<!--LANG:en-->English only`
|
||||||
|
vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN')
|
||||||
|
|
||||||
|
const result = (appUpdater as any).parseMultiLangReleaseNotes(malformedNotes)
|
||||||
|
|
||||||
|
// Should clean up markers and return cleaned content
|
||||||
|
expect(result).toContain('English only')
|
||||||
|
expect(result).not.toContain('<!--LANG:')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty release notes', () => {
|
||||||
|
const result = (appUpdater as any).parseMultiLangReleaseNotes('')
|
||||||
|
|
||||||
|
expect(result).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle errors gracefully', () => {
|
||||||
|
// Force an error by mocking configManager to throw
|
||||||
|
vi.mocked(configManager.getLanguage).mockImplementation(() => {
|
||||||
|
throw new Error('Test error')
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
|
||||||
|
|
||||||
|
// Should return original notes as fallback
|
||||||
|
expect(result).toBe(sampleReleaseNotes)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hasMultiLanguageMarkers', () => {
|
||||||
|
it('should return true when markers are present', () => {
|
||||||
|
const notes = '<!--LANG:en-->Test'
|
||||||
|
|
||||||
|
const result = (appUpdater as any).hasMultiLanguageMarkers(notes)
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when no markers are present', () => {
|
||||||
|
const notes = 'Simple text without markers'
|
||||||
|
|
||||||
|
const result = (appUpdater as any).hasMultiLanguageMarkers(notes)
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('processReleaseInfo', () => {
|
||||||
|
it('should process multi-language release notes in string format', () => {
|
||||||
|
vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN')
|
||||||
|
|
||||||
|
const releaseInfo = {
|
||||||
|
version: '1.0.0',
|
||||||
|
files: [],
|
||||||
|
path: '',
|
||||||
|
sha512: '',
|
||||||
|
releaseDate: new Date().toISOString(),
|
||||||
|
releaseNotes: `<!--LANG:en-->English notes<!--LANG:zh-CN-->中文说明<!--LANG:END-->`
|
||||||
|
} as UpdateInfo
|
||||||
|
|
||||||
|
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
|
||||||
|
|
||||||
|
expect(result.releaseNotes).toBe('中文说明')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not process release notes without markers', () => {
|
||||||
|
const releaseInfo = {
|
||||||
|
version: '1.0.0',
|
||||||
|
files: [],
|
||||||
|
path: '',
|
||||||
|
sha512: '',
|
||||||
|
releaseDate: new Date().toISOString(),
|
||||||
|
releaseNotes: 'Simple release notes'
|
||||||
|
} as UpdateInfo
|
||||||
|
|
||||||
|
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
|
||||||
|
|
||||||
|
expect(result.releaseNotes).toBe('Simple release notes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle array format release notes', () => {
|
||||||
|
const releaseInfo = {
|
||||||
|
version: '1.0.0',
|
||||||
|
files: [],
|
||||||
|
path: '',
|
||||||
|
sha512: '',
|
||||||
|
releaseDate: new Date().toISOString(),
|
||||||
|
releaseNotes: [
|
||||||
|
{ version: '1.0.0', note: 'Note 1' },
|
||||||
|
{ version: '1.0.1', note: 'Note 2' }
|
||||||
|
]
|
||||||
|
} as UpdateInfo
|
||||||
|
|
||||||
|
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
|
||||||
|
|
||||||
|
expect(result.releaseNotes).toEqual(releaseInfo.releaseNotes)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle null release notes', () => {
|
||||||
|
const releaseInfo = {
|
||||||
|
version: '1.0.0',
|
||||||
|
files: [],
|
||||||
|
path: '',
|
||||||
|
sha512: '',
|
||||||
|
releaseDate: new Date().toISOString(),
|
||||||
|
releaseNotes: null
|
||||||
|
} as UpdateInfo
|
||||||
|
|
||||||
|
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
|
||||||
|
|
||||||
|
expect(result.releaseNotes).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatReleaseNotes', () => {
|
||||||
|
it('should format string release notes with markers', () => {
|
||||||
|
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
|
||||||
|
const notes = `<!--LANG:en-->English<!--LANG:zh-CN-->中文<!--LANG:END-->`
|
||||||
|
|
||||||
|
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||||
|
|
||||||
|
expect(result).toBe('English')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should format string release notes without markers', () => {
|
||||||
|
const notes = 'Simple notes'
|
||||||
|
|
||||||
|
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||||
|
|
||||||
|
expect(result).toBe('Simple notes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should format array release notes', () => {
|
||||||
|
const notes = [
|
||||||
|
{ version: '1.0.0', note: 'Note 1' },
|
||||||
|
{ version: '1.0.1', note: 'Note 2' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||||
|
|
||||||
|
expect(result).toBe('Note 1\nNote 2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle null release notes', () => {
|
||||||
|
const result = (appUpdater as any).formatReleaseNotes(null)
|
||||||
|
|
||||||
|
expect(result).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle undefined release notes', () => {
|
||||||
|
const result = (appUpdater as any).formatReleaseNotes(undefined)
|
||||||
|
|
||||||
|
expect(result).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,290 @@
|
|||||||
|
import type { SDKMessage } from '@anthropic-ai/claude-code'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { ClaudeStreamState, transformSDKMessageToStreamParts } from '../transform'
|
||||||
|
|
||||||
|
const baseStreamMetadata = {
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
session_id: 'session-123'
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuid = (n: number) => `00000000-0000-0000-0000-${n.toString().padStart(12, '0')}`
|
||||||
|
|
||||||
|
describe('Claude → AiSDK transform', () => {
|
||||||
|
it('handles tool call streaming lifecycle', () => {
|
||||||
|
const state = new ClaudeStreamState()
|
||||||
|
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
|
||||||
|
|
||||||
|
const messages: SDKMessage[] = [
|
||||||
|
{
|
||||||
|
...baseStreamMetadata,
|
||||||
|
type: 'stream_event',
|
||||||
|
uuid: uuid(1),
|
||||||
|
event: {
|
||||||
|
type: 'message_start',
|
||||||
|
message: {
|
||||||
|
id: 'msg-start',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
model: 'claude-test',
|
||||||
|
content: [],
|
||||||
|
stop_reason: null,
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as unknown as SDKMessage,
|
||||||
|
{
|
||||||
|
...baseStreamMetadata,
|
||||||
|
type: 'stream_event',
|
||||||
|
uuid: uuid(2),
|
||||||
|
event: {
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: 0,
|
||||||
|
content_block: {
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'tool-1',
|
||||||
|
name: 'Bash',
|
||||||
|
input: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as unknown as SDKMessage,
|
||||||
|
{
|
||||||
|
...baseStreamMetadata,
|
||||||
|
type: 'stream_event',
|
||||||
|
uuid: uuid(3),
|
||||||
|
event: {
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: 0,
|
||||||
|
delta: {
|
||||||
|
type: 'input_json_delta',
|
||||||
|
partial_json: '{"command":"ls"}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as unknown as SDKMessage,
|
||||||
|
{
|
||||||
|
...baseStreamMetadata,
|
||||||
|
type: 'assistant',
|
||||||
|
uuid: uuid(4),
|
||||||
|
message: {
|
||||||
|
id: 'msg-tool',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
model: 'claude-test',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'tool-1',
|
||||||
|
name: 'Bash',
|
||||||
|
input: {
|
||||||
|
command: 'ls'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
stop_reason: 'tool_use',
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: {
|
||||||
|
input_tokens: 1,
|
||||||
|
output_tokens: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as unknown as SDKMessage,
|
||||||
|
{
|
||||||
|
...baseStreamMetadata,
|
||||||
|
type: 'stream_event',
|
||||||
|
uuid: uuid(5),
|
||||||
|
event: {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index: 0
|
||||||
|
}
|
||||||
|
} as unknown as SDKMessage,
|
||||||
|
{
|
||||||
|
...baseStreamMetadata,
|
||||||
|
type: 'stream_event',
|
||||||
|
uuid: uuid(6),
|
||||||
|
event: {
|
||||||
|
type: 'message_delta',
|
||||||
|
delta: {
|
||||||
|
stop_reason: 'tool_use',
|
||||||
|
stop_sequence: null
|
||||||
|
},
|
||||||
|
usage: {
|
||||||
|
input_tokens: 1,
|
||||||
|
output_tokens: 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as unknown as SDKMessage,
|
||||||
|
{
|
||||||
|
...baseStreamMetadata,
|
||||||
|
type: 'stream_event',
|
||||||
|
uuid: uuid(7),
|
||||||
|
event: {
|
||||||
|
type: 'message_stop'
|
||||||
|
}
|
||||||
|
} as unknown as SDKMessage,
|
||||||
|
{
|
||||||
|
...baseStreamMetadata,
|
||||||
|
type: 'user',
|
||||||
|
uuid: uuid(8),
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'tool-1',
|
||||||
|
content: 'ok',
|
||||||
|
is_error: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
} as SDKMessage
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
const transformed = transformSDKMessageToStreamParts(message, state)
|
||||||
|
for (const part of transformed) {
|
||||||
|
parts.push(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const types = parts.map((part) => part.type)
|
||||||
|
expect(types).toEqual([
|
||||||
|
'start-step',
|
||||||
|
'tool-input-start',
|
||||||
|
'tool-input-delta',
|
||||||
|
'tool-call',
|
||||||
|
'tool-input-end',
|
||||||
|
'finish-step',
|
||||||
|
'tool-result'
|
||||||
|
])
|
||||||
|
|
||||||
|
const finishStep = parts.find((part) => part.type === 'finish-step') as Extract<
|
||||||
|
(typeof parts)[number],
|
||||||
|
{ type: 'finish-step' }
|
||||||
|
>
|
||||||
|
expect(finishStep.finishReason).toBe('tool-calls')
|
||||||
|
expect(finishStep.usage).toEqual({ inputTokens: 1, outputTokens: 5, totalTokens: 6 })
|
||||||
|
|
||||||
|
const toolResult = parts.find((part) => part.type === 'tool-result') as Extract<
|
||||||
|
(typeof parts)[number],
|
||||||
|
{ type: 'tool-result' }
|
||||||
|
>
|
||||||
|
expect(toolResult.toolCallId).toBe('tool-1')
|
||||||
|
expect(toolResult.toolName).toBe('Bash')
|
||||||
|
expect(toolResult.input).toEqual({ command: 'ls' })
|
||||||
|
expect(toolResult.output).toBe('ok')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles streaming text completion', () => {
|
||||||
|
const state = new ClaudeStreamState()
|
||||||
|
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
|
||||||
|
|
||||||
|
const messages: SDKMessage[] = [
|
||||||
|
{
|
||||||
|
...baseStreamMetadata,
|
||||||
|
type: 'stream_event',
|
||||||
|
uuid: uuid(9),
|
||||||
|
event: {
|
||||||
|
type: 'message_start',
|
||||||
|
message: {
|
||||||
|
id: 'msg-text',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
model: 'claude-text',
|
||||||
|
content: [],
|
||||||
|
stop_reason: null,
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as unknown as SDKMessage,
|
||||||
|
{
|
||||||
|
...baseStreamMetadata,
|
||||||
|
type: 'stream_event',
|
||||||
|
uuid: uuid(10),
|
||||||
|
event: {
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: 0,
|
||||||
|
content_block: {
|
||||||
|
type: 'text',
|
||||||
|
text: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as unknown as SDKMessage,
|
||||||
|
{
|
||||||
|
...baseStreamMetadata,
|
||||||
|
type: 'stream_event',
|
||||||
|
uuid: uuid(11),
|
||||||
|
event: {
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: 0,
|
||||||
|
delta: {
|
||||||
|
type: 'text_delta',
|
||||||
|
text: 'Hello'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as unknown as SDKMessage,
|
||||||
|
{
|
||||||
|
...baseStreamMetadata,
|
||||||
|
type: 'stream_event',
|
||||||
|
uuid: uuid(12),
|
||||||
|
event: {
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: 0,
|
||||||
|
delta: {
|
||||||
|
type: 'text_delta',
|
||||||
|
text: ' world'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as unknown as SDKMessage,
|
||||||
|
{
|
||||||
|
...baseStreamMetadata,
|
||||||
|
type: 'stream_event',
|
||||||
|
uuid: uuid(13),
|
||||||
|
event: {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index: 0
|
||||||
|
}
|
||||||
|
} as unknown as SDKMessage,
|
||||||
|
{
|
||||||
|
...baseStreamMetadata,
|
||||||
|
type: 'stream_event',
|
||||||
|
uuid: uuid(14),
|
||||||
|
event: {
|
||||||
|
type: 'message_delta',
|
||||||
|
delta: {
|
||||||
|
stop_reason: 'end_turn',
|
||||||
|
stop_sequence: null
|
||||||
|
},
|
||||||
|
usage: {
|
||||||
|
input_tokens: 2,
|
||||||
|
output_tokens: 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as unknown as SDKMessage,
|
||||||
|
{
|
||||||
|
...baseStreamMetadata,
|
||||||
|
type: 'stream_event',
|
||||||
|
uuid: uuid(15),
|
||||||
|
event: {
|
||||||
|
type: 'message_stop'
|
||||||
|
}
|
||||||
|
} as SDKMessage
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
const transformed = transformSDKMessageToStreamParts(message, state)
|
||||||
|
parts.push(...transformed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const types = parts.map((part) => part.type)
|
||||||
|
expect(types).toEqual(['start-step', 'text-start', 'text-delta', 'text-delta', 'text-end', 'finish-step'])
|
||||||
|
|
||||||
|
const finishStep = parts.find((part) => part.type === 'finish-step') as Extract<
|
||||||
|
(typeof parts)[number],
|
||||||
|
{ type: 'finish-step' }
|
||||||
|
>
|
||||||
|
expect(finishStep.finishReason).toBe('stop')
|
||||||
|
expect(finishStep.usage).toEqual({ inputTokens: 2, outputTokens: 4, totalTokens: 6 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* Lightweight state container shared by the Claude → AiSDK transformer. Anthropic does not send
|
||||||
|
* deterministic identifiers for intermediate content blocks, so we stitch one together by tracking
|
||||||
|
* block indices and associated AiSDK ids. This class also keeps:
|
||||||
|
* • incremental text / reasoning buffers so we can emit only deltas while retaining the full
|
||||||
|
* aggregate for later tool-call emission;
|
||||||
|
* • a reverse lookup for tool calls so `tool_result` snapshots can recover their metadata;
|
||||||
|
* • pending usage + finish reason from `message_delta` events until the corresponding
|
||||||
|
* `message_stop` arrives.
|
||||||
|
* Every Claude turn gets its own instance. `resetStep` should be invoked once the finish event has
|
||||||
|
* been emitted to avoid leaking state into the next turn.
|
||||||
|
*/
|
||||||
|
import type { FinishReason, LanguageModelUsage, ProviderMetadata } from 'ai'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared fields for every block that Claude can stream (text, reasoning, tool).
|
||||||
|
*/
|
||||||
|
type BaseBlockState = {
|
||||||
|
id: string
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextBlockState = BaseBlockState & {
|
||||||
|
kind: 'text'
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReasoningBlockState = BaseBlockState & {
|
||||||
|
kind: 'reasoning'
|
||||||
|
text: string
|
||||||
|
redacted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolBlockState = BaseBlockState & {
|
||||||
|
kind: 'tool'
|
||||||
|
toolCallId: string
|
||||||
|
toolName: string
|
||||||
|
inputBuffer: string
|
||||||
|
providerMetadata?: ProviderMetadata
|
||||||
|
resolvedInput?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BlockState = TextBlockState | ReasoningBlockState | ToolBlockState
|
||||||
|
|
||||||
|
type PendingUsageState = {
|
||||||
|
usage?: LanguageModelUsage
|
||||||
|
finishReason?: FinishReason
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingToolCall = {
|
||||||
|
toolCallId: string
|
||||||
|
toolName: string
|
||||||
|
input: unknown
|
||||||
|
providerMetadata?: ProviderMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks the lifecycle of Claude streaming blocks (text, thinking, tool calls)
|
||||||
|
* across individual websocket events. The transformer relies on this class to
|
||||||
|
* stitch together deltas, manage pending tool inputs/results, and propagate
|
||||||
|
* usage/finish metadata once Anthropic closes a message.
|
||||||
|
*/
|
||||||
|
export class ClaudeStreamState {
|
||||||
|
private blocksByIndex = new Map<number, BlockState>()
|
||||||
|
private toolIndexById = new Map<string, number>()
|
||||||
|
private pendingUsage: PendingUsageState = {}
|
||||||
|
private pendingToolCalls = new Map<string, PendingToolCall>()
|
||||||
|
private stepActive = false
|
||||||
|
|
||||||
|
/** Marks the beginning of a new AiSDK step. */
|
||||||
|
beginStep(): void {
|
||||||
|
this.stepActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
hasActiveStep(): boolean {
|
||||||
|
return this.stepActive
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a text block placeholder so future deltas can accumulate into it. */
|
||||||
|
openTextBlock(index: number, id: string): TextBlockState {
|
||||||
|
const block: TextBlockState = {
|
||||||
|
kind: 'text',
|
||||||
|
id,
|
||||||
|
index,
|
||||||
|
text: ''
|
||||||
|
}
|
||||||
|
this.blocksByIndex.set(index, block)
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts tracking an Anthropic "thinking" block, optionally flagged as redacted. */
|
||||||
|
openReasoningBlock(index: number, id: string, redacted: boolean): ReasoningBlockState {
|
||||||
|
const block: ReasoningBlockState = {
|
||||||
|
kind: 'reasoning',
|
||||||
|
id,
|
||||||
|
index,
|
||||||
|
redacted,
|
||||||
|
text: ''
|
||||||
|
}
|
||||||
|
this.blocksByIndex.set(index, block)
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Caches tool metadata so subsequent input deltas and results can find it. */
|
||||||
|
openToolBlock(
|
||||||
|
index: number,
|
||||||
|
params: { toolCallId: string; toolName: string; providerMetadata?: ProviderMetadata }
|
||||||
|
): ToolBlockState {
|
||||||
|
const block: ToolBlockState = {
|
||||||
|
kind: 'tool',
|
||||||
|
id: params.toolCallId,
|
||||||
|
index,
|
||||||
|
toolCallId: params.toolCallId,
|
||||||
|
toolName: params.toolName,
|
||||||
|
inputBuffer: '',
|
||||||
|
providerMetadata: params.providerMetadata
|
||||||
|
}
|
||||||
|
this.blocksByIndex.set(index, block)
|
||||||
|
this.toolIndexById.set(params.toolCallId, index)
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
getBlock(index: number): BlockState | undefined {
|
||||||
|
return this.blocksByIndex.get(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
getToolBlockById(toolCallId: string): ToolBlockState | undefined {
|
||||||
|
const index = this.toolIndexById.get(toolCallId)
|
||||||
|
if (index === undefined) return undefined
|
||||||
|
const block = this.blocksByIndex.get(index)
|
||||||
|
if (!block || block.kind !== 'tool') return undefined
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appends streamed text to a text block, returning the updated state when present. */
|
||||||
|
appendTextDelta(index: number, text: string): TextBlockState | undefined {
|
||||||
|
const block = this.blocksByIndex.get(index)
|
||||||
|
if (!block || block.kind !== 'text') return undefined
|
||||||
|
block.text += text
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appends streamed "thinking" content to the tracked reasoning block. */
|
||||||
|
appendReasoningDelta(index: number, text: string): ReasoningBlockState | undefined {
|
||||||
|
const block = this.blocksByIndex.get(index)
|
||||||
|
if (!block || block.kind !== 'reasoning') return undefined
|
||||||
|
block.text += text
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Concatenates incremental JSON payloads for tool input blocks. */
|
||||||
|
appendToolInputDelta(index: number, jsonDelta: string): ToolBlockState | undefined {
|
||||||
|
const block = this.blocksByIndex.get(index)
|
||||||
|
if (!block || block.kind !== 'tool') return undefined
|
||||||
|
block.inputBuffer += jsonDelta
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records a tool call to be consumed once its result arrives from the user. */
|
||||||
|
registerToolCall(
|
||||||
|
toolCallId: string,
|
||||||
|
payload: { toolName: string; input: unknown; providerMetadata?: ProviderMetadata }
|
||||||
|
): void {
|
||||||
|
this.pendingToolCalls.set(toolCallId, {
|
||||||
|
toolCallId,
|
||||||
|
toolName: payload.toolName,
|
||||||
|
input: payload.input,
|
||||||
|
providerMetadata: payload.providerMetadata
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retrieves and clears the buffered tool call metadata for the given id. */
|
||||||
|
consumePendingToolCall(toolCallId: string): PendingToolCall | undefined {
|
||||||
|
const entry = this.pendingToolCalls.get(toolCallId)
|
||||||
|
if (entry) {
|
||||||
|
this.pendingToolCalls.delete(toolCallId)
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the final input payload for a tool block once the provider signals
|
||||||
|
* completion so that downstream tool results can reference the original call.
|
||||||
|
*/
|
||||||
|
completeToolBlock(toolCallId: string, input: unknown, providerMetadata?: ProviderMetadata): void {
|
||||||
|
this.registerToolCall(toolCallId, {
|
||||||
|
toolName: this.getToolBlockById(toolCallId)?.toolName ?? 'unknown',
|
||||||
|
input,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
const block = this.getToolBlockById(toolCallId)
|
||||||
|
if (block) {
|
||||||
|
block.resolvedInput = input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes a block from the active index map when Claude signals it is done. */
|
||||||
|
closeBlock(index: number): BlockState | undefined {
|
||||||
|
const block = this.blocksByIndex.get(index)
|
||||||
|
if (!block) return undefined
|
||||||
|
this.blocksByIndex.delete(index)
|
||||||
|
if (block.kind === 'tool') {
|
||||||
|
this.toolIndexById.delete(block.toolCallId)
|
||||||
|
}
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stores interim usage metrics so they can be emitted with the `finish-step`. */
|
||||||
|
setPendingUsage(usage?: LanguageModelUsage, finishReason?: FinishReason): void {
|
||||||
|
if (usage) {
|
||||||
|
this.pendingUsage.usage = usage
|
||||||
|
}
|
||||||
|
if (finishReason) {
|
||||||
|
this.pendingUsage.finishReason = finishReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingUsage(): PendingUsageState {
|
||||||
|
return { ...this.pendingUsage }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears any accumulated usage values for the next streamed message. */
|
||||||
|
resetPendingUsage(): void {
|
||||||
|
this.pendingUsage = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops cached block metadata for the currently active message. */
|
||||||
|
resetBlocks(): void {
|
||||||
|
this.blocksByIndex.clear()
|
||||||
|
this.toolIndexById.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resets the entire step lifecycle after emitting a terminal frame. */
|
||||||
|
resetStep(): void {
|
||||||
|
this.resetBlocks()
|
||||||
|
this.resetPendingUsage()
|
||||||
|
this.stepActive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { PendingToolCall }
|
||||||
@ -11,7 +11,7 @@ import { app } from 'electron'
|
|||||||
|
|
||||||
import { GetAgentSessionResponse } from '../..'
|
import { GetAgentSessionResponse } from '../..'
|
||||||
import { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
|
import { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
|
||||||
import { transformSDKMessageToStreamParts } from './transform'
|
import { ClaudeStreamState, transformSDKMessageToStreamParts } from './transform'
|
||||||
|
|
||||||
const require_ = createRequire(import.meta.url)
|
const require_ = createRequire(import.meta.url)
|
||||||
const logger = loggerService.withContext('ClaudeCodeService')
|
const logger = loggerService.withContext('ClaudeCodeService')
|
||||||
@ -92,6 +92,7 @@ class ClaudeCodeService implements AgentServiceInterface {
|
|||||||
errorChunks.push(chunk)
|
errorChunks.push(chunk)
|
||||||
},
|
},
|
||||||
appendSystemPrompt: session.instructions,
|
appendSystemPrompt: session.instructions,
|
||||||
|
includePartialMessages: true,
|
||||||
permissionMode: session.configuration?.permission_mode,
|
permissionMode: session.configuration?.permission_mode,
|
||||||
maxTurns: session.configuration?.max_turns,
|
maxTurns: session.configuration?.max_turns,
|
||||||
allowedTools: session.allowed_tools
|
allowedTools: session.allowed_tools
|
||||||
@ -164,6 +165,7 @@ class ClaudeCodeService implements AgentServiceInterface {
|
|||||||
let hasCompleted = false
|
let hasCompleted = false
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
const streamState = new ClaudeStreamState()
|
||||||
try {
|
try {
|
||||||
// Process streaming responses using SDK query
|
// Process streaming responses using SDK query
|
||||||
for await (const message of query({
|
for await (const message of query({
|
||||||
@ -173,15 +175,21 @@ class ClaudeCodeService implements AgentServiceInterface {
|
|||||||
if (hasCompleted) break
|
if (hasCompleted) break
|
||||||
|
|
||||||
jsonOutput.push(message)
|
jsonOutput.push(message)
|
||||||
logger.silly('claude response', { message })
|
|
||||||
if (message.type === 'assistant' || message.type === 'user') {
|
if (message.type === 'assistant' || message.type === 'user') {
|
||||||
logger.silly('message content', {
|
logger.silly('claude response', {
|
||||||
message: JSON.stringify({ role: message.message.role, content: message.message.content })
|
message,
|
||||||
|
content: JSON.stringify(message.message.content)
|
||||||
|
})
|
||||||
|
} else if (message.type === 'stream_event') {
|
||||||
|
logger.silly('Claude stream event', {
|
||||||
|
message,
|
||||||
|
event: JSON.stringify(message.event)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform SDKMessage to UIMessageChunks
|
// Transform SDKMessage to UIMessageChunks
|
||||||
const chunks = transformSDKMessageToStreamParts(message)
|
const chunks = transformSDKMessageToStreamParts(message, streamState)
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
stream.emit('data', {
|
stream.emit('data', {
|
||||||
type: 'chunk',
|
type: 'chunk',
|
||||||
|
|||||||
@ -1,66 +1,82 @@
|
|||||||
// This file is used to transform claude code json response to aisdk streaming format
|
/**
|
||||||
|
* Translates Anthropic Claude Code streaming messages into the generic AiSDK stream
|
||||||
|
* parts that the agent runtime understands. The transformer coordinates batched
|
||||||
|
* text/tool payloads, keeps per-message state using {@link ClaudeStreamState},
|
||||||
|
* and normalises usage metadata and finish reasons so downstream consumers do
|
||||||
|
* not need to reason about Anthropic-specific payload shapes.
|
||||||
|
*
|
||||||
|
* Stream lifecycle cheatsheet (per Claude turn):
|
||||||
|
* 1. `stream_event.message_start` → emit `start-step` and mark the state as active.
|
||||||
|
* 2. `content_block_start` (by index) → open a stateful block; emits one of
|
||||||
|
* `text-start` | `reasoning-start` | `tool-input-start`.
|
||||||
|
* 3. `content_block_delta` → append incremental text / reasoning / tool JSON,
|
||||||
|
* emitting only the delta to minimise UI churn.
|
||||||
|
* 4. `content_block_stop` → emit the matching `*-end` event and release the block.
|
||||||
|
* 5. `message_delta` → capture usage + stop reason but defer emission.
|
||||||
|
* 6. `message_stop` → emit `finish-step` with cached usage & reason, then reset.
|
||||||
|
* 7. Assistant snapshots with `tool_use` finalise the tool block (`tool-call`).
|
||||||
|
* 8. User snapshots with `tool_result` emit `tool-result`/`tool-error` using the cached payload.
|
||||||
|
* 9. Assistant snapshots with plain text (when no stream events were provided) fall back to
|
||||||
|
* emitting `text-*` parts and a synthetic `finish-step`.
|
||||||
|
*/
|
||||||
|
|
||||||
import type { LanguageModelV2Usage } from '@ai-sdk/provider'
|
|
||||||
import { SDKMessage } from '@anthropic-ai/claude-code'
|
import { SDKMessage } from '@anthropic-ai/claude-code'
|
||||||
|
import type { BetaStopReason } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import type { ClaudeCodeRawValue } from '@shared/agents/claudecode/types'
|
import type { FinishReason, LanguageModelUsage, ProviderMetadata, TextStreamPart } from 'ai'
|
||||||
import type { ProviderMetadata, TextStreamPart } from 'ai'
|
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
|
import { ClaudeStreamState } from './claude-stream-state'
|
||||||
import { mapClaudeCodeFinishReason } from './map-claude-code-finish-reason'
|
import { mapClaudeCodeFinishReason } from './map-claude-code-finish-reason'
|
||||||
|
|
||||||
const logger = loggerService.withContext('ClaudeCodeTransform')
|
const logger = loggerService.withContext('ClaudeCodeTransform')
|
||||||
|
|
||||||
type AgentStreamPart = TextStreamPart<Record<string, any>>
|
type AgentStreamPart = TextStreamPart<Record<string, any>>
|
||||||
|
|
||||||
type contentBlock =
|
type ToolUseContent = {
|
||||||
| {
|
type: 'tool_use'
|
||||||
type: 'text'
|
id: string
|
||||||
}
|
name: string
|
||||||
| {
|
input: unknown
|
||||||
type: 'tool-call'
|
|
||||||
toolCallId: string
|
|
||||||
toolName: string
|
|
||||||
input: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentBlockState = new Map<string, contentBlock>()
|
|
||||||
|
|
||||||
type toolCallBlock = Extract<contentBlock, { type: 'tool-call' }>
|
|
||||||
|
|
||||||
// Helper function to generate unique IDs for text blocks
|
|
||||||
const generateMessageId = (): string => `msg_${uuidv4().replace(/-/g, '')}`
|
|
||||||
|
|
||||||
// Main transform function
|
|
||||||
export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage): AgentStreamPart[] {
|
|
||||||
const chunks: AgentStreamPart[] = []
|
|
||||||
// logger.silly('Transforming SDKMessage to stream parts', sdkMessage)
|
|
||||||
switch (sdkMessage.type) {
|
|
||||||
case 'assistant':
|
|
||||||
case 'user':
|
|
||||||
chunks.push(...handleUserOrAssistantMessage(sdkMessage))
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'stream_event':
|
|
||||||
chunks.push(...handleStreamEvent(sdkMessage))
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'system':
|
|
||||||
chunks.push(...handleSystemMessage(sdkMessage))
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'result':
|
|
||||||
chunks.push(...handleResultMessage(sdkMessage))
|
|
||||||
break
|
|
||||||
|
|
||||||
default:
|
|
||||||
logger.warn('Unknown SDKMessage type:', { type: (sdkMessage as any).type })
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ToolResultContent = {
|
||||||
|
type: 'tool_result'
|
||||||
|
tool_use_id: string
|
||||||
|
content: unknown
|
||||||
|
is_error?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps Anthropic stop reasons to the AiSDK equivalents so higher level
|
||||||
|
* consumers can treat completion states uniformly across providers.
|
||||||
|
*/
|
||||||
|
const finishReasonMapping: Record<BetaStopReason, FinishReason> = {
|
||||||
|
end_turn: 'stop',
|
||||||
|
max_tokens: 'length',
|
||||||
|
stop_sequence: 'stop',
|
||||||
|
tool_use: 'tool-calls',
|
||||||
|
pause_turn: 'unknown',
|
||||||
|
refusal: 'content-filter'
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyUsage: LanguageModelUsage = {
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
totalTokens: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates deterministic-ish message identifiers that are compatible with the
|
||||||
|
* AiSDK text stream contract. Anthropic deltas sometimes omit ids, so we create
|
||||||
|
* our own to ensure the downstream renderer can stitch chunks together.
|
||||||
|
*/
|
||||||
|
const generateMessageId = (): string => `msg_${uuidv4().replace(/-/g, '')}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts provider metadata from the raw Claude message so we can surface it
|
||||||
|
* on every emitted stream part for observability and debugging purposes.
|
||||||
|
*/
|
||||||
const sdkMessageToProviderMetadata = (message: SDKMessage): ProviderMetadata => {
|
const sdkMessageToProviderMetadata = (message: SDKMessage): ProviderMetadata => {
|
||||||
return {
|
return {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
@ -71,250 +87,523 @@ const sdkMessageToProviderMetadata = (message: SDKMessage): ProviderMetadata =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateTextChunks(id: string, text: string, message: SDKMessage): AgentStreamPart[] {
|
/**
|
||||||
const providerMetadata = sdkMessageToProviderMetadata(message)
|
* Central entrypoint that receives Claude Code websocket events and converts
|
||||||
return [
|
* them into AiSDK `TextStreamPart`s. The state machine tracks outstanding
|
||||||
{
|
* blocks across calls so that incremental deltas can be correlated correctly.
|
||||||
type: 'text-start',
|
*/
|
||||||
id
|
export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage, state: ClaudeStreamState): AgentStreamPart[] {
|
||||||
},
|
switch (sdkMessage.type) {
|
||||||
{
|
case 'assistant':
|
||||||
type: 'text-delta',
|
return handleAssistantMessage(sdkMessage, state)
|
||||||
id,
|
case 'user':
|
||||||
text
|
return handleUserMessage(sdkMessage, state)
|
||||||
},
|
case 'stream_event':
|
||||||
{
|
return handleStreamEvent(sdkMessage, state)
|
||||||
type: 'text-end',
|
case 'system':
|
||||||
id,
|
return handleSystemMessage(sdkMessage)
|
||||||
providerMetadata: {
|
case 'result':
|
||||||
...providerMetadata
|
return handleResultMessage(sdkMessage)
|
||||||
}
|
default:
|
||||||
}
|
logger.warn('Unknown SDKMessage type', { type: (sdkMessage as any).type })
|
||||||
]
|
return []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUserOrAssistantMessage(message: Extract<SDKMessage, { type: 'assistant' | 'user' }>): AgentStreamPart[] {
|
/**
|
||||||
|
* Handles aggregated assistant messages that arrive outside of the streaming
|
||||||
|
* protocol (e.g. after a tool call finishes). We emit the appropriate
|
||||||
|
* text/tool events and close the active step once the payload is fully
|
||||||
|
* processed.
|
||||||
|
*/
|
||||||
|
function handleAssistantMessage(
|
||||||
|
message: Extract<SDKMessage, { type: 'assistant' }>,
|
||||||
|
state: ClaudeStreamState
|
||||||
|
): AgentStreamPart[] {
|
||||||
const chunks: AgentStreamPart[] = []
|
const chunks: AgentStreamPart[] = []
|
||||||
const messageId = message.uuid?.toString() || generateMessageId()
|
const providerMetadata = sdkMessageToProviderMetadata(message)
|
||||||
|
const content = message.message.content
|
||||||
|
const isStreamingActive = state.hasActiveStep()
|
||||||
|
|
||||||
// handle normal text content
|
if (typeof content === 'string') {
|
||||||
if (typeof message.message.content === 'string') {
|
if (!content) {
|
||||||
const textContent = message.message.content
|
return chunks
|
||||||
if (textContent) {
|
|
||||||
chunks.push(...generateTextChunks(messageId, textContent, message))
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(message.message.content)) {
|
|
||||||
for (const block of message.message.content) {
|
if (!isStreamingActive) {
|
||||||
switch (block.type) {
|
state.beginStep()
|
||||||
case 'text':
|
chunks.push({
|
||||||
chunks.push(...generateTextChunks(messageId, block.text, message))
|
type: 'start-step',
|
||||||
break
|
request: { body: '' },
|
||||||
case 'tool_use':
|
warnings: []
|
||||||
chunks.push({
|
})
|
||||||
type: 'tool-call',
|
}
|
||||||
toolCallId: block.id,
|
|
||||||
toolName: block.name,
|
const textId = message.uuid?.toString() || generateMessageId()
|
||||||
input: block.input,
|
chunks.push({
|
||||||
providerExecuted: true,
|
type: 'text-start',
|
||||||
providerMetadata: sdkMessageToProviderMetadata(message)
|
id: textId,
|
||||||
})
|
providerMetadata
|
||||||
contentBlockState.set(block.id, {
|
})
|
||||||
type: 'tool-call',
|
chunks.push({
|
||||||
toolCallId: block.id,
|
type: 'text-delta',
|
||||||
toolName: block.name,
|
id: textId,
|
||||||
input: block.input
|
text: content,
|
||||||
})
|
providerMetadata
|
||||||
break
|
})
|
||||||
case 'tool_result': {
|
chunks.push({
|
||||||
logger.silly('Handling tool result:', { block, content: contentBlockState })
|
type: 'text-end',
|
||||||
const hasToolCall = contentBlockState.has(block.tool_use_id)
|
id: textId,
|
||||||
const toolCall = contentBlockState.get(block.tool_use_id) as toolCallBlock
|
providerMetadata
|
||||||
chunks.push({
|
})
|
||||||
type: 'tool-result',
|
return finalizeNonStreamingStep(message, state, chunks)
|
||||||
toolCallId: block.tool_use_id,
|
}
|
||||||
toolName: hasToolCall ? toolCall.toolName : 'Unknown',
|
|
||||||
input: hasToolCall ? toolCall.input : '',
|
if (!Array.isArray(content)) {
|
||||||
output: block.content
|
return chunks
|
||||||
})
|
}
|
||||||
break
|
|
||||||
|
const textBlocks: string[] = []
|
||||||
|
|
||||||
|
for (const block of content) {
|
||||||
|
switch (block.type) {
|
||||||
|
case 'text':
|
||||||
|
if (!isStreamingActive) {
|
||||||
|
textBlocks.push(block.text)
|
||||||
}
|
}
|
||||||
default:
|
break
|
||||||
logger.warn('Unknown content block type in user/assistant message:', {
|
case 'tool_use':
|
||||||
type: block.type
|
handleAssistantToolUse(block as ToolUseContent, providerMetadata, state, chunks)
|
||||||
})
|
break
|
||||||
chunks.push({
|
default:
|
||||||
type: 'raw',
|
logger.warn('Unhandled assistant content block', { type: (block as any).type })
|
||||||
rawValue: block
|
break
|
||||||
})
|
}
|
||||||
break
|
}
|
||||||
|
|
||||||
|
if (!isStreamingActive && textBlocks.length > 0) {
|
||||||
|
const id = message.uuid?.toString() || generateMessageId()
|
||||||
|
state.beginStep()
|
||||||
|
chunks.push({
|
||||||
|
type: 'start-step',
|
||||||
|
request: { body: '' },
|
||||||
|
warnings: []
|
||||||
|
})
|
||||||
|
chunks.push({
|
||||||
|
type: 'text-start',
|
||||||
|
id,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
chunks.push({
|
||||||
|
type: 'text-delta',
|
||||||
|
id,
|
||||||
|
text: textBlocks.join(''),
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
chunks.push({
|
||||||
|
type: 'text-end',
|
||||||
|
id,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
return finalizeNonStreamingStep(message, state, chunks)
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers tool invocations with the stream state so that later tool results
|
||||||
|
* can be matched with the originating call.
|
||||||
|
*/
|
||||||
|
function handleAssistantToolUse(
|
||||||
|
block: ToolUseContent,
|
||||||
|
providerMetadata: ProviderMetadata,
|
||||||
|
state: ClaudeStreamState,
|
||||||
|
chunks: AgentStreamPart[]
|
||||||
|
): void {
|
||||||
|
chunks.push({
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: block.id,
|
||||||
|
toolName: block.name,
|
||||||
|
input: block.input,
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
state.completeToolBlock(block.id, block.input, providerMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits the terminating `finish-step` frame for non-streamed responses and
|
||||||
|
* clears the currently active step in the state tracker.
|
||||||
|
*/
|
||||||
|
function finalizeNonStreamingStep(
|
||||||
|
message: Extract<SDKMessage, { type: 'assistant' }>,
|
||||||
|
state: ClaudeStreamState,
|
||||||
|
chunks: AgentStreamPart[]
|
||||||
|
): AgentStreamPart[] {
|
||||||
|
const usage = calculateUsageFromMessage(message)
|
||||||
|
const finishReason = inferFinishReason(message.message.stop_reason)
|
||||||
|
chunks.push({
|
||||||
|
type: 'finish-step',
|
||||||
|
response: {
|
||||||
|
id: message.uuid,
|
||||||
|
timestamp: new Date(),
|
||||||
|
modelId: message.message.model ?? ''
|
||||||
|
},
|
||||||
|
usage: usage ?? emptyUsage,
|
||||||
|
finishReason,
|
||||||
|
providerMetadata: sdkMessageToProviderMetadata(message)
|
||||||
|
})
|
||||||
|
state.resetStep()
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts user-originated websocket frames (text, tool results, etc.) into
|
||||||
|
* the AiSDK format. Tool results are matched back to pending tool calls via the
|
||||||
|
* shared `ClaudeStreamState` instance.
|
||||||
|
*/
|
||||||
|
function handleUserMessage(
|
||||||
|
message: Extract<SDKMessage, { type: 'user' }>,
|
||||||
|
state: ClaudeStreamState
|
||||||
|
): AgentStreamPart[] {
|
||||||
|
const chunks: AgentStreamPart[] = []
|
||||||
|
const providerMetadata = sdkMessageToProviderMetadata(message)
|
||||||
|
const content = message.message.content
|
||||||
|
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
if (!content) {
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = message.uuid?.toString() || generateMessageId()
|
||||||
|
chunks.push({
|
||||||
|
type: 'text-start',
|
||||||
|
id,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
chunks.push({
|
||||||
|
type: 'text-delta',
|
||||||
|
id,
|
||||||
|
text: content,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
chunks.push({
|
||||||
|
type: 'text-end',
|
||||||
|
id,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const block of content) {
|
||||||
|
if (block.type === 'tool_result') {
|
||||||
|
const toolResult = block as ToolResultContent
|
||||||
|
const pendingCall = state.consumePendingToolCall(toolResult.tool_use_id)
|
||||||
|
if (toolResult.is_error) {
|
||||||
|
chunks.push({
|
||||||
|
type: 'tool-error',
|
||||||
|
toolCallId: toolResult.tool_use_id,
|
||||||
|
toolName: pendingCall?.toolName ?? 'unknown',
|
||||||
|
input: pendingCall?.input,
|
||||||
|
error: toolResult.content,
|
||||||
|
providerExecuted: true
|
||||||
|
} as AgentStreamPart)
|
||||||
|
} else {
|
||||||
|
chunks.push({
|
||||||
|
type: 'tool-result',
|
||||||
|
toolCallId: toolResult.tool_use_id,
|
||||||
|
toolName: pendingCall?.toolName ?? 'unknown',
|
||||||
|
input: pendingCall?.input,
|
||||||
|
output: toolResult.content,
|
||||||
|
providerExecuted: true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
} else if (block.type === 'text') {
|
||||||
|
const id = message.uuid?.toString() || generateMessageId()
|
||||||
|
chunks.push({
|
||||||
|
type: 'text-start',
|
||||||
|
id,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
chunks.push({
|
||||||
|
type: 'text-delta',
|
||||||
|
id,
|
||||||
|
text: (block as { text: string }).text,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
chunks.push({
|
||||||
|
type: 'text-end',
|
||||||
|
id,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.warn('Unhandled user content block', { type: (block as any).type })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return chunks
|
return chunks
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle stream events (real-time streaming)
|
/**
|
||||||
function handleStreamEvent(message: Extract<SDKMessage, { type: 'stream_event' }>): AgentStreamPart[] {
|
* Handles the fine-grained real-time streaming protocol where Anthropic emits
|
||||||
|
* discrete events for message lifecycle, content blocks, and usage deltas.
|
||||||
|
*/
|
||||||
|
function handleStreamEvent(
|
||||||
|
message: Extract<SDKMessage, { type: 'stream_event' }>,
|
||||||
|
state: ClaudeStreamState
|
||||||
|
): AgentStreamPart[] {
|
||||||
const chunks: AgentStreamPart[] = []
|
const chunks: AgentStreamPart[] = []
|
||||||
const event = message.event
|
const providerMetadata = sdkMessageToProviderMetadata(message)
|
||||||
const blockKey = `${message.uuid ?? message.session_id ?? 'session'}:${event.type}`
|
const { event } = message
|
||||||
logger.silly('Handling stream event:', { event })
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'message_start':
|
case 'message_start':
|
||||||
// No specific UI chunk needed for message start in this protocol
|
state.beginStep()
|
||||||
|
chunks.push({
|
||||||
|
type: 'start-step',
|
||||||
|
request: { body: '' },
|
||||||
|
warnings: []
|
||||||
|
})
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'content_block_start':
|
case 'content_block_start':
|
||||||
switch (event.content_block.type) {
|
handleContentBlockStart(event.index, event.content_block, providerMetadata, state, chunks)
|
||||||
case 'text': {
|
|
||||||
contentBlockState.set(blockKey, { type: 'text' })
|
|
||||||
chunks.push({
|
|
||||||
type: 'text-start',
|
|
||||||
id: String(event.index),
|
|
||||||
providerMetadata: {
|
|
||||||
...sdkMessageToProviderMetadata(message),
|
|
||||||
anthropic: {
|
|
||||||
uuid: message.uuid,
|
|
||||||
session_id: message.session_id,
|
|
||||||
content_block_index: event.index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'tool_use': {
|
|
||||||
contentBlockState.set(event.content_block.id, {
|
|
||||||
type: 'tool-call',
|
|
||||||
toolCallId: event.content_block.id,
|
|
||||||
toolName: event.content_block.name,
|
|
||||||
input: ''
|
|
||||||
})
|
|
||||||
chunks.push({
|
|
||||||
type: 'tool-call',
|
|
||||||
toolCallId: event.content_block.id,
|
|
||||||
toolName: event.content_block.name,
|
|
||||||
input: event.content_block.input,
|
|
||||||
providerExecuted: true,
|
|
||||||
providerMetadata: sdkMessageToProviderMetadata(message)
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'content_block_delta':
|
|
||||||
switch (event.delta.type) {
|
|
||||||
case 'text_delta': {
|
|
||||||
chunks.push({
|
|
||||||
type: 'text-delta',
|
|
||||||
id: String(event.index),
|
|
||||||
text: event.delta.text,
|
|
||||||
providerMetadata: {
|
|
||||||
...sdkMessageToProviderMetadata(message),
|
|
||||||
anthropic: {
|
|
||||||
uuid: message.uuid,
|
|
||||||
session_id: message.session_id,
|
|
||||||
content_block_index: event.index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
// case 'thinking_delta': {
|
|
||||||
// chunks.push({
|
|
||||||
// type: 'reasoning-delta',
|
|
||||||
// id: String(event.index),
|
|
||||||
// text: event.delta.thinking,
|
|
||||||
// });
|
|
||||||
// break
|
|
||||||
// }
|
|
||||||
// case 'signature_delta': {
|
|
||||||
// if (blockType === 'thinking') {
|
|
||||||
// chunks.push({
|
|
||||||
// type: 'reasoning-delta',
|
|
||||||
// id: String(event.index),
|
|
||||||
// text: '',
|
|
||||||
// providerMetadata: {
|
|
||||||
// ...sdkMessageToProviderMetadata(message),
|
|
||||||
// anthropic: {
|
|
||||||
// uuid: message.uuid,
|
|
||||||
// session_id: message.session_id,
|
|
||||||
// content_block_index: event.index,
|
|
||||||
// signature: event.delta.signature
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
// break
|
|
||||||
// }
|
|
||||||
case 'input_json_delta': {
|
|
||||||
const contentBlock = contentBlockState.get(blockKey)
|
|
||||||
if (contentBlock && contentBlock.type === 'tool-call') {
|
|
||||||
contentBlockState.set(blockKey, {
|
|
||||||
...contentBlock,
|
|
||||||
input: `${contentBlock.input ?? ''}${event.delta.partial_json ?? ''}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'content_block_stop':
|
case 'content_block_delta':
|
||||||
{
|
handleContentBlockDelta(event.index, event.delta, providerMetadata, state, chunks)
|
||||||
const contentBlock = contentBlockState.get(blockKey)
|
break
|
||||||
if (contentBlock?.type === 'text') {
|
|
||||||
|
case 'content_block_stop': {
|
||||||
|
const block = state.closeBlock(event.index)
|
||||||
|
if (!block) {
|
||||||
|
logger.warn('Received content_block_stop for unknown index', { index: event.index })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (block.kind) {
|
||||||
|
case 'text':
|
||||||
chunks.push({
|
chunks.push({
|
||||||
type: 'text-end',
|
type: 'text-end',
|
||||||
id: String(event.index)
|
id: block.id,
|
||||||
|
providerMetadata
|
||||||
})
|
})
|
||||||
}
|
break
|
||||||
contentBlockState.delete(blockKey)
|
case 'reasoning':
|
||||||
|
chunks.push({
|
||||||
|
type: 'reasoning-end',
|
||||||
|
id: block.id,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'tool':
|
||||||
|
chunks.push({
|
||||||
|
type: 'tool-input-end',
|
||||||
|
id: block.toolCallId,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'message_delta':
|
}
|
||||||
// Handle usage updates or other message-level deltas
|
|
||||||
|
case 'message_delta': {
|
||||||
|
const finishReason = event.delta.stop_reason
|
||||||
|
? mapStopReason(event.delta.stop_reason as BetaStopReason)
|
||||||
|
: undefined
|
||||||
|
const usage = convertUsage(event.usage)
|
||||||
|
state.setPendingUsage(usage, finishReason)
|
||||||
break
|
break
|
||||||
case 'message_stop':
|
}
|
||||||
// This could signal the end of the message
|
|
||||||
|
case 'message_stop': {
|
||||||
|
const pending = state.getPendingUsage()
|
||||||
|
chunks.push({
|
||||||
|
type: 'finish-step',
|
||||||
|
response: {
|
||||||
|
id: message.uuid,
|
||||||
|
timestamp: new Date(),
|
||||||
|
modelId: ''
|
||||||
|
},
|
||||||
|
usage: pending.usage ?? emptyUsage,
|
||||||
|
finishReason: pending.finishReason ?? 'stop',
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
state.resetStep()
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
logger.warn('Unknown stream event type:', { type: (event as any).type })
|
logger.warn('Unknown stream event type', { type: (event as any).type })
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
return chunks
|
return chunks
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle system messages
|
/**
|
||||||
|
* Opens the appropriate block type when Claude starts streaming a new content
|
||||||
|
* section so later deltas know which logical entity to append to.
|
||||||
|
*/
|
||||||
|
function handleContentBlockStart(
|
||||||
|
index: number,
|
||||||
|
contentBlock: any,
|
||||||
|
providerMetadata: ProviderMetadata,
|
||||||
|
state: ClaudeStreamState,
|
||||||
|
chunks: AgentStreamPart[]
|
||||||
|
): void {
|
||||||
|
switch (contentBlock.type) {
|
||||||
|
case 'text': {
|
||||||
|
const block = state.openTextBlock(index, generateMessageId())
|
||||||
|
chunks.push({
|
||||||
|
type: 'text-start',
|
||||||
|
id: block.id,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'thinking':
|
||||||
|
case 'redacted_thinking': {
|
||||||
|
const block = state.openReasoningBlock(index, generateMessageId(), contentBlock.type === 'redacted_thinking')
|
||||||
|
chunks.push({
|
||||||
|
type: 'reasoning-start',
|
||||||
|
id: block.id,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'tool_use': {
|
||||||
|
const block = state.openToolBlock(index, {
|
||||||
|
toolCallId: contentBlock.id,
|
||||||
|
toolName: contentBlock.name,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
chunks.push({
|
||||||
|
type: 'tool-input-start',
|
||||||
|
id: block.toolCallId,
|
||||||
|
toolName: block.toolName,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
logger.warn('Unhandled content_block_start type', { type: contentBlock.type })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies incremental deltas to the active block (text, thinking, tool input)
|
||||||
|
* and emits the translated AiSDK chunk immediately.
|
||||||
|
*/
|
||||||
|
function handleContentBlockDelta(
|
||||||
|
index: number,
|
||||||
|
delta: any,
|
||||||
|
providerMetadata: ProviderMetadata,
|
||||||
|
state: ClaudeStreamState,
|
||||||
|
chunks: AgentStreamPart[]
|
||||||
|
): void {
|
||||||
|
switch (delta.type) {
|
||||||
|
case 'text_delta': {
|
||||||
|
const block = state.appendTextDelta(index, delta.text)
|
||||||
|
if (!block) {
|
||||||
|
logger.warn('Received text_delta for unknown block', { index })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chunks.push({
|
||||||
|
type: 'text-delta',
|
||||||
|
id: block.id,
|
||||||
|
text: block.text,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'thinking_delta': {
|
||||||
|
const block = state.appendReasoningDelta(index, delta.thinking)
|
||||||
|
if (!block) {
|
||||||
|
logger.warn('Received thinking_delta for unknown block', { index })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chunks.push({
|
||||||
|
type: 'reasoning-delta',
|
||||||
|
id: block.id,
|
||||||
|
text: delta.thinking,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'signature_delta': {
|
||||||
|
const block = state.getBlock(index)
|
||||||
|
if (block && block.kind === 'reasoning') {
|
||||||
|
chunks.push({
|
||||||
|
type: 'reasoning-delta',
|
||||||
|
id: block.id,
|
||||||
|
text: '',
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'input_json_delta': {
|
||||||
|
const block = state.appendToolInputDelta(index, delta.partial_json)
|
||||||
|
if (!block) {
|
||||||
|
logger.warn('Received input_json_delta for unknown block', { index })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chunks.push({
|
||||||
|
type: 'tool-input-delta',
|
||||||
|
id: block.toolCallId,
|
||||||
|
delta: block.inputBuffer,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
logger.warn('Unhandled content_block_delta type', { type: delta.type })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System messages currently only deliver the session bootstrap payload. We
|
||||||
|
* forward it as both a `start` marker and a raw snapshot for diagnostics.
|
||||||
|
*/
|
||||||
function handleSystemMessage(message: Extract<SDKMessage, { type: 'system' }>): AgentStreamPart[] {
|
function handleSystemMessage(message: Extract<SDKMessage, { type: 'system' }>): AgentStreamPart[] {
|
||||||
const chunks: AgentStreamPart[] = []
|
const chunks: AgentStreamPart[] = []
|
||||||
switch (message.subtype) {
|
if (message.subtype === 'init') {
|
||||||
case 'init': {
|
chunks.push({
|
||||||
chunks.push({
|
type: 'start'
|
||||||
type: 'start'
|
})
|
||||||
})
|
chunks.push({
|
||||||
const rawValue: ClaudeCodeRawValue = {
|
type: 'raw',
|
||||||
|
rawValue: {
|
||||||
type: 'init',
|
type: 'init',
|
||||||
session_id: message.session_id,
|
session_id: message.session_id,
|
||||||
slash_commands: message.slash_commands,
|
slash_commands: message.slash_commands,
|
||||||
tools: message.tools,
|
tools: message.tools,
|
||||||
raw: message
|
raw: message
|
||||||
}
|
}
|
||||||
chunks.push({
|
})
|
||||||
type: 'raw',
|
|
||||||
rawValue
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return chunks
|
return chunks
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle result messages (completion with usage stats)
|
/**
|
||||||
|
* Terminal result messages arrive once the Claude Code session concludes.
|
||||||
|
* Successful runs yield a `finish` frame with aggregated usage metrics, while
|
||||||
|
* failures are surfaced as `error` frames.
|
||||||
|
*/
|
||||||
function handleResultMessage(message: Extract<SDKMessage, { type: 'result' }>): AgentStreamPart[] {
|
function handleResultMessage(message: Extract<SDKMessage, { type: 'result' }>): AgentStreamPart[] {
|
||||||
const chunks: AgentStreamPart[] = []
|
const chunks: AgentStreamPart[] = []
|
||||||
|
|
||||||
let usage: LanguageModelV2Usage | undefined
|
let usage: LanguageModelUsage | undefined
|
||||||
if ('usage' in message) {
|
if ('usage' in message) {
|
||||||
usage = {
|
usage = {
|
||||||
inputTokens: message.usage.input_tokens ?? 0,
|
inputTokens: message.usage.input_tokens ?? 0,
|
||||||
@ -322,10 +611,11 @@ function handleResultMessage(message: Extract<SDKMessage, { type: 'result' }>):
|
|||||||
totalTokens: (message.usage.input_tokens ?? 0) + (message.usage.output_tokens ?? 0)
|
totalTokens: (message.usage.input_tokens ?? 0) + (message.usage.output_tokens ?? 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.subtype === 'success') {
|
if (message.subtype === 'success') {
|
||||||
chunks.push({
|
chunks.push({
|
||||||
type: 'finish',
|
type: 'finish',
|
||||||
totalUsage: usage,
|
totalUsage: usage ?? emptyUsage,
|
||||||
finishReason: mapClaudeCodeFinishReason(message.subtype),
|
finishReason: mapClaudeCodeFinishReason(message.subtype),
|
||||||
providerMetadata: {
|
providerMetadata: {
|
||||||
...sdkMessageToProviderMetadata(message),
|
...sdkMessageToProviderMetadata(message),
|
||||||
@ -345,3 +635,60 @@ function handleResultMessage(message: Extract<SDKMessage, { type: 'result' }>):
|
|||||||
}
|
}
|
||||||
return chunks
|
return chunks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalises usage payloads so the caller always receives numeric values even
|
||||||
|
* when the provider omits certain fields.
|
||||||
|
*/
|
||||||
|
function convertUsage(
|
||||||
|
usage?: {
|
||||||
|
input_tokens?: number | null
|
||||||
|
output_tokens?: number | null
|
||||||
|
} | null
|
||||||
|
): LanguageModelUsage | undefined {
|
||||||
|
if (!usage) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const inputTokens = usage.input_tokens ?? 0
|
||||||
|
const outputTokens = usage.output_tokens ?? 0
|
||||||
|
return {
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
totalTokens: inputTokens + outputTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anthropic-only wrapper around {@link finishReasonMapping} that defaults to
|
||||||
|
* `unknown` to avoid surprising downstream consumers when new stop reasons are
|
||||||
|
* introduced.
|
||||||
|
*/
|
||||||
|
function mapStopReason(reason: BetaStopReason): FinishReason {
|
||||||
|
return finishReasonMapping[reason] ?? 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts token accounting details from an assistant message, if available.
|
||||||
|
*/
|
||||||
|
function calculateUsageFromMessage(
|
||||||
|
message: Extract<SDKMessage, { type: 'assistant' }>
|
||||||
|
): LanguageModelUsage | undefined {
|
||||||
|
const usage = message.message.usage
|
||||||
|
if (!usage) return undefined
|
||||||
|
return {
|
||||||
|
inputTokens: usage.input_tokens ?? 0,
|
||||||
|
outputTokens: usage.output_tokens ?? 0,
|
||||||
|
totalTokens: (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts Anthropic stop reasons into AiSDK finish reasons, falling back to a
|
||||||
|
* generic `stop` if the provider omits the detail entirely.
|
||||||
|
*/
|
||||||
|
function inferFinishReason(stopReason: BetaStopReason | null | undefined): FinishReason {
|
||||||
|
if (!stopReason) return 'stop'
|
||||||
|
return mapStopReason(stopReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ClaudeStreamState }
|
||||||
|
|||||||
@ -185,8 +185,7 @@ export class AiSdkToChunkAdapter {
|
|||||||
case 'reasoning-end':
|
case 'reasoning-end':
|
||||||
this.onChunk({
|
this.onChunk({
|
||||||
type: ChunkType.THINKING_COMPLETE,
|
type: ChunkType.THINKING_COMPLETE,
|
||||||
text: (chunk.providerMetadata?.metadata?.thinking_content as string) || '',
|
text: (chunk.providerMetadata?.metadata?.thinking_content as string) || final.reasoningContent
|
||||||
thinking_millsec: (chunk.providerMetadata?.metadata?.thinking_millsec as number) || 0
|
|
||||||
})
|
})
|
||||||
final.reasoningContent = ''
|
final.reasoningContent = ''
|
||||||
break
|
break
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
|
import { isNewApiProvider } from '@renderer/config/providers'
|
||||||
import { Provider } from '@renderer/types'
|
import { Provider } from '@renderer/types'
|
||||||
|
|
||||||
import { AihubmixAPIClient } from './aihubmix/AihubmixAPIClient'
|
import { AihubmixAPIClient } from './aihubmix/AihubmixAPIClient'
|
||||||
@ -45,7 +46,7 @@ export class ApiClientFactory {
|
|||||||
return instance
|
return instance
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider.id === 'new-api') {
|
if (isNewApiProvider(provider)) {
|
||||||
logger.debug(`Creating NewAPIClient for provider: ${provider.id}`)
|
logger.debug(`Creating NewAPIClient for provider: ${provider.id}`)
|
||||||
instance = new NewAPIClient(provider) as BaseApiClient
|
instance = new NewAPIClient(provider) as BaseApiClient
|
||||||
return instance
|
return instance
|
||||||
|
|||||||
@ -67,7 +67,9 @@ vi.mock('@renderer/config/models', () => ({
|
|||||||
silicon: [],
|
silicon: [],
|
||||||
defaultModel: []
|
defaultModel: []
|
||||||
},
|
},
|
||||||
isOpenAIModel: vi.fn(() => false)
|
isOpenAIModel: vi.fn(() => false),
|
||||||
|
glm45FlashModel: {},
|
||||||
|
qwen38bModel: {}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe('ApiClientFactory', () => {
|
describe('ApiClientFactory', () => {
|
||||||
|
|||||||
@ -35,18 +35,8 @@ vi.mock('@renderer/config/models', () => ({
|
|||||||
findTokenLimit: vi.fn().mockReturnValue(4096),
|
findTokenLimit: vi.fn().mockReturnValue(4096),
|
||||||
isFunctionCallingModel: vi.fn().mockReturnValue(false),
|
isFunctionCallingModel: vi.fn().mockReturnValue(false),
|
||||||
DEFAULT_MAX_TOKENS: 4096,
|
DEFAULT_MAX_TOKENS: 4096,
|
||||||
qwen38bModel: {
|
qwen38bModel: {},
|
||||||
id: 'Qwen/Qwen3-8B',
|
glm45FlashModel: {}
|
||||||
name: 'Qwen3-8B',
|
|
||||||
provider: 'cherryai',
|
|
||||||
group: 'Qwen'
|
|
||||||
},
|
|
||||||
glm45FlashModel: {
|
|
||||||
id: 'glm-4.5-flash',
|
|
||||||
name: 'GLM-4.5-Flash',
|
|
||||||
provider: 'cherryai',
|
|
||||||
group: 'GLM-4.5'
|
|
||||||
}
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@renderer/services/AssistantService', () => ({
|
vi.mock('@renderer/services/AssistantService', () => ({
|
||||||
|
|||||||
@ -143,12 +143,14 @@ export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageMo
|
|||||||
const tagName = {
|
const tagName = {
|
||||||
reasoning: 'reasoning',
|
reasoning: 'reasoning',
|
||||||
think: 'think',
|
think: 'think',
|
||||||
thought: 'thought'
|
thought: 'thought',
|
||||||
|
seedThink: 'seed:think'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReasoningTagName(modelId: string | undefined): string {
|
function getReasoningTagName(modelId: string | undefined): string {
|
||||||
if (modelId?.includes('gpt-oss')) return tagName.reasoning
|
if (modelId?.includes('gpt-oss')) return tagName.reasoning
|
||||||
if (modelId?.includes('gemini')) return tagName.thought
|
if (modelId?.includes('gemini')) return tagName.thought
|
||||||
|
if (modelId?.includes('seed-oss-36b')) return tagName.seedThink
|
||||||
return tagName.think
|
return tagName.think
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,18 +7,14 @@ export default definePlugin({
|
|||||||
transformStream: () => () => {
|
transformStream: () => () => {
|
||||||
// === 时间跟踪状态 ===
|
// === 时间跟踪状态 ===
|
||||||
let thinkingStartTime = 0
|
let thinkingStartTime = 0
|
||||||
let hasStartedThinking = false
|
|
||||||
let accumulatedThinkingContent = ''
|
let accumulatedThinkingContent = ''
|
||||||
let reasoningBlockId = ''
|
|
||||||
|
|
||||||
return new TransformStream<TextStreamPart<ToolSet>, TextStreamPart<ToolSet>>({
|
return new TransformStream<TextStreamPart<ToolSet>, TextStreamPart<ToolSet>>({
|
||||||
transform(chunk: TextStreamPart<ToolSet>, controller: TransformStreamDefaultController<TextStreamPart<ToolSet>>) {
|
transform(chunk: TextStreamPart<ToolSet>, controller: TransformStreamDefaultController<TextStreamPart<ToolSet>>) {
|
||||||
// === 处理 reasoning 类型 ===
|
// === 处理 reasoning 类型 ===
|
||||||
if (chunk.type === 'reasoning-start') {
|
if (chunk.type === 'reasoning-start') {
|
||||||
controller.enqueue(chunk)
|
controller.enqueue(chunk)
|
||||||
hasStartedThinking = true
|
|
||||||
thinkingStartTime = performance.now()
|
thinkingStartTime = performance.now()
|
||||||
reasoningBlockId = chunk.id
|
|
||||||
} else if (chunk.type === 'reasoning-delta') {
|
} else if (chunk.type === 'reasoning-delta') {
|
||||||
accumulatedThinkingContent += chunk.text
|
accumulatedThinkingContent += chunk.text
|
||||||
controller.enqueue({
|
controller.enqueue({
|
||||||
@ -32,21 +28,6 @@ export default definePlugin({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else if (chunk.type === 'reasoning-end' && hasStartedThinking) {
|
|
||||||
controller.enqueue({
|
|
||||||
type: 'reasoning-end',
|
|
||||||
id: reasoningBlockId,
|
|
||||||
providerMetadata: {
|
|
||||||
metadata: {
|
|
||||||
thinking_millsec: performance.now() - thinkingStartTime,
|
|
||||||
thinking_content: accumulatedThinkingContent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
accumulatedThinkingContent = ''
|
|
||||||
hasStartedThinking = false
|
|
||||||
thinkingStartTime = 0
|
|
||||||
reasoningBlockId = ''
|
|
||||||
} else {
|
} else {
|
||||||
controller.enqueue(chunk)
|
controller.enqueue(chunk)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
type ProviderSettingsMap
|
type ProviderSettingsMap
|
||||||
} from '@cherrystudio/ai-core/provider'
|
} from '@cherrystudio/ai-core/provider'
|
||||||
import { isOpenAIChatCompletionOnlyModel } from '@renderer/config/models'
|
import { isOpenAIChatCompletionOnlyModel } from '@renderer/config/models'
|
||||||
|
import { isNewApiProvider } from '@renderer/config/providers'
|
||||||
import {
|
import {
|
||||||
getAwsBedrockAccessKeyId,
|
getAwsBedrockAccessKeyId,
|
||||||
getAwsBedrockRegion,
|
getAwsBedrockRegion,
|
||||||
@ -65,7 +66,7 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
|
|||||||
if (provider.id === 'aihubmix') {
|
if (provider.id === 'aihubmix') {
|
||||||
return aihubmixProviderCreator(model, provider)
|
return aihubmixProviderCreator(model, provider)
|
||||||
}
|
}
|
||||||
if (provider.id === 'new-api') {
|
if (isNewApiProvider(provider)) {
|
||||||
return newApiResolverCreator(model, provider)
|
return newApiResolverCreator(model, provider)
|
||||||
}
|
}
|
||||||
if (provider.id === 'vertexai') {
|
if (provider.id === 'vertexai') {
|
||||||
|
|||||||
@ -52,7 +52,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
// Don't disable reasoning for models that require it
|
// Don't disable reasoning for models that require it
|
||||||
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model)) {
|
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model) || model.id.includes('seed-oss')) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
return { reasoning: { enabled: false, exclude: true } }
|
return { reasoning: { enabled: false, exclude: true } }
|
||||||
@ -112,6 +112,8 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
|||||||
return {
|
return {
|
||||||
enable_thinking: true
|
enable_thinking: true
|
||||||
}
|
}
|
||||||
|
case SystemProviderIds.hunyuan:
|
||||||
|
case SystemProviderIds['tencent-cloud-ti']:
|
||||||
case SystemProviderIds.doubao:
|
case SystemProviderIds.doubao:
|
||||||
return {
|
return {
|
||||||
thinking: {
|
thinking: {
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||||
import { MinAppType } from '@renderer/types'
|
import { MinAppType } from '@renderer/types'
|
||||||
import { FC } from 'react'
|
import { FC } from 'react'
|
||||||
import styled from 'styled-components'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
app: MinAppType
|
app: MinAppType
|
||||||
@ -11,31 +10,52 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MinAppIcon: FC<Props> = ({ app, size = 48, style, sidebar = false }) => {
|
const MinAppIcon: FC<Props> = ({ app, size = 48, style, sidebar = false }) => {
|
||||||
|
// First try to find in DEFAULT_MIN_APPS for predefined styling
|
||||||
const _app = DEFAULT_MIN_APPS.find((item) => item.id === app.id)
|
const _app = DEFAULT_MIN_APPS.find((item) => item.id === app.id)
|
||||||
|
|
||||||
if (!_app) {
|
// If found in DEFAULT_MIN_APPS, use predefined styling
|
||||||
return null
|
if (_app) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={_app.logo}
|
||||||
|
className="select-none rounded-2xl"
|
||||||
|
style={{
|
||||||
|
border: _app.bodered ? '0.5px solid var(--color-border)' : 'none',
|
||||||
|
width: `${size}px`,
|
||||||
|
height: `${size}px`,
|
||||||
|
backgroundColor: _app.background,
|
||||||
|
userSelect: 'none',
|
||||||
|
...(sidebar ? {} : app.style),
|
||||||
|
...style
|
||||||
|
}}
|
||||||
|
draggable={false}
|
||||||
|
alt={app.name || 'MinApp Icon'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// If not found in DEFAULT_MIN_APPS but app has logo, use it (for temporary apps)
|
||||||
<Container
|
if (app.logo) {
|
||||||
src={_app.logo}
|
return (
|
||||||
style={{
|
<img
|
||||||
border: _app.bodered ? '0.5px solid var(--color-border)' : 'none',
|
src={app.logo}
|
||||||
width: `${size}px`,
|
className="select-none rounded-2xl"
|
||||||
height: `${size}px`,
|
style={{
|
||||||
backgroundColor: _app.background,
|
border: 'none',
|
||||||
...(sidebar ? {} : app.style),
|
width: `${size}px`,
|
||||||
...style
|
height: `${size}px`,
|
||||||
}}
|
backgroundColor: 'transparent',
|
||||||
/>
|
userSelect: 'none',
|
||||||
)
|
...(sidebar ? {} : app.style),
|
||||||
|
...style
|
||||||
|
}}
|
||||||
|
draggable={false}
|
||||||
|
alt={app.name || 'MinApp Icon'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const Container = styled.img`
|
|
||||||
border-radius: 16px;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-drag: none;
|
|
||||||
`
|
|
||||||
|
|
||||||
export default MinAppIcon
|
export default MinAppIcon
|
||||||
|
|||||||
@ -1,15 +1,11 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
exports[`MinAppIcon > should render correctly with various props 1`] = `
|
exports[`MinAppIcon > should render correctly with various props 1`] = `
|
||||||
.c0 {
|
|
||||||
border-radius: 16px;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-drag: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
<img
|
<img
|
||||||
class="c0"
|
alt="Test App"
|
||||||
|
class="select-none rounded-2xl"
|
||||||
|
draggable="false"
|
||||||
src="/test-logo-1.png"
|
src="/test-logo-1.png"
|
||||||
style="border: 0.5px solid var(--color-border); width: 64px; height: 64px; background-color: rgb(240, 240, 240); opacity: 0.8; transform: scale(1.1); margin-top: 10px;"
|
style="border: 0.5px solid var(--color-border); width: 64px; height: 64px; background-color: rgb(240, 240, 240); user-select: none; opacity: 0.8; transform: scale(1.1); margin-top: 10px;"
|
||||||
/>
|
/>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -180,7 +180,7 @@ const PopupContainer: React.FC<Props> = ({ model, filter: baseFilter, showTagFil
|
|||||||
key: `provider-${p.id}`,
|
key: `provider-${p.id}`,
|
||||||
type: 'group',
|
type: 'group',
|
||||||
name: getFancyProviderName(p),
|
name: getFancyProviderName(p),
|
||||||
actions: (
|
actions: p.id !== 'cherryai' && (
|
||||||
<Tooltip title={t('navigate.provider_settings')} mouseEnterDelay={0.5} mouseLeaveDelay={0}>
|
<Tooltip title={t('navigate.provider_settings')} mouseEnterDelay={0.5} mouseLeaveDelay={0}>
|
||||||
<Settings2
|
<Settings2
|
||||||
size={12}
|
size={12}
|
||||||
|
|||||||
@ -18,7 +18,8 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
|
|||||||
// Sanitize the SVG content
|
// Sanitize the SVG content
|
||||||
const sanitizedContent = DOMPurify.sanitize(svgContent, {
|
const sanitizedContent = DOMPurify.sanitize(svgContent, {
|
||||||
ADD_TAGS: ['animate', 'foreignObject', 'use'],
|
ADD_TAGS: ['animate', 'foreignObject', 'use'],
|
||||||
ADD_ATTR: ['from', 'to']
|
ADD_ATTR: ['from', 'to'],
|
||||||
|
HTML_INTEGRATION_POINTS: { foreignobject: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' })
|
const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' })
|
||||||
@ -36,6 +37,7 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
|
|||||||
border-radius: var(--shadow-host-border-radius);
|
border-radius: var(--shadow-host-border-radius);
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
overflow: hidden; /* Prevent scrollbars, as scaling is now handled */
|
overflow: hidden; /* Prevent scrollbars, as scaling is now handled */
|
||||||
|
white-space: normal;
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { PlusOutlined } from '@ant-design/icons'
|
import { PlusOutlined } from '@ant-design/icons'
|
||||||
|
import { loggerService } from '@logger'
|
||||||
import { Sortable, useDndReorder } from '@renderer/components/dnd'
|
import { Sortable, useDndReorder } from '@renderer/components/dnd'
|
||||||
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
|
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
|
||||||
import { isMac } from '@renderer/config/constant'
|
import { isMac } from '@renderer/config/constant'
|
||||||
@ -12,9 +13,10 @@ import tabsService from '@renderer/services/TabsService'
|
|||||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||||
import type { Tab } from '@renderer/store/tabs'
|
import type { Tab } from '@renderer/store/tabs'
|
||||||
import { addTab, removeTab, setActiveTab, setTabs } from '@renderer/store/tabs'
|
import { addTab, removeTab, setActiveTab, setTabs } from '@renderer/store/tabs'
|
||||||
import { ThemeMode } from '@renderer/types'
|
import { MinAppType, ThemeMode } from '@renderer/types'
|
||||||
import { classNames } from '@renderer/utils'
|
import { classNames } from '@renderer/utils'
|
||||||
import { Tooltip } from 'antd'
|
import { Tooltip } from 'antd'
|
||||||
|
import { LRUCache } from 'lru-cache'
|
||||||
import {
|
import {
|
||||||
FileSearch,
|
FileSearch,
|
||||||
Folder,
|
Folder,
|
||||||
@ -45,14 +47,40 @@ interface TabsContainerProps {
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTabIcon = (tabId: string, minapps: any[]): React.ReactNode | undefined => {
|
const logger = loggerService.withContext('TabContainer')
|
||||||
|
|
||||||
|
const getTabIcon = (
|
||||||
|
tabId: string,
|
||||||
|
minapps: MinAppType[],
|
||||||
|
minAppsCache?: LRUCache<string, MinAppType>
|
||||||
|
): React.ReactNode | undefined => {
|
||||||
// Check if it's a minapp tab (format: apps:appId)
|
// Check if it's a minapp tab (format: apps:appId)
|
||||||
if (tabId.startsWith('apps:')) {
|
if (tabId.startsWith('apps:')) {
|
||||||
const appId = tabId.replace('apps:', '')
|
const appId = tabId.replace('apps:', '')
|
||||||
const app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
let app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
||||||
|
|
||||||
|
// If not found in permanent apps, search in temporary apps cache
|
||||||
|
// The cache stores apps opened via openSmartMinapp() for top navbar mode
|
||||||
|
// These are temporary MinApps that were opened but not yet saved to user's config
|
||||||
|
// The cache is LRU (Least Recently Used) with max size from settings
|
||||||
|
// Cache validity: Apps in cache are currently active/recently used, not outdated
|
||||||
|
if (!app && minAppsCache) {
|
||||||
|
app = minAppsCache.get(appId)
|
||||||
|
|
||||||
|
// Defensive programming: If app not found in cache but tab exists,
|
||||||
|
// the cache entry may have been evicted due to LRU policy
|
||||||
|
// Log warning for debugging potential sync issues
|
||||||
|
if (!app) {
|
||||||
|
logger.warn(`MinApp ${appId} not found in cache, using fallback icon`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (app) {
|
if (app) {
|
||||||
return <MinAppIcon size={14} app={app} />
|
return <MinAppIcon size={14} app={app} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: If no app found (cache evicted), show default icon
|
||||||
|
return <LayoutGrid size={14} />
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (tabId) {
|
switch (tabId) {
|
||||||
@ -94,7 +122,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
|||||||
const activeTabId = useAppSelector((state) => state.tabs.activeTabId)
|
const activeTabId = useAppSelector((state) => state.tabs.activeTabId)
|
||||||
const isFullscreen = useFullscreen()
|
const isFullscreen = useFullscreen()
|
||||||
const { settedTheme, toggleTheme } = useTheme()
|
const { settedTheme, toggleTheme } = useTheme()
|
||||||
const { hideMinappPopup } = useMinappPopup()
|
const { hideMinappPopup, minAppsCache } = useMinappPopup()
|
||||||
const { minapps } = useMinapps()
|
const { minapps } = useMinapps()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@ -112,8 +140,23 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
|||||||
// Check if it's a minapp tab
|
// Check if it's a minapp tab
|
||||||
if (tabId.startsWith('apps:')) {
|
if (tabId.startsWith('apps:')) {
|
||||||
const appId = tabId.replace('apps:', '')
|
const appId = tabId.replace('apps:', '')
|
||||||
const app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
let app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
||||||
return app ? app.name : 'MinApp'
|
|
||||||
|
// If not found in permanent apps, search in temporary apps cache
|
||||||
|
// This ensures temporary MinApps display proper titles while being used
|
||||||
|
// The LRU cache automatically manages app lifecycle and prevents memory leaks
|
||||||
|
if (!app && minAppsCache) {
|
||||||
|
app = minAppsCache.get(appId)
|
||||||
|
|
||||||
|
// Defensive programming: If app not found in cache but tab exists,
|
||||||
|
// the cache entry may have been evicted due to LRU policy
|
||||||
|
if (!app) {
|
||||||
|
logger.warn(`MinApp ${appId} not found in cache, using fallback title`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return app name if found, otherwise use fallback with appId
|
||||||
|
return app ? app.name : `MinApp-${appId}`
|
||||||
}
|
}
|
||||||
return getTitleLabel(tabId)
|
return getTitleLabel(tabId)
|
||||||
}
|
}
|
||||||
@ -196,7 +239,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
|||||||
renderItem={(tab) => (
|
renderItem={(tab) => (
|
||||||
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
|
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
|
||||||
<TabHeader>
|
<TabHeader>
|
||||||
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps)}</TabIcon>}
|
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps, minAppsCache)}</TabIcon>}
|
||||||
<TabTitle>{getTabTitle(tab.id)}</TabTitle>
|
<TabTitle>{getTabTitle(tab.id)}</TabTitle>
|
||||||
</TabHeader>
|
</TabHeader>
|
||||||
{tab.id !== 'home' && (
|
{tab.id !== 'home' && (
|
||||||
@ -259,7 +302,7 @@ const TabsBar = styled.div<{ $isFullscreen: boolean }>`
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? 'env(titlebar-area-x)' : '15px')};
|
padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? 'calc(env(titlebar-area-x) + 4px)' : '15px')};
|
||||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : '0')};
|
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : '0')};
|
||||||
height: var(--navbar-height);
|
height: var(--navbar-height);
|
||||||
min-height: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? 'env(titlebar-area-height)' : '')};
|
min-height: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? 'env(titlebar-area-height)' : '')};
|
||||||
|
|||||||
@ -88,6 +88,7 @@ const NavbarCenterContainer = styled.div`
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 ${isMac ? '20px' : 0};
|
padding: 0 ${isMac ? '20px' : 0};
|
||||||
|
padding-left: 10px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--color-text-1);
|
color: var(--color-text-1);
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -108,7 +109,8 @@ const NavbarMainContainer = styled.div<{ $isFullscreen: boolean }>`
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 ${isMac ? '20px' : 0};
|
padding-right: ${isMac ? '20px' : 0};
|
||||||
|
padding-left: 10px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--color-text-1);
|
color: var(--color-text-1);
|
||||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
|
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
|
||||||
|
|||||||
@ -17,6 +17,7 @@ interface ItemRendererProps<T> {
|
|||||||
transform?: Transform | null
|
transform?: Transform | null
|
||||||
transition?: string | null
|
transition?: string | null
|
||||||
listeners?: DraggableSyntheticListeners
|
listeners?: DraggableSyntheticListeners
|
||||||
|
itemStyle?: React.CSSProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ItemRenderer<T>({
|
export function ItemRenderer<T>({
|
||||||
@ -30,6 +31,7 @@ export function ItemRenderer<T>({
|
|||||||
transform,
|
transform,
|
||||||
transition,
|
transition,
|
||||||
listeners,
|
listeners,
|
||||||
|
itemStyle,
|
||||||
...props
|
...props
|
||||||
}: ItemRendererProps<T>) {
|
}: ItemRendererProps<T>) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -44,7 +46,7 @@ export function ItemRenderer<T>({
|
|||||||
}
|
}
|
||||||
}, [dragOverlay])
|
}, [dragOverlay])
|
||||||
|
|
||||||
const wrapperStyle = {
|
const style = {
|
||||||
transition,
|
transition,
|
||||||
transform: CSS.Transform.toString(transform ?? null)
|
transform: CSS.Transform.toString(transform ?? null)
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
@ -54,7 +56,7 @@ export function ItemRenderer<T>({
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
data-index={index}
|
data-index={index}
|
||||||
className={classNames({ dragOverlay: dragOverlay })}
|
className={classNames({ dragOverlay: dragOverlay })}
|
||||||
style={{ ...wrapperStyle }}>
|
style={{ ...style, ...itemStyle }}>
|
||||||
<DraggableItem
|
<DraggableItem
|
||||||
className={classNames({ dragging: dragging, dragOverlay: dragOverlay, ghost: ghost })}
|
className={classNames({ dragging: dragging, dragOverlay: dragOverlay, ghost: ghost })}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
|
|||||||
@ -61,6 +61,8 @@ interface SortableProps<T> {
|
|||||||
className?: string
|
className?: string
|
||||||
/** Item list style */
|
/** Item list style */
|
||||||
listStyle?: React.CSSProperties
|
listStyle?: React.CSSProperties
|
||||||
|
/** Item style */
|
||||||
|
itemStyle?: React.CSSProperties
|
||||||
/** Item gap */
|
/** Item gap */
|
||||||
gap?: number | string
|
gap?: number | string
|
||||||
/** Restrictions, shortcuts for some modifiers */
|
/** Restrictions, shortcuts for some modifiers */
|
||||||
@ -87,6 +89,7 @@ function Sortable<T>({
|
|||||||
showGhost = false,
|
showGhost = false,
|
||||||
className,
|
className,
|
||||||
listStyle,
|
listStyle,
|
||||||
|
itemStyle,
|
||||||
gap,
|
gap,
|
||||||
restrictions,
|
restrictions,
|
||||||
modifiers: customModifiers
|
modifiers: customModifiers
|
||||||
@ -195,19 +198,19 @@ function Sortable<T>({
|
|||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
useDragOverlay={useDragOverlay}
|
useDragOverlay={useDragOverlay}
|
||||||
showGhost={showGhost}
|
showGhost={showGhost}
|
||||||
|
itemStyle={itemStyle}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ListWrapper>
|
</ListWrapper>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
|
|
||||||
{useDragOverlay
|
{useDragOverlay &&
|
||||||
? createPortal(
|
createPortal(
|
||||||
<DragOverlay adjustScale dropAnimation={dropAnimation}>
|
<DragOverlay adjustScale dropAnimation={dropAnimation}>
|
||||||
{activeItem ? <ItemRenderer item={activeItem} renderItem={renderItem} dragOverlay /> : null}
|
{activeItem && <ItemRenderer item={activeItem} renderItem={renderItem} itemStyle={itemStyle} dragOverlay />}
|
||||||
</DragOverlay>,
|
</DragOverlay>,
|
||||||
document.body
|
document.body
|
||||||
)
|
)}
|
||||||
: null}
|
|
||||||
</DndContext>
|
</DndContext>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ interface SortableItemProps<T> {
|
|||||||
renderItem: RenderItemType<T>
|
renderItem: RenderItemType<T>
|
||||||
useDragOverlay?: boolean
|
useDragOverlay?: boolean
|
||||||
showGhost?: boolean
|
showGhost?: boolean
|
||||||
|
itemStyle?: React.CSSProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SortableItem<T>({
|
export function SortableItem<T>({
|
||||||
@ -18,7 +19,8 @@ export function SortableItem<T>({
|
|||||||
index,
|
index,
|
||||||
renderItem,
|
renderItem,
|
||||||
useDragOverlay = true,
|
useDragOverlay = true,
|
||||||
showGhost = true
|
showGhost = true,
|
||||||
|
itemStyle
|
||||||
}: SortableItemProps<T>) {
|
}: SortableItemProps<T>) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id
|
id
|
||||||
@ -36,6 +38,7 @@ export function SortableItem<T>({
|
|||||||
transform={transform}
|
transform={transform}
|
||||||
transition={transition}
|
transition={transition}
|
||||||
listeners={listeners}
|
listeners={listeners}
|
||||||
|
itemStyle={itemStyle}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -93,7 +93,17 @@ export function isSupportedThinkingTokenModel(model?: Model): boolean {
|
|||||||
// Specifically for DeepSeek V3.1. White list for now
|
// Specifically for DeepSeek V3.1. White list for now
|
||||||
if (isDeepSeekHybridInferenceModel(model)) {
|
if (isDeepSeekHybridInferenceModel(model)) {
|
||||||
return (
|
return (
|
||||||
['openrouter', 'dashscope', 'modelscope', 'doubao', 'silicon', 'nvidia', 'ppio'] satisfies SystemProviderId[]
|
[
|
||||||
|
'openrouter',
|
||||||
|
'dashscope',
|
||||||
|
'modelscope',
|
||||||
|
'doubao',
|
||||||
|
'silicon',
|
||||||
|
'nvidia',
|
||||||
|
'ppio',
|
||||||
|
'hunyuan',
|
||||||
|
'tencent-cloud-ti'
|
||||||
|
] satisfies SystemProviderId[]
|
||||||
).some((id) => id === model.provider)
|
).some((id) => id === model.provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -381,7 +391,8 @@ export function isReasoningModel(model?: Model): boolean {
|
|||||||
isDeepSeekHybridInferenceModel(model) ||
|
isDeepSeekHybridInferenceModel(model) ||
|
||||||
modelId.includes('magistral') ||
|
modelId.includes('magistral') ||
|
||||||
modelId.includes('minimax-m1') ||
|
modelId.includes('minimax-m1') ||
|
||||||
modelId.includes('pangu-pro-moe')
|
modelId.includes('pangu-pro-moe') ||
|
||||||
|
modelId.includes('seed-oss')
|
||||||
) {
|
) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -138,16 +138,6 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
|||||||
isSystem: true,
|
isSystem: true,
|
||||||
enabled: false
|
enabled: false
|
||||||
},
|
},
|
||||||
ppio: {
|
|
||||||
id: 'ppio',
|
|
||||||
name: 'PPIO',
|
|
||||||
type: 'openai',
|
|
||||||
apiKey: '',
|
|
||||||
apiHost: 'https://api.ppinfra.com/v3/openai/',
|
|
||||||
models: SYSTEM_MODELS.ppio,
|
|
||||||
isSystem: true,
|
|
||||||
enabled: false
|
|
||||||
},
|
|
||||||
alayanew: {
|
alayanew: {
|
||||||
id: 'alayanew',
|
id: 'alayanew',
|
||||||
name: 'AlayaNew',
|
name: 'AlayaNew',
|
||||||
@ -158,16 +148,6 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
|||||||
isSystem: true,
|
isSystem: true,
|
||||||
enabled: false
|
enabled: false
|
||||||
},
|
},
|
||||||
qiniu: {
|
|
||||||
id: 'qiniu',
|
|
||||||
name: 'Qiniu',
|
|
||||||
type: 'openai',
|
|
||||||
apiKey: '',
|
|
||||||
apiHost: 'https://api.qnaigc.com',
|
|
||||||
models: SYSTEM_MODELS.qiniu,
|
|
||||||
isSystem: true,
|
|
||||||
enabled: false
|
|
||||||
},
|
|
||||||
dmxapi: {
|
dmxapi: {
|
||||||
id: 'dmxapi',
|
id: 'dmxapi',
|
||||||
name: 'DMXAPI',
|
name: 'DMXAPI',
|
||||||
@ -178,6 +158,16 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
|||||||
isSystem: true,
|
isSystem: true,
|
||||||
enabled: false
|
enabled: false
|
||||||
},
|
},
|
||||||
|
aionly: {
|
||||||
|
id: 'aionly',
|
||||||
|
name: 'AIOnly',
|
||||||
|
type: 'openai',
|
||||||
|
apiKey: '',
|
||||||
|
apiHost: 'https://api.aiionly.com',
|
||||||
|
models: SYSTEM_MODELS.aionly,
|
||||||
|
isSystem: true,
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
burncloud: {
|
burncloud: {
|
||||||
id: 'burncloud',
|
id: 'burncloud',
|
||||||
name: 'BurnCloud',
|
name: 'BurnCloud',
|
||||||
@ -238,6 +228,26 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
|||||||
isSystem: true,
|
isSystem: true,
|
||||||
enabled: false
|
enabled: false
|
||||||
},
|
},
|
||||||
|
ppio: {
|
||||||
|
id: 'ppio',
|
||||||
|
name: 'PPIO',
|
||||||
|
type: 'openai',
|
||||||
|
apiKey: '',
|
||||||
|
apiHost: 'https://api.ppinfra.com/v3/openai/',
|
||||||
|
models: SYSTEM_MODELS.ppio,
|
||||||
|
isSystem: true,
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
qiniu: {
|
||||||
|
id: 'qiniu',
|
||||||
|
name: 'Qiniu',
|
||||||
|
type: 'openai',
|
||||||
|
apiKey: '',
|
||||||
|
apiHost: 'https://api.qnaigc.com',
|
||||||
|
models: SYSTEM_MODELS.qiniu,
|
||||||
|
isSystem: true,
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
openrouter: {
|
openrouter: {
|
||||||
id: 'openrouter',
|
id: 'openrouter',
|
||||||
name: 'OpenRouter',
|
name: 'OpenRouter',
|
||||||
@ -612,16 +622,6 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
|||||||
models: SYSTEM_MODELS['poe'],
|
models: SYSTEM_MODELS['poe'],
|
||||||
isSystem: true,
|
isSystem: true,
|
||||||
enabled: false
|
enabled: false
|
||||||
},
|
|
||||||
aionly: {
|
|
||||||
id: 'aionly',
|
|
||||||
name: 'AIOnly',
|
|
||||||
type: 'openai',
|
|
||||||
apiKey: '',
|
|
||||||
apiHost: 'https://api.aiionly.com',
|
|
||||||
models: SYSTEM_MODELS.aionly,
|
|
||||||
isSystem: true,
|
|
||||||
enabled: false
|
|
||||||
}
|
}
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@ -1375,3 +1375,7 @@ const SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS = ['gemini', 'vertexai'] as con
|
|||||||
export const isGeminiWebSearchProvider = (provider: Provider) => {
|
export const isGeminiWebSearchProvider = (provider: Provider) => {
|
||||||
return SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS.some((id) => id === provider.id)
|
return SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS.some((id) => id === provider.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isNewApiProvider = (provider: Provider) => {
|
||||||
|
return ['new-api', 'cherryin'].includes(provider.id)
|
||||||
|
}
|
||||||
|
|||||||
@ -172,7 +172,10 @@ export function useAssistant(id: string) {
|
|||||||
(model: Model) => assistant && dispatch(setModel({ assistantId: assistant?.id, model })),
|
(model: Model) => assistant && dispatch(setModel({ assistantId: assistant?.id, model })),
|
||||||
[assistant, dispatch]
|
[assistant, dispatch]
|
||||||
),
|
),
|
||||||
updateAssistant: useCallback((assistant: Partial<Assistant>) => dispatch(updateAssistant(assistant)), [dispatch]),
|
updateAssistant: useCallback(
|
||||||
|
(update: Partial<Omit<Assistant, 'id'>>) => dispatch(updateAssistant({ id, ...update })),
|
||||||
|
[dispatch, id]
|
||||||
|
),
|
||||||
updateAssistantSettings
|
updateAssistantSettings
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings' // 使用设置中的值
|
import { useSettings } from '@renderer/hooks/useSettings' // 使用设置中的值
|
||||||
|
import NavigationService from '@renderer/services/NavigationService'
|
||||||
import TabsService from '@renderer/services/TabsService'
|
import TabsService from '@renderer/services/TabsService'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
import {
|
import {
|
||||||
@ -14,6 +15,8 @@ import { clearWebviewState } from '@renderer/utils/webviewStateManager'
|
|||||||
import { LRUCache } from 'lru-cache'
|
import { LRUCache } from 'lru-cache'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
|
import { useNavbarPosition } from './useSettings'
|
||||||
|
|
||||||
let minAppsCache: LRUCache<string, MinAppType>
|
let minAppsCache: LRUCache<string, MinAppType>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -34,6 +37,7 @@ export const useMinappPopup = () => {
|
|||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { openedKeepAliveMinapps, openedOneOffMinapp, minappShow } = useRuntime()
|
const { openedKeepAliveMinapps, openedOneOffMinapp, minappShow } = useRuntime()
|
||||||
const { maxKeepAliveMinapps } = useSettings() // 使用设置中的值
|
const { maxKeepAliveMinapps } = useSettings() // 使用设置中的值
|
||||||
|
const { isTopNavbar } = useNavbarPosition()
|
||||||
|
|
||||||
const createLRUCache = useCallback(() => {
|
const createLRUCache = useCallback(() => {
|
||||||
return new LRUCache<string, MinAppType>({
|
return new LRUCache<string, MinAppType>({
|
||||||
@ -165,6 +169,33 @@ export const useMinappPopup = () => {
|
|||||||
dispatch(setMinappShow(false))
|
dispatch(setMinappShow(false))
|
||||||
}, [dispatch, minappShow, openedOneOffMinapp])
|
}, [dispatch, minappShow, openedOneOffMinapp])
|
||||||
|
|
||||||
|
/** Smart open minapp that adapts to navbar position */
|
||||||
|
const openSmartMinapp = useCallback(
|
||||||
|
(config: MinAppType, keepAlive: boolean = false) => {
|
||||||
|
if (isTopNavbar) {
|
||||||
|
// For top navbar mode, need to add to cache first for temporary apps
|
||||||
|
const cacheApp = minAppsCache.get(config.id)
|
||||||
|
if (!cacheApp) {
|
||||||
|
// Add temporary app to cache so MinAppPage can find it
|
||||||
|
minAppsCache.set(config.id, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set current minapp and show state
|
||||||
|
dispatch(setCurrentMinappId(config.id))
|
||||||
|
dispatch(setMinappShow(true))
|
||||||
|
|
||||||
|
// Then navigate to the app tab using NavigationService
|
||||||
|
if (NavigationService.navigate) {
|
||||||
|
NavigationService.navigate(`/apps/${config.id}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For side navbar, use the traditional popup system
|
||||||
|
openMinapp(config, keepAlive)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isTopNavbar, openMinapp, dispatch]
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
openMinapp,
|
openMinapp,
|
||||||
openMinappKeepAlive,
|
openMinappKeepAlive,
|
||||||
@ -172,6 +203,7 @@ export const useMinappPopup = () => {
|
|||||||
closeMinapp,
|
closeMinapp,
|
||||||
hideMinappPopup,
|
hideMinappPopup,
|
||||||
closeAllMinapps,
|
closeAllMinapps,
|
||||||
|
openSmartMinapp,
|
||||||
// Expose cache instance for TabsService integration
|
// Expose cache instance for TabsService integration
|
||||||
minAppsCache
|
minAppsCache
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import { loggerService } from '@renderer/services/LoggerService'
|
|||||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||||
import { setIsBunInstalled } from '@renderer/store/mcp'
|
import { setIsBunInstalled } from '@renderer/store/mcp'
|
||||||
import { Model } from '@renderer/types'
|
import { EndpointType, Model } from '@renderer/types'
|
||||||
import { getClaudeSupportedProviders } from '@renderer/utils/provider'
|
import { getClaudeSupportedProviders } from '@renderer/utils/provider'
|
||||||
import { codeTools, terminalApps, TerminalConfig } from '@shared/config/constant'
|
import { codeTools, terminalApps, TerminalConfig } from '@shared/config/constant'
|
||||||
import { Alert, Avatar, Button, Checkbox, Input, Popover, Select, Space, Tooltip } from 'antd'
|
import { Alert, Avatar, Button, Checkbox, Input, Popover, Select, Space, Tooltip } from 'antd'
|
||||||
@ -70,18 +70,43 @@ const CodeToolsPage: FC = () => {
|
|||||||
if (isEmbeddingModel(m) || isRerankModel(m) || isTextToImageModel(m)) {
|
if (isEmbeddingModel(m) || isRerankModel(m) || isTextToImageModel(m)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m.provider === 'cherryai') {
|
if (m.provider === 'cherryai') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedCliTool === codeTools.claudeCode) {
|
if (selectedCliTool === codeTools.claudeCode) {
|
||||||
|
if (m.supported_endpoint_types) {
|
||||||
|
return m.supported_endpoint_types.includes('anthropic')
|
||||||
|
}
|
||||||
return m.id.includes('claude') || CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS.includes(m.provider)
|
return m.id.includes('claude') || CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS.includes(m.provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedCliTool === codeTools.geminiCli) {
|
if (selectedCliTool === codeTools.geminiCli) {
|
||||||
|
if (m.supported_endpoint_types) {
|
||||||
|
return m.supported_endpoint_types.includes('gemini')
|
||||||
|
}
|
||||||
return m.id.includes('gemini')
|
return m.id.includes('gemini')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedCliTool === codeTools.openaiCodex) {
|
if (selectedCliTool === codeTools.openaiCodex) {
|
||||||
|
if (m.supported_endpoint_types) {
|
||||||
|
return ['openai', 'openai-response'].some((type) =>
|
||||||
|
m.supported_endpoint_types?.includes(type as EndpointType)
|
||||||
|
)
|
||||||
|
}
|
||||||
return m.id.includes('openai') || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(m.provider)
|
return m.id.includes('openai') || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(m.provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedCliTool === codeTools.qwenCode || selectedCliTool === codeTools.iFlowCli) {
|
||||||
|
if (m.supported_endpoint_types) {
|
||||||
|
return ['openai', 'openai-response'].some((type) =>
|
||||||
|
m.supported_endpoint_types?.includes(type as EndpointType)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
[selectedCliTool]
|
[selectedCliTool]
|
||||||
|
|||||||
@ -23,10 +23,16 @@ export const CLI_TOOLS = [
|
|||||||
{ value: codeTools.iFlowCli, label: 'iFlow CLI' }
|
{ value: codeTools.iFlowCli, label: 'iFlow CLI' }
|
||||||
]
|
]
|
||||||
|
|
||||||
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api']
|
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', 'cherryin']
|
||||||
export const CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS = ['deepseek', 'moonshot', 'zhipu', 'dashscope', 'modelscope']
|
export const CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS = ['deepseek', 'moonshot', 'zhipu', 'dashscope', 'modelscope']
|
||||||
export const CLAUDE_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', ...CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS]
|
export const CLAUDE_SUPPORTED_PROVIDERS = [
|
||||||
export const OPENAI_CODEX_SUPPORTED_PROVIDERS = ['openai', 'openrouter', 'aihubmix', 'new-api']
|
'aihubmix',
|
||||||
|
'dmxapi',
|
||||||
|
'new-api',
|
||||||
|
'cherryin',
|
||||||
|
...CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS
|
||||||
|
]
|
||||||
|
export const OPENAI_CODEX_SUPPORTED_PROVIDERS = ['openai', 'openrouter', 'aihubmix', 'new-api', 'cherryin']
|
||||||
|
|
||||||
// Provider 过滤映射
|
// Provider 过滤映射
|
||||||
export const CLI_TOOL_PROVIDER_MAP: Record<string, (providers: Provider[]) => Provider[]> = {
|
export const CLI_TOOL_PROVIDER_MAP: Record<string, (providers: Provider[]) => Provider[]> = {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { LoadingIcon } from '@renderer/components/Icons'
|
import { Spinner } from '@heroui/react'
|
||||||
import { MessageBlockStatus, MessageBlockType, type PlaceholderMessageBlock } from '@renderer/types/newMessage'
|
import { MessageBlockStatus, MessageBlockType, type PlaceholderMessageBlock } from '@renderer/types/newMessage'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
@ -10,7 +10,7 @@ const PlaceholderBlock: React.FC<PlaceholderBlockProps> = ({ block }) => {
|
|||||||
if (block.status === MessageBlockStatus.PROCESSING && block.type === MessageBlockType.UNKNOWN) {
|
if (block.status === MessageBlockStatus.PROCESSING && block.type === MessageBlockType.UNKNOWN) {
|
||||||
return (
|
return (
|
||||||
<MessageContentLoading>
|
<MessageContentLoading>
|
||||||
<LoadingIcon />
|
<Spinner color="current" variant="dots" />
|
||||||
</MessageContentLoading>
|
</MessageContentLoading>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { RootState } from '@renderer/store'
|
|||||||
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
|
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
|
||||||
import type { ImageMessageBlock, Message, MessageBlock } from '@renderer/types/newMessage'
|
import type { ImageMessageBlock, Message, MessageBlock } from '@renderer/types/newMessage'
|
||||||
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||||
import { isMainTextBlock, isVideoBlock } from '@renderer/utils/messageUtils/is'
|
import { isMainTextBlock, isMessageProcessing, isVideoBlock } from '@renderer/utils/messageUtils/is'
|
||||||
import { AnimatePresence, motion, type Variants } from 'motion/react'
|
import { AnimatePresence, motion, type Variants } from 'motion/react'
|
||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { useSelector } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
@ -107,6 +107,9 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
|
|||||||
const renderedBlocks = blocks.map((blockId) => blockEntities[blockId]).filter(Boolean)
|
const renderedBlocks = blocks.map((blockId) => blockEntities[blockId]).filter(Boolean)
|
||||||
const groupedBlocks = useMemo(() => groupSimilarBlocks(renderedBlocks), [renderedBlocks])
|
const groupedBlocks = useMemo(() => groupSimilarBlocks(renderedBlocks), [renderedBlocks])
|
||||||
|
|
||||||
|
// Check if message is still processing
|
||||||
|
const isProcessing = isMessageProcessing(message)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence mode="sync">
|
<AnimatePresence mode="sync">
|
||||||
{groupedBlocks.map((block) => {
|
{groupedBlocks.map((block) => {
|
||||||
@ -151,9 +154,6 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
|
|||||||
|
|
||||||
switch (block.type) {
|
switch (block.type) {
|
||||||
case MessageBlockType.UNKNOWN:
|
case MessageBlockType.UNKNOWN:
|
||||||
if (block.status === MessageBlockStatus.PROCESSING) {
|
|
||||||
blockComponent = <PlaceholderBlock key={block.id} block={block} />
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
case MessageBlockType.MAIN_TEXT:
|
case MessageBlockType.MAIN_TEXT:
|
||||||
case MessageBlockType.CODE: {
|
case MessageBlockType.CODE: {
|
||||||
@ -213,6 +213,19 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
|
|||||||
</AnimatedBlockWrapper>
|
</AnimatedBlockWrapper>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{isProcessing && (
|
||||||
|
<AnimatedBlockWrapper key="message-loading-placeholder" enableAnimation={true}>
|
||||||
|
<PlaceholderBlock
|
||||||
|
block={{
|
||||||
|
id: `loading-${message.id}`,
|
||||||
|
messageId: message.id,
|
||||||
|
type: MessageBlockType.UNKNOWN,
|
||||||
|
status: MessageBlockStatus.PROCESSING,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AnimatedBlockWrapper>
|
||||||
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar'
|
import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar'
|
||||||
import { HStack } from '@renderer/components/Layout'
|
import { HStack } from '@renderer/components/Layout'
|
||||||
import SearchPopup from '@renderer/components/Popups/SearchPopup'
|
import SearchPopup from '@renderer/components/Popups/SearchPopup'
|
||||||
import { isLinux, isWin } from '@renderer/config/constant'
|
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||||
import { modelGenerating } from '@renderer/hooks/useRuntime'
|
import { modelGenerating } from '@renderer/hooks/useRuntime'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
@ -86,7 +86,14 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
{!showAssistants && (
|
{!showAssistants && (
|
||||||
<NavbarLeft style={{ justifyContent: 'flex-start', borderRight: 'none', padding: '0 10px', minWidth: 'auto' }}>
|
<NavbarLeft
|
||||||
|
style={{
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
borderRight: 'none',
|
||||||
|
paddingLeft: 0,
|
||||||
|
paddingRight: 10,
|
||||||
|
minWidth: 'auto'
|
||||||
|
}}>
|
||||||
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={0.8}>
|
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={0.8}>
|
||||||
<NavbarIcon onClick={() => toggleShowAssistants()}>
|
<NavbarIcon onClick={() => toggleShowAssistants()}>
|
||||||
<PanelRightClose size={18} />
|
<PanelRightClose size={18} />
|
||||||
@ -106,7 +113,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</NavbarLeft>
|
</NavbarLeft>
|
||||||
)}
|
)}
|
||||||
<HStack alignItems="center" gap={6}>
|
<HStack alignItems="center" gap={6} ml={!isMac ? 16 : 0}>
|
||||||
<SelectModelButton assistant={assistant} />
|
<SelectModelButton assistant={assistant} />
|
||||||
</HStack>
|
</HStack>
|
||||||
<NavbarRight
|
<NavbarRight
|
||||||
@ -114,7 +121,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
|||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
paddingRight: isWin || isLinux ? '144px' : '6px'
|
paddingRight: isWin || isLinux ? '144px' : '15px'
|
||||||
}}
|
}}
|
||||||
className="home-navbar-right">
|
className="home-navbar-right">
|
||||||
<HStack alignItems="center" gap={6}>
|
<HStack alignItems="center" gap={6}>
|
||||||
|
|||||||
@ -412,7 +412,7 @@ const SettingsTab: FC<Props> = (props) => {
|
|||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
</SettingGroup>
|
</SettingGroup>
|
||||||
</CollapsibleSettingGroup>
|
</CollapsibleSettingGroup>
|
||||||
<CollapsibleSettingGroup title={t('settings.math.title')} defaultExpanded={true}>
|
<CollapsibleSettingGroup title={t('settings.math.title')} defaultExpanded={false}>
|
||||||
<SettingGroup>
|
<SettingGroup>
|
||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingRowTitleSmall>{t('settings.math.engine.label')}</SettingRowTitleSmall>
|
<SettingRowTitleSmall>{t('settings.math.engine.label')}</SettingRowTitleSmall>
|
||||||
@ -441,7 +441,7 @@ const SettingsTab: FC<Props> = (props) => {
|
|||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
</SettingGroup>
|
</SettingGroup>
|
||||||
</CollapsibleSettingGroup>
|
</CollapsibleSettingGroup>
|
||||||
<CollapsibleSettingGroup title={t('chat.settings.code.title')} defaultExpanded={true}>
|
<CollapsibleSettingGroup title={t('chat.settings.code.title')} defaultExpanded={false}>
|
||||||
<SettingGroup>
|
<SettingGroup>
|
||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingRowTitleSmall>{t('message.message.code_style')}</SettingRowTitleSmall>
|
<SettingRowTitleSmall>{t('message.message.code_style')}</SettingRowTitleSmall>
|
||||||
@ -585,7 +585,7 @@ const SettingsTab: FC<Props> = (props) => {
|
|||||||
</SettingGroup>
|
</SettingGroup>
|
||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
</CollapsibleSettingGroup>
|
</CollapsibleSettingGroup>
|
||||||
<CollapsibleSettingGroup title={t('settings.messages.input.title')} defaultExpanded={true}>
|
<CollapsibleSettingGroup title={t('settings.messages.input.title')} defaultExpanded={false}>
|
||||||
<SettingGroup>
|
<SettingGroup>
|
||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingRowTitleSmall>{t('settings.messages.input.show_estimated_tokens')}</SettingRowTitleSmall>
|
<SettingRowTitleSmall>{t('settings.messages.input.show_estimated_tokens')}</SettingRowTitleSmall>
|
||||||
|
|||||||
@ -44,11 +44,20 @@ const MinAppPage: FC = () => {
|
|||||||
}
|
}
|
||||||
}, [isTopNavbar])
|
}, [isTopNavbar])
|
||||||
|
|
||||||
// Find the app from all available apps
|
// Find the app from all available apps (including cached ones)
|
||||||
const app = useMemo(() => {
|
const app = useMemo(() => {
|
||||||
if (!appId) return null
|
if (!appId) return null
|
||||||
return [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
|
||||||
}, [appId, minapps])
|
// First try to find in default and custom mini-apps
|
||||||
|
let foundApp = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
||||||
|
|
||||||
|
// If not found and we have cache, try to find in cache (for temporary apps)
|
||||||
|
if (!foundApp && minAppsCache) {
|
||||||
|
foundApp = minAppsCache.get(appId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return foundApp
|
||||||
|
}, [appId, minapps, minAppsCache])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If app not found, redirect to apps list
|
// If app not found, redirect to apps list
|
||||||
|
|||||||
@ -111,7 +111,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
const targetScrollTop = elementOffsetTop - (containerHeight - elementHeight) / 2
|
const targetScrollTop = elementOffsetTop - (containerHeight - elementHeight) / 2
|
||||||
scrollContainer.scrollTo({
|
scrollContainer.scrollTo({
|
||||||
top: Math.max(0, targetScrollTop),
|
top: Math.max(0, targetScrollTop),
|
||||||
behavior: 'smooth'
|
behavior: 'instant'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -307,7 +307,7 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createNewPainting = () => {
|
const handleAddPainting = () => {
|
||||||
if (generating) return
|
if (generating) return
|
||||||
const newPainting = getNewPainting()
|
const newPainting = getNewPainting()
|
||||||
const addedPainting = addPainting('zhipu_paintings', newPainting)
|
const addedPainting = addPainting('zhipu_paintings', newPainting)
|
||||||
@ -342,12 +342,12 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Navbar>
|
<Navbar>
|
||||||
<NavbarCenter>
|
<NavbarCenter style={{ borderRight: 'none' }}>{t('paintings.title')}</NavbarCenter>
|
||||||
<Title>{t('title.paintings')}</Title>
|
|
||||||
</NavbarCenter>
|
|
||||||
{isMac && (
|
{isMac && (
|
||||||
<NavbarRight>
|
<NavbarRight style={{ justifyContent: 'flex-end' }}>
|
||||||
<Button type="text" icon={<PlusOutlined />} onClick={createNewPainting} disabled={generating} />
|
<Button size="small" className="nodrag" icon={<PlusOutlined />} onClick={handleAddPainting}>
|
||||||
|
{t('paintings.button.new.image')}
|
||||||
|
</Button>
|
||||||
</NavbarRight>
|
</NavbarRight>
|
||||||
)}
|
)}
|
||||||
</Navbar>
|
</Navbar>
|
||||||
@ -482,7 +482,7 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
selectedPainting={painting}
|
selectedPainting={painting}
|
||||||
onSelectPainting={onSelectPainting}
|
onSelectPainting={onSelectPainting}
|
||||||
onDeletePainting={onDeletePainting}
|
onDeletePainting={onDeletePainting}
|
||||||
onNewPainting={createNewPainting}
|
onNewPainting={handleAddPainting}
|
||||||
/>
|
/>
|
||||||
</ContentContainer>
|
</ContentContainer>
|
||||||
</Container>
|
</Container>
|
||||||
@ -556,12 +556,6 @@ const ToolbarMenu = styled.div`
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
`
|
`
|
||||||
|
|
||||||
const Title = styled.h1`
|
|
||||||
margin: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
`
|
|
||||||
|
|
||||||
const ProviderTitleContainer = styled.div`
|
const ProviderTitleContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import { runAsyncFunction } from '@renderer/utils'
|
|||||||
import { UpgradeChannel } from '@shared/config/constant'
|
import { UpgradeChannel } from '@shared/config/constant'
|
||||||
import { Avatar, Button, Progress, Radio, Row, Switch, Tag, Tooltip } from 'antd'
|
import { Avatar, Button, Progress, Radio, Row, Switch, Tag, Tooltip } from 'antd'
|
||||||
import { debounce } from 'lodash'
|
import { debounce } from 'lodash'
|
||||||
import { Bug, FileCheck, Github, Globe, Mail, Rss } from 'lucide-react'
|
import { Bug, FileCheck, Globe, Mail, Rss } from 'lucide-react'
|
||||||
import { BadgeQuestionMark } from 'lucide-react'
|
import { BadgeQuestionMark } from 'lucide-react'
|
||||||
import { FC, useEffect, useState } from 'react'
|
import { FC, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@ -32,7 +32,7 @@ const AboutSettings: FC = () => {
|
|||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { update } = useRuntime()
|
const { update } = useRuntime()
|
||||||
const { openMinapp } = useMinappPopup()
|
const { openSmartMinapp } = useMinappPopup()
|
||||||
|
|
||||||
const onCheckUpdate = debounce(
|
const onCheckUpdate = debounce(
|
||||||
async () => {
|
async () => {
|
||||||
@ -79,7 +79,7 @@ const AboutSettings: FC = () => {
|
|||||||
|
|
||||||
const showLicense = async () => {
|
const showLicense = async () => {
|
||||||
const { appPath } = await window.api.getAppInfo()
|
const { appPath } = await window.api.getAppInfo()
|
||||||
openMinapp({
|
openSmartMinapp({
|
||||||
id: 'cherrystudio-license',
|
id: 'cherrystudio-license',
|
||||||
name: t('settings.about.license.title'),
|
name: t('settings.about.license.title'),
|
||||||
url: `file://${appPath}/resources/cherry-studio/license.html`,
|
url: `file://${appPath}/resources/cherry-studio/license.html`,
|
||||||
@ -89,7 +89,7 @@ const AboutSettings: FC = () => {
|
|||||||
|
|
||||||
const showReleases = async () => {
|
const showReleases = async () => {
|
||||||
const { appPath } = await window.api.getAppInfo()
|
const { appPath } = await window.api.getAppInfo()
|
||||||
openMinapp({
|
openSmartMinapp({
|
||||||
id: 'cherrystudio-releases',
|
id: 'cherrystudio-releases',
|
||||||
name: t('settings.about.releases.title'),
|
name: t('settings.about.releases.title'),
|
||||||
url: `file://${appPath}/resources/cherry-studio/releases.html?theme=${theme === ThemeMode.dark ? 'dark' : 'light'}`,
|
url: `file://${appPath}/resources/cherry-studio/releases.html?theme=${theme === ThemeMode.dark ? 'dark' : 'light'}`,
|
||||||
@ -273,7 +273,7 @@ const AboutSettings: FC = () => {
|
|||||||
<IndicatorLight color="green" />
|
<IndicatorLight color="green" />
|
||||||
</SettingRowTitle>
|
</SettingRowTitle>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<UpdateNotesWrapper>
|
<UpdateNotesWrapper className="markdown">
|
||||||
<Markdown>
|
<Markdown>
|
||||||
{typeof update.info.releaseNotes === 'string'
|
{typeof update.info.releaseNotes === 'string'
|
||||||
? update.info.releaseNotes.replace(/\n/g, '\n\n')
|
? update.info.releaseNotes.replace(/\n/g, '\n\n')
|
||||||
@ -309,7 +309,7 @@ const AboutSettings: FC = () => {
|
|||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingRowTitle>
|
<SettingRowTitle>
|
||||||
<Github size={18} />
|
<GithubOutlined size={18} />
|
||||||
{t('settings.about.feedback.title')}
|
{t('settings.about.feedback.title')}
|
||||||
</SettingRowTitle>
|
</SettingRowTitle>
|
||||||
<Button onClick={() => onOpenWebsite('https://github.com/CherryHQ/cherry-studio/issues/new/choose')}>
|
<Button onClick={() => onOpenWebsite('https://github.com/CherryHQ/cherry-studio/issues/new/choose')}>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||||
import { HStack } from '@renderer/components/Layout'
|
import { HStack } from '@renderer/components/Layout'
|
||||||
|
import { AppLogo } from '@renderer/config/env'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||||
import { RootState, useAppDispatch } from '@renderer/store'
|
import { RootState, useAppDispatch } from '@renderer/store'
|
||||||
@ -16,7 +17,7 @@ const JoplinSettings: FC = () => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { openMinapp } = useMinappPopup()
|
const { openSmartMinapp } = useMinappPopup()
|
||||||
|
|
||||||
const joplinToken = useSelector((state: RootState) => state.settings.joplinToken)
|
const joplinToken = useSelector((state: RootState) => state.settings.joplinToken)
|
||||||
const joplinUrl = useSelector((state: RootState) => state.settings.joplinUrl)
|
const joplinUrl = useSelector((state: RootState) => state.settings.joplinUrl)
|
||||||
@ -66,10 +67,11 @@ const JoplinSettings: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleJoplinHelpClick = () => {
|
const handleJoplinHelpClick = () => {
|
||||||
openMinapp({
|
openSmartMinapp({
|
||||||
id: 'joplin-help',
|
id: 'joplin-help',
|
||||||
name: 'Joplin Help',
|
name: 'Joplin Help',
|
||||||
url: 'https://joplinapp.org/help/apps/clipper'
|
url: 'https://joplinapp.org/help/apps/clipper',
|
||||||
|
logo: AppLogo
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||||
import { Client } from '@notionhq/client'
|
import { Client } from '@notionhq/client'
|
||||||
import { HStack } from '@renderer/components/Layout'
|
import { HStack } from '@renderer/components/Layout'
|
||||||
|
import { AppLogo } from '@renderer/config/env'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||||
import { RootState, useAppDispatch } from '@renderer/store'
|
import { RootState, useAppDispatch } from '@renderer/store'
|
||||||
@ -21,7 +22,7 @@ const NotionSettings: FC = () => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { openMinapp } = useMinappPopup()
|
const { openSmartMinapp } = useMinappPopup()
|
||||||
|
|
||||||
const notionApiKey = useSelector((state: RootState) => state.settings.notionApiKey)
|
const notionApiKey = useSelector((state: RootState) => state.settings.notionApiKey)
|
||||||
const notionDatabaseID = useSelector((state: RootState) => state.settings.notionDatabaseID)
|
const notionDatabaseID = useSelector((state: RootState) => state.settings.notionDatabaseID)
|
||||||
@ -67,10 +68,11 @@ const NotionSettings: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleNotionTitleClick = () => {
|
const handleNotionTitleClick = () => {
|
||||||
openMinapp({
|
openSmartMinapp({
|
||||||
id: 'notion-help',
|
id: 'notion-help',
|
||||||
name: 'Notion Help',
|
name: 'Notion Help',
|
||||||
url: 'https://docs.cherry-ai.com/advanced-basic/notion'
|
url: 'https://docs.cherry-ai.com/advanced-basic/notion',
|
||||||
|
logo: AppLogo
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { HStack } from '@renderer/components/Layout'
|
|||||||
import { S3BackupManager } from '@renderer/components/S3BackupManager'
|
import { S3BackupManager } from '@renderer/components/S3BackupManager'
|
||||||
import { S3BackupModal, useS3BackupModal } from '@renderer/components/S3Modals'
|
import { S3BackupModal, useS3BackupModal } from '@renderer/components/S3Modals'
|
||||||
import Selector from '@renderer/components/Selector'
|
import Selector from '@renderer/components/Selector'
|
||||||
|
import { AppLogo } from '@renderer/config/env'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
@ -47,7 +48,7 @@ const S3Settings: FC = () => {
|
|||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { openMinapp } = useMinappPopup()
|
const { openSmartMinapp } = useMinappPopup()
|
||||||
|
|
||||||
const { s3Sync } = useAppSelector((state) => state.backup)
|
const { s3Sync } = useAppSelector((state) => state.backup)
|
||||||
|
|
||||||
@ -62,10 +63,11 @@ const S3Settings: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleTitleClick = () => {
|
const handleTitleClick = () => {
|
||||||
openMinapp({
|
openSmartMinapp({
|
||||||
id: 's3-help',
|
id: 's3-help',
|
||||||
name: 'S3 Compatible Storage Help',
|
name: 'S3 Compatible Storage Help',
|
||||||
url: 'https://docs.cherry-ai.com/data-settings/s3-compatible'
|
url: 'https://docs.cherry-ai.com/data-settings/s3-compatible',
|
||||||
|
logo: AppLogo
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { HStack } from '@renderer/components/Layout'
|
import { HStack } from '@renderer/components/Layout'
|
||||||
|
import { AppLogo } from '@renderer/config/env'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||||
import { RootState, useAppDispatch } from '@renderer/store'
|
import { RootState, useAppDispatch } from '@renderer/store'
|
||||||
@ -16,7 +17,7 @@ import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle
|
|||||||
const logger = loggerService.withContext('SiyuanSettings')
|
const logger = loggerService.withContext('SiyuanSettings')
|
||||||
|
|
||||||
const SiyuanSettings: FC = () => {
|
const SiyuanSettings: FC = () => {
|
||||||
const { openMinapp } = useMinappPopup()
|
const { openSmartMinapp } = useMinappPopup()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
@ -43,10 +44,11 @@ const SiyuanSettings: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSiyuanHelpClick = () => {
|
const handleSiyuanHelpClick = () => {
|
||||||
openMinapp({
|
openSmartMinapp({
|
||||||
id: 'siyuan-help',
|
id: 'siyuan-help',
|
||||||
name: 'Siyuan Help',
|
name: 'Siyuan Help',
|
||||||
url: 'https://docs.cherry-ai.com/advanced-basic/siyuan'
|
url: 'https://docs.cherry-ai.com/advanced-basic/siyuan',
|
||||||
|
logo: AppLogo
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||||
import { HStack } from '@renderer/components/Layout'
|
import { HStack } from '@renderer/components/Layout'
|
||||||
|
import { AppLogo } from '@renderer/config/env'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||||
import { RootState, useAppDispatch } from '@renderer/store'
|
import { RootState, useAppDispatch } from '@renderer/store'
|
||||||
@ -16,7 +17,7 @@ const YuqueSettings: FC = () => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { openMinapp } = useMinappPopup()
|
const { openSmartMinapp } = useMinappPopup()
|
||||||
|
|
||||||
const yuqueToken = useSelector((state: RootState) => state.settings.yuqueToken)
|
const yuqueToken = useSelector((state: RootState) => state.settings.yuqueToken)
|
||||||
const yuqueUrl = useSelector((state: RootState) => state.settings.yuqueUrl)
|
const yuqueUrl = useSelector((state: RootState) => state.settings.yuqueUrl)
|
||||||
@ -65,10 +66,11 @@ const YuqueSettings: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleYuqueHelpClick = () => {
|
const handleYuqueHelpClick = () => {
|
||||||
openMinapp({
|
openSmartMinapp({
|
||||||
id: 'yuque-help',
|
id: 'yuque-help',
|
||||||
name: 'Yuque Help',
|
name: 'Yuque Help',
|
||||||
url: 'https://www.yuque.com/settings/tokens'
|
url: 'https://www.yuque.com/settings/tokens',
|
||||||
|
logo: AppLogo
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -183,7 +183,7 @@ const CardContainer = styled.div<{ $isActive: boolean }>`
|
|||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
height: 125px;
|
height: 125px;
|
||||||
opacity: ${(props) => (props.$isActive ? 1 : 0.6)};
|
opacity: ${(props) => (props.$isActive ? 1 : 0.6)};
|
||||||
width: calc(100vw - var(--settings-width) - 40px);
|
width: 100%;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|||||||
@ -251,7 +251,8 @@ const McpServersList: FC = () => {
|
|||||||
onSortEnd={onSortEnd}
|
onSortEnd={onSortEnd}
|
||||||
layout="list"
|
layout="list"
|
||||||
horizontal={false}
|
horizontal={false}
|
||||||
listStyle={{ display: 'flex', flexDirection: 'column' }}
|
listStyle={{ display: 'flex', flexDirection: 'column', width: '100%' }}
|
||||||
|
itemStyle={{ width: '100%' }}
|
||||||
gap="12px"
|
gap="12px"
|
||||||
restrictions={{ scrollableAncestor: true }}
|
restrictions={{ scrollableAncestor: true }}
|
||||||
useDragOverlay
|
useDragOverlay
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
isVisionModel,
|
isVisionModel,
|
||||||
isWebSearchModel
|
isWebSearchModel
|
||||||
} from '@renderer/config/models'
|
} from '@renderer/config/models'
|
||||||
|
import { isNewApiProvider } from '@renderer/config/providers'
|
||||||
import { useDynamicLabelWidth } from '@renderer/hooks/useDynamicLabelWidth'
|
import { useDynamicLabelWidth } from '@renderer/hooks/useDynamicLabelWidth'
|
||||||
import { Model, ModelCapability, ModelType, Provider } from '@renderer/types'
|
import { Model, ModelCapability, ModelType, Provider } from '@renderer/types'
|
||||||
import { getDefaultGroupName, getDifference, getUnion, uniqueObjectArray } from '@renderer/utils'
|
import { getDefaultGroupName, getDifference, getUnion, uniqueObjectArray } from '@renderer/utils'
|
||||||
@ -78,7 +79,7 @@ const ModelEditContent: FC<ModelEditContentProps & ModalProps> = ({ provider, mo
|
|||||||
id: formValues.id || model.id,
|
id: formValues.id || model.id,
|
||||||
name: formValues.name || model.name,
|
name: formValues.name || model.name,
|
||||||
group: formValues.group || model.group,
|
group: formValues.group || model.group,
|
||||||
endpoint_type: provider.id === 'new-api' ? formValues.endpointType : model.endpoint_type,
|
endpoint_type: isNewApiProvider(provider) ? formValues.endpointType : model.endpoint_type,
|
||||||
capabilities: overrides?.capabilities ?? modelCapabilities,
|
capabilities: overrides?.capabilities ?? modelCapabilities,
|
||||||
supported_text_delta: overrides?.supported_text_delta ?? supportedTextDelta,
|
supported_text_delta: overrides?.supported_text_delta ?? supportedTextDelta,
|
||||||
pricing: {
|
pricing: {
|
||||||
@ -97,7 +98,7 @@ const ModelEditContent: FC<ModelEditContentProps & ModalProps> = ({ provider, mo
|
|||||||
id: values.id || model.id,
|
id: values.id || model.id,
|
||||||
name: values.name || model.name,
|
name: values.name || model.name,
|
||||||
group: values.group || model.group,
|
group: values.group || model.group,
|
||||||
endpoint_type: provider.id === 'new-api' ? values.endpointType : model.endpoint_type,
|
endpoint_type: isNewApiProvider(provider) ? values.endpointType : model.endpoint_type,
|
||||||
capabilities: modelCapabilities,
|
capabilities: modelCapabilities,
|
||||||
supported_text_delta: supportedTextDelta,
|
supported_text_delta: supportedTextDelta,
|
||||||
pricing: {
|
pricing: {
|
||||||
@ -247,7 +248,7 @@ const ModelEditContent: FC<ModelEditContentProps & ModalProps> = ({ provider, mo
|
|||||||
<Modal title={t('models.edit')} footer={null} transitionName="animation-move-down" centered {...props}>
|
<Modal title={t('models.edit')} footer={null} transitionName="animation-move-down" centered {...props}>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
labelCol={{ flex: provider.id === 'new-api' ? labelWidth : '110px' }}
|
labelCol={{ flex: isNewApiProvider(provider) ? labelWidth : '110px' }}
|
||||||
labelAlign="left"
|
labelAlign="left"
|
||||||
colon={false}
|
colon={false}
|
||||||
style={{ marginTop: 15 }}
|
style={{ marginTop: 15 }}
|
||||||
@ -309,7 +310,7 @@ const ModelEditContent: FC<ModelEditContentProps & ModalProps> = ({ provider, mo
|
|||||||
tooltip={t('settings.models.add.group_name.tooltip')}>
|
tooltip={t('settings.models.add.group_name.tooltip')}>
|
||||||
<Input placeholder={t('settings.models.add.group_name.placeholder')} spellCheck={false} />
|
<Input placeholder={t('settings.models.add.group_name.placeholder')} spellCheck={false} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{provider.id === 'new-api' && (
|
{isNewApiProvider(provider) && (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="endpointType"
|
name="endpointType"
|
||||||
label={t('settings.models.add.endpoint_type.label')}
|
label={t('settings.models.add.endpoint_type.label')}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import ModelIdWithTags from '@renderer/components/ModelIdWithTags'
|
|||||||
import CustomTag from '@renderer/components/Tags/CustomTag'
|
import CustomTag from '@renderer/components/Tags/CustomTag'
|
||||||
import { DynamicVirtualList } from '@renderer/components/VirtualList'
|
import { DynamicVirtualList } from '@renderer/components/VirtualList'
|
||||||
import { getModelLogo } from '@renderer/config/models'
|
import { getModelLogo } from '@renderer/config/models'
|
||||||
|
import { isNewApiProvider } from '@renderer/config/providers'
|
||||||
import FileItem from '@renderer/pages/files/FileItem'
|
import FileItem from '@renderer/pages/files/FileItem'
|
||||||
import NewApiBatchAddModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/NewApiBatchAddModelPopup'
|
import NewApiBatchAddModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/NewApiBatchAddModelPopup'
|
||||||
import { Model, Provider } from '@renderer/types'
|
import { Model, Provider } from '@renderer/types'
|
||||||
@ -91,7 +92,7 @@ const ManageModelsList: React.FC<ManageModelsListProps> = ({ modelGroups, provid
|
|||||||
// 添加整组
|
// 添加整组
|
||||||
const wouldAddModels = models.filter((model) => !isModelInProvider(provider, model.id))
|
const wouldAddModels = models.filter((model) => !isModelInProvider(provider, model.id))
|
||||||
|
|
||||||
if (provider.id === 'new-api') {
|
if (isNewApiProvider(provider)) {
|
||||||
if (wouldAddModels.every(isValidNewApiModel)) {
|
if (wouldAddModels.every(isValidNewApiModel)) {
|
||||||
wouldAddModels.forEach(onAddModel)
|
wouldAddModels.forEach(onAddModel)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
isWebSearchModel,
|
isWebSearchModel,
|
||||||
SYSTEM_MODELS
|
SYSTEM_MODELS
|
||||||
} from '@renderer/config/models'
|
} from '@renderer/config/models'
|
||||||
|
import { isNewApiProvider } from '@renderer/config/providers'
|
||||||
import { useProvider } from '@renderer/hooks/useProvider'
|
import { useProvider } from '@renderer/hooks/useProvider'
|
||||||
import NewApiAddModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/NewApiAddModelPopup'
|
import NewApiAddModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/NewApiAddModelPopup'
|
||||||
import NewApiBatchAddModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/NewApiBatchAddModelPopup'
|
import NewApiBatchAddModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/NewApiBatchAddModelPopup'
|
||||||
@ -129,7 +130,7 @@ const PopupContainer: React.FC<Props> = ({ providerId, resolve }) => {
|
|||||||
const onAddModel = useCallback(
|
const onAddModel = useCallback(
|
||||||
(model: Model) => {
|
(model: Model) => {
|
||||||
if (!isEmpty(model.name)) {
|
if (!isEmpty(model.name)) {
|
||||||
if (provider.id === 'new-api') {
|
if (isNewApiProvider(provider)) {
|
||||||
if (model.supported_endpoint_types && model.supported_endpoint_types.length > 0) {
|
if (model.supported_endpoint_types && model.supported_endpoint_types.length > 0) {
|
||||||
addModel({
|
addModel({
|
||||||
...model,
|
...model,
|
||||||
@ -160,7 +161,7 @@ const PopupContainer: React.FC<Props> = ({ providerId, resolve }) => {
|
|||||||
content: t('settings.models.manage.add_listed.confirm'),
|
content: t('settings.models.manage.add_listed.confirm'),
|
||||||
centered: true,
|
centered: true,
|
||||||
onOk: () => {
|
onOk: () => {
|
||||||
if (provider.id === 'new-api') {
|
if (isNewApiProvider(provider)) {
|
||||||
if (models.every(isValidNewApiModel)) {
|
if (models.every(isValidNewApiModel)) {
|
||||||
wouldAddModel.forEach(onAddModel)
|
wouldAddModel.forEach(onAddModel)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import CollapsibleSearchBar from '@renderer/components/CollapsibleSearchBar'
|
|||||||
import { LoadingIcon, StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons'
|
import { LoadingIcon, StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons'
|
||||||
import { HStack } from '@renderer/components/Layout'
|
import { HStack } from '@renderer/components/Layout'
|
||||||
import CustomTag from '@renderer/components/Tags/CustomTag'
|
import CustomTag from '@renderer/components/Tags/CustomTag'
|
||||||
import { PROVIDER_URLS } from '@renderer/config/providers'
|
import { isNewApiProvider, PROVIDER_URLS } from '@renderer/config/providers'
|
||||||
import { useProvider } from '@renderer/hooks/useProvider'
|
import { useProvider } from '@renderer/hooks/useProvider'
|
||||||
import { getProviderLabel } from '@renderer/i18n/label'
|
import { getProviderLabel } from '@renderer/i18n/label'
|
||||||
import { SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle } from '@renderer/pages/settings'
|
import { SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle } from '@renderer/pages/settings'
|
||||||
@ -86,7 +86,7 @@ const ModelList: React.FC<ModelListProps> = ({ providerId }) => {
|
|||||||
}, [provider.id])
|
}, [provider.id])
|
||||||
|
|
||||||
const onAddModel = useCallback(() => {
|
const onAddModel = useCallback(() => {
|
||||||
if (provider.id === 'new-api') {
|
if (isNewApiProvider(provider)) {
|
||||||
NewApiAddModelPopup.show({ title: t('settings.models.add.add_model'), provider })
|
NewApiAddModelPopup.show({ title: t('settings.models.add.add_model'), provider })
|
||||||
} else {
|
} else {
|
||||||
AddModelPopup.show({ title: t('settings.models.add.add_model'), provider })
|
AddModelPopup.show({ title: t('settings.models.add.add_model'), provider })
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { TopView } from '@renderer/components/TopView'
|
import { TopView } from '@renderer/components/TopView'
|
||||||
import { endpointTypeOptions } from '@renderer/config/endpointTypes'
|
import { endpointTypeOptions } from '@renderer/config/endpointTypes'
|
||||||
import { isNotSupportedTextDelta } from '@renderer/config/models'
|
import { isNotSupportedTextDelta } from '@renderer/config/models'
|
||||||
|
import { isNewApiProvider } from '@renderer/config/providers'
|
||||||
import { useDynamicLabelWidth } from '@renderer/hooks/useDynamicLabelWidth'
|
import { useDynamicLabelWidth } from '@renderer/hooks/useDynamicLabelWidth'
|
||||||
import { useProvider } from '@renderer/hooks/useProvider'
|
import { useProvider } from '@renderer/hooks/useProvider'
|
||||||
import { EndpointType, Model, Provider } from '@renderer/types'
|
import { EndpointType, Model, Provider } from '@renderer/types'
|
||||||
@ -60,7 +61,7 @@ const PopupContainer: React.FC<Props> = ({ title, provider, resolve, model, endp
|
|||||||
provider: provider.id,
|
provider: provider.id,
|
||||||
name: values.name ? values.name : id.toUpperCase(),
|
name: values.name ? values.name : id.toUpperCase(),
|
||||||
group: values.group ?? getDefaultGroupName(id),
|
group: values.group ?? getDefaultGroupName(id),
|
||||||
endpoint_type: provider.id === 'new-api' ? values.endpointType : undefined
|
endpoint_type: isNewApiProvider(provider) ? values.endpointType : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
addModel({ ...model, supported_text_delta: !isNotSupportedTextDelta(model) })
|
addModel({ ...model, supported_text_delta: !isNotSupportedTextDelta(model) })
|
||||||
|
|||||||
@ -15,22 +15,23 @@ export const createThinkingCallbacks = (deps: ThinkingCallbacksDependencies) =>
|
|||||||
|
|
||||||
// 内部维护的状态
|
// 内部维护的状态
|
||||||
let thinkingBlockId: string | null = null
|
let thinkingBlockId: string | null = null
|
||||||
|
let _thinking_millsec = 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onThinkingStart: async () => {
|
onThinkingStart: async () => {
|
||||||
if (blockManager.hasInitialPlaceholder) {
|
if (blockManager.hasInitialPlaceholder) {
|
||||||
const changes = {
|
const changes: Partial<MessageBlock> = {
|
||||||
type: MessageBlockType.THINKING,
|
type: MessageBlockType.THINKING,
|
||||||
content: '',
|
content: '',
|
||||||
status: MessageBlockStatus.STREAMING,
|
status: MessageBlockStatus.STREAMING,
|
||||||
thinking_millsec: 0
|
thinking_millsec: _thinking_millsec
|
||||||
}
|
}
|
||||||
thinkingBlockId = blockManager.initialPlaceholderBlockId!
|
thinkingBlockId = blockManager.initialPlaceholderBlockId!
|
||||||
blockManager.smartBlockUpdate(thinkingBlockId, changes, MessageBlockType.THINKING, true)
|
blockManager.smartBlockUpdate(thinkingBlockId, changes, MessageBlockType.THINKING, true)
|
||||||
} else if (!thinkingBlockId) {
|
} else if (!thinkingBlockId) {
|
||||||
const newBlock = createThinkingBlock(assistantMsgId, '', {
|
const newBlock = createThinkingBlock(assistantMsgId, '', {
|
||||||
status: MessageBlockStatus.STREAMING,
|
status: MessageBlockStatus.STREAMING,
|
||||||
thinking_millsec: 0
|
thinking_millsec: _thinking_millsec
|
||||||
})
|
})
|
||||||
thinkingBlockId = newBlock.id
|
thinkingBlockId = newBlock.id
|
||||||
await blockManager.handleBlockTransition(newBlock, MessageBlockType.THINKING)
|
await blockManager.handleBlockTransition(newBlock, MessageBlockType.THINKING)
|
||||||
@ -38,26 +39,27 @@ export const createThinkingCallbacks = (deps: ThinkingCallbacksDependencies) =>
|
|||||||
},
|
},
|
||||||
|
|
||||||
onThinkingChunk: async (text: string, thinking_millsec?: number) => {
|
onThinkingChunk: async (text: string, thinking_millsec?: number) => {
|
||||||
|
_thinking_millsec = thinking_millsec || 0
|
||||||
if (thinkingBlockId) {
|
if (thinkingBlockId) {
|
||||||
const blockChanges: Partial<MessageBlock> = {
|
const blockChanges: Partial<MessageBlock> = {
|
||||||
content: text,
|
content: text,
|
||||||
status: MessageBlockStatus.STREAMING,
|
status: MessageBlockStatus.STREAMING,
|
||||||
thinking_millsec: thinking_millsec || 0
|
thinking_millsec: _thinking_millsec
|
||||||
}
|
}
|
||||||
blockManager.smartBlockUpdate(thinkingBlockId, blockChanges, MessageBlockType.THINKING)
|
blockManager.smartBlockUpdate(thinkingBlockId, blockChanges, MessageBlockType.THINKING)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onThinkingComplete: (finalText: string, final_thinking_millsec?: number) => {
|
onThinkingComplete: (finalText: string) => {
|
||||||
if (thinkingBlockId) {
|
if (thinkingBlockId) {
|
||||||
const changes = {
|
const changes: Partial<MessageBlock> = {
|
||||||
type: MessageBlockType.THINKING,
|
|
||||||
content: finalText,
|
content: finalText,
|
||||||
status: MessageBlockStatus.SUCCESS,
|
status: MessageBlockStatus.SUCCESS,
|
||||||
thinking_millsec: final_thinking_millsec || 0
|
thinking_millsec: _thinking_millsec
|
||||||
}
|
}
|
||||||
blockManager.smartBlockUpdate(thinkingBlockId, changes, MessageBlockType.THINKING, true)
|
blockManager.smartBlockUpdate(thinkingBlockId, changes, MessageBlockType.THINKING, true)
|
||||||
thinkingBlockId = null
|
thinkingBlockId = null
|
||||||
|
_thinking_millsec = 0
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[onThinkingComplete] Received thinking.complete but last block was not THINKING (was ${blockManager.lastBlockType}) or lastBlockId is null.`
|
`[onThinkingComplete] Received thinking.complete but last block was not THINKING (was ${blockManager.lastBlockType}) or lastBlockId is null.`
|
||||||
|
|||||||
@ -46,8 +46,9 @@ const assistantsSlice = createSlice({
|
|||||||
removeAssistant: (state, action: PayloadAction<{ id: string }>) => {
|
removeAssistant: (state, action: PayloadAction<{ id: string }>) => {
|
||||||
state.assistants = state.assistants.filter((c) => c.id !== action.payload.id)
|
state.assistants = state.assistants.filter((c) => c.id !== action.payload.id)
|
||||||
},
|
},
|
||||||
updateAssistant: (state, action: PayloadAction<Partial<Assistant>>) => {
|
updateAssistant: (state, action: PayloadAction<Partial<Assistant> & { id: string }>) => {
|
||||||
state.assistants = state.assistants.map((c) => (c.id === action.payload.id ? { ...c, ...action.payload } : c))
|
const { id, ...update } = action.payload
|
||||||
|
state.assistants = state.assistants.map((c) => (c.id === id ? { ...c, ...update } : c))
|
||||||
},
|
},
|
||||||
updateAssistantSettings: (
|
updateAssistantSettings: (
|
||||||
state,
|
state,
|
||||||
|
|||||||
@ -2495,6 +2495,7 @@ const migrateConfig = {
|
|||||||
'157': (state: RootState) => {
|
'157': (state: RootState) => {
|
||||||
try {
|
try {
|
||||||
addProvider(state, 'aionly')
|
addProvider(state, 'aionly')
|
||||||
|
state.llm.providers = moveProvider(state.llm.providers, 'aionly', 10)
|
||||||
|
|
||||||
const cherryinProvider = state.llm.providers.find((provider) => provider.id === 'cherryin')
|
const cherryinProvider = state.llm.providers.find((provider) => provider.id === 'cherryin')
|
||||||
|
|
||||||
|
|||||||
@ -410,7 +410,8 @@ describe('streamCallback Integration Tests', () => {
|
|||||||
{ type: ChunkType.THINKING_START },
|
{ type: ChunkType.THINKING_START },
|
||||||
{ type: ChunkType.THINKING_DELTA, text: 'Let me think...', thinking_millsec: 1000 },
|
{ type: ChunkType.THINKING_DELTA, text: 'Let me think...', thinking_millsec: 1000 },
|
||||||
{ type: ChunkType.THINKING_DELTA, text: 'I need to consider...', thinking_millsec: 2000 },
|
{ type: ChunkType.THINKING_DELTA, text: 'I need to consider...', thinking_millsec: 2000 },
|
||||||
{ type: ChunkType.THINKING_COMPLETE, text: 'Final thoughts', thinking_millsec: 3000 },
|
{ type: ChunkType.THINKING_DELTA, text: 'Final thoughts', thinking_millsec: 3000 },
|
||||||
|
{ type: ChunkType.THINKING_COMPLETE, text: 'Final thoughts' },
|
||||||
{ type: ChunkType.BLOCK_COMPLETE }
|
{ type: ChunkType.BLOCK_COMPLETE }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
16
yarn.lock
16
yarn.lock
@ -155,7 +155,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@ai-sdk/google@npm:2.0.14, @ai-sdk/google@npm:^2.0.14":
|
"@ai-sdk/google@npm:2.0.14":
|
||||||
version: 2.0.14
|
version: 2.0.14
|
||||||
resolution: "@ai-sdk/google@npm:2.0.14"
|
resolution: "@ai-sdk/google@npm:2.0.14"
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -167,6 +167,18 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch":
|
||||||
|
version: 2.0.14
|
||||||
|
resolution: "@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch::version=2.0.14&hash=a91bb2"
|
||||||
|
dependencies:
|
||||||
|
"@ai-sdk/provider": "npm:2.0.0"
|
||||||
|
"@ai-sdk/provider-utils": "npm:3.0.9"
|
||||||
|
peerDependencies:
|
||||||
|
zod: ^3.25.76 || ^4
|
||||||
|
checksum: 10c0/5ec33dc9898457b1f48ed14cb767817345032c539dd21b7e21985ed47bc21b0820922b581bf349bb3898136790b12da3a0a7c9903c333a28ead0c3c2cd5230f2
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@ai-sdk/mistral@npm:^2.0.14":
|
"@ai-sdk/mistral@npm:^2.0.14":
|
||||||
version: 2.0.14
|
version: 2.0.14
|
||||||
resolution: "@ai-sdk/mistral@npm:2.0.14"
|
resolution: "@ai-sdk/mistral@npm:2.0.14"
|
||||||
@ -2374,7 +2386,7 @@ __metadata:
|
|||||||
"@ai-sdk/anthropic": "npm:^2.0.17"
|
"@ai-sdk/anthropic": "npm:^2.0.17"
|
||||||
"@ai-sdk/azure": "npm:^2.0.30"
|
"@ai-sdk/azure": "npm:^2.0.30"
|
||||||
"@ai-sdk/deepseek": "npm:^1.0.17"
|
"@ai-sdk/deepseek": "npm:^1.0.17"
|
||||||
"@ai-sdk/google": "npm:^2.0.14"
|
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch"
|
||||||
"@ai-sdk/openai": "npm:^2.0.30"
|
"@ai-sdk/openai": "npm:^2.0.30"
|
||||||
"@ai-sdk/openai-compatible": "npm:^1.0.17"
|
"@ai-sdk/openai-compatible": "npm:^1.0.17"
|
||||||
"@ai-sdk/provider": "npm:^2.0.0"
|
"@ai-sdk/provider": "npm:^2.0.0"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user