Compare commits

..

13 Commits

Author SHA1 Message Date
beyondkmp 00754f3644 Merge branch 'main' into betterSqlite 2025-11-21 10:27:20 +08:00
SuYao dcdd1bf852 refactor: replace renderToolContent function with ToolContent component for improved readability (#11300)
* refactor: replace renderToolContent function with ToolContent component for improved readability

* fix

* fix test
2025-11-21 09:55:46 +08:00
beyondkmp 36a9af3e6b Merge branch 'main' into betterSqlite 2025-11-21 09:16:36 +08:00
beyondkmp a12b6bfeca feat: enable native language emoji search with CLDR data format (#11381)
* feat: add i18n support and local data to emoji picker

- Add emoji-picker-element-data package for offline-first emoji data
- Implement i18n translations for emoji picker UI (de, en, es, fr, ja, pt, ru, zh)
- Switch from CDN to local emoji data to improve performance and reliability
- Add locale mapping to match app language with emoji picker data
- Move emoji-picker-element import to EmojiPicker component for better encapsulation
- Use proper TypeScript types instead of 'any' for type safety

This improves user experience by providing localized emoji picker interface
and eliminating dependency on external CDN, ensuring the picker works offline.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: enable native language emoji search with CLDR data format

Switch from emojibase to CLDR format for emoji-picker-element data to support full multi-language search functionality. Users can now search for emojis in their native language (e.g., German users can search "Herz" for ❤️, Spanish users can search "corazón"). Also improves type safety by using the LanguageVarious type for locale mappings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-20 19:23:27 +08:00
beyondkmp 67e032344b Merge branch 'main' into betterSqlite 2025-11-20 15:17:57 +08:00
beyondkmp 59a8f3c47d add rebuild 2025-11-20 15:15:02 +08:00
亢奋猫 0f1a487bb0 refactor: simplify agent creation form (#11369)
* refactor(AgentModal): simplify agent type handling and update default values

- Removed unused agent type options and related logic.
- Updated default agent name from 'Claude Code' to 'Agent'.
- Adjusted padding in button styles and textarea rows for better UI consistency.
- Cleaned up unnecessary imports and code comments for improved readability.

* refactor(AgentSettings): clean up and enhance name setting component

- Removed unused imports and commented-out code in AgentModal and EssentialSettings.
- Updated NameSetting to include an emoji avatar picker for enhanced user experience.
- Simplified the logic for updating the agent's name and avatar.
- Improved overall readability and maintainability of the code.
2025-11-20 10:42:49 +08:00
亢奋猫 2df8bb58df fix: remove light background from MCP NpxUv install alerts (#11372)
- Remove 'banner' prop from Alert components in InstallNpxUv
- Set SettingContainer background to 'inherit' in MCP settings
- Fixes the light background color issue in NpxUv interface

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-20 10:41:41 +08:00
defi-failure 62976f6fe0 refactor: namespace tool call ids with session id to prevent conflicts (#11319) 2025-11-20 10:35:11 +08:00
MyPrototypeWhat 77529b3cd3 chore: update ai-core release scripts and bump version to 1.0.7 (#11370)
* chore: update ai-core release scripts and bump version to 1.0.7

* chore: update ai-sdk-provider release script to include build step and enhance type exports in webSearchPlugin and providers

* chore: bump @cherrystudio/ai-core version to 1.0.8 and update dependencies in package.json and yarn.lock

* chore: bump @cherrystudio/ai-core version to 1.0.9 and @cherrystudio/ai-sdk-provider version to 0.1.2 in package.json and yarn.lock

---------

Co-authored-by: suyao <sy20010504@gmail.com>
2025-11-19 20:44:22 +08:00
SuYao c8e9a10190 bump ai core version (#11363)
* bump ai core version

* chore

* chore: add patch for @ai-sdk/openai and update peer dependencies in aiCore

* chore: update installation instructions in README to include @ai-sdk/google and @ai-sdk/openai

* chore: bump @cherrystudio/ai-core version to 1.0.6 in package.json and yarn.lock

---------

Co-authored-by: MyPrototypeWhat <daoquqiexing@gmail.com>
2025-11-19 18:13:33 +08:00
scientia 0e011ff35f fix: fix api-host for vercel ai-gateway provider (#11321)
Co-authored-by: scientia <wangdenghui@xiaomi.com>
2025-11-19 17:11:17 +08:00
beyondkmp fadb436c7d feat: integrate better-sqlite3 for database management
- Replaced @libsql/client with better-sqlite3 for improved database handling.
- Updated database interactions in BaseService, MigrationService, and various repository classes to utilize better-sqlite3 methods.
- Added better-sqlite3 and its types to package.json and yarn.lock.
- Adjusted database connection logic and query execution to align with better-sqlite3 API.
2025-11-19 15:40:26 +08:00
41 changed files with 812 additions and 564 deletions
+11 -4
View File
@@ -74,9 +74,12 @@
"format:check": "biome format && biome lint",
"prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky",
"claude": "dotenv -e .env -- claude",
"release:aicore:alpha": "yarn workspace @cherrystudio/ai-core version prerelease --immediate && yarn workspace @cherrystudio/ai-core npm publish --tag alpha --access public",
"release:aicore:beta": "yarn workspace @cherrystudio/ai-core version prerelease --immediate && yarn workspace @cherrystudio/ai-core npm publish --tag beta --access public",
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
"release:aicore:alpha": "yarn workspace @cherrystudio/ai-core version prerelease --preid alpha --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm publish --tag alpha --access public",
"release:aicore:beta": "yarn workspace @cherrystudio/ai-core version prerelease --preid beta --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm publish --tag beta --access public",
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm publish --access public",
"release:ai-sdk-provider": "yarn workspace @cherrystudio/ai-sdk-provider version patch --immediate && yarn workspace @cherrystudio/ai-sdk-provider build && yarn workspace @cherrystudio/ai-sdk-provider npm publish --access public",
"rebuild": "electron-rebuild -f -w better-sqlite3",
"postinstall": "electron-builder install-app-deps"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.30#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.patch",
@@ -85,6 +88,8 @@
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
"@paymoapp/electron-shutdown-handler": "^1.1.2",
"@strongtz/win32-arm64-msvc": "^0.4.7",
"better-sqlite3": "12.4.1",
"emoji-picker-element-data": "^1",
"express": "^5.1.0",
"font-list": "^2.0.0",
"graceful-fs": "^4.2.11",
@@ -115,6 +120,7 @@
"@ai-sdk/google-vertex": "^3.0.68",
"@ai-sdk/huggingface": "patch:@ai-sdk/huggingface@npm%3A0.0.8#~/.yarn/patches/@ai-sdk-huggingface-npm-0.0.8-d4d0aaac93.patch",
"@ai-sdk/mistral": "^2.0.23",
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch",
"@ai-sdk/perplexity": "^2.0.17",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@anthropic-ai/sdk": "^0.41.0",
@@ -123,7 +129,7 @@
"@aws-sdk/client-bedrock-runtime": "^3.910.0",
"@aws-sdk/client-s3": "^3.910.0",
"@biomejs/biome": "2.2.4",
"@cherrystudio/ai-core": "workspace:^1.0.0-alpha.18",
"@cherrystudio/ai-core": "workspace:^1.0.9",
"@cherrystudio/embedjs": "^0.1.31",
"@cherrystudio/embedjs-libsql": "^0.1.31",
"@cherrystudio/embedjs-loader-csv": "^0.1.31",
@@ -198,6 +204,7 @@
"@tiptap/y-tiptap": "^3.0.0",
"@truto/turndown-plugin-gfm": "^1.0.2",
"@tryfabric/martian": "^1.2.4",
"@types/better-sqlite3": "^7.6.12",
"@types/cli-progress": "^3",
"@types/content-type": "^1.1.9",
"@types/cors": "^2.8.19",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cherrystudio/ai-sdk-provider",
"version": "0.1.0",
"version": "0.1.2",
"description": "Cherry Studio AI SDK provider bundle with CherryIN routing.",
"keywords": [
"ai-sdk",
+1 -1
View File
@@ -71,7 +71,7 @@ Cherry Studio AI Core 是一个基于 Vercel AI SDK 的统一 AI Provider 接口
## 安装
```bash
npm install @cherrystudio/ai-core ai
npm install @cherrystudio/ai-core ai @ai-sdk/google @ai-sdk/openai
```
### React Native
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@cherrystudio/ai-core",
"version": "1.0.1",
"version": "1.0.9",
"description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK",
"main": "dist/index.js",
"module": "dist/index.mjs",
@@ -33,19 +33,19 @@
},
"homepage": "https://github.com/CherryHQ/cherry-studio#readme",
"peerDependencies": {
"@ai-sdk/google": "^2.0.36",
"@ai-sdk/openai": "^2.0.64",
"@cherrystudio/ai-sdk-provider": "^0.1.2",
"ai": "^5.0.26"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.43",
"@ai-sdk/azure": "^2.0.66",
"@ai-sdk/deepseek": "^1.0.27",
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.36#~/.yarn/patches/@ai-sdk-google-npm-2.0.36-6f3cc06026.patch",
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch",
"@ai-sdk/openai-compatible": "^1.0.26",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.16",
"@ai-sdk/xai": "^2.0.31",
"@cherrystudio/ai-sdk-provider": "workspace:*",
"zod": "^4.1.5"
},
"devDependencies": {
@@ -4,12 +4,7 @@
*/
export const BUILT_IN_PLUGIN_PREFIX = 'built-in:'
export { googleToolsPlugin } from './googleToolsPlugin'
export { createLoggingPlugin } from './logging'
export { createPromptToolUsePlugin } from './toolUsePlugin/promptToolUsePlugin'
export type {
PromptToolUseConfig,
ToolUseRequestContext,
ToolUseResult
} from './toolUsePlugin/type'
export { webSearchPlugin, type WebSearchPluginConfig } from './webSearchPlugin'
export * from './googleToolsPlugin'
export * from './toolUsePlugin/promptToolUsePlugin'
export * from './toolUsePlugin/type'
export * from './webSearchPlugin'
@@ -32,7 +32,7 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR
})
// 导出类型定义供开发者使用
export type { WebSearchPluginConfig, WebSearchToolOutputSchema } from './helper'
export * from './helper'
// 默认导出
export default webSearchPlugin
+1 -1
View File
@@ -44,7 +44,7 @@ export {
// ==================== 基础数据和类型 ====================
// 基础Provider数据源
export { baseProviderIds, baseProviders } from './schemas'
export { baseProviderIds, baseProviders, isBaseProvider } from './schemas'
// 类型定义和Schema
export type {
@@ -7,7 +7,6 @@ import { createAzure } from '@ai-sdk/azure'
import { type AzureOpenAIProviderSettings } from '@ai-sdk/azure'
import { createDeepSeek } from '@ai-sdk/deepseek'
import { createGoogleGenerativeAI } from '@ai-sdk/google'
import { createHuggingFace } from '@ai-sdk/huggingface'
import { createOpenAI, type OpenAIProviderSettings } from '@ai-sdk/openai'
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
import type { LanguageModelV2 } from '@ai-sdk/provider'
@@ -33,8 +32,7 @@ export const baseProviderIds = [
'deepseek',
'openrouter',
'cherryin',
'cherryin-chat',
'huggingface'
'cherryin-chat'
] as const
/**
@@ -158,12 +156,6 @@ export const baseProviders = [
})
},
supportsImageGeneration: true
},
{
id: 'huggingface',
name: 'HuggingFace',
creator: createHuggingFace,
supportsImageGeneration: true
}
] as const satisfies BaseProvider[]
-6
View File
@@ -104,12 +104,6 @@ const router = express
logger.warn('No models available from providers', { filter })
}
logger.info('Models response ready', {
filter,
total: response.total,
modelIds: response.data.map((m) => m.id)
})
return res.json(response satisfies ApiModelsResponse)
} catch (error: any) {
logger.error('Error fetching models', { error })
+1 -1
View File
@@ -32,7 +32,7 @@ export class ModelsService {
for (const model of models) {
const provider = providers.find((p) => p.id === model.provider)
logger.debug(`Processing model ${model.id}`)
// logger.debug(`Processing model ${model.id}`)
if (!provider) {
logger.debug(`Skipping model ${model.id} . Reason: Provider not found.`)
continue
+7 -9
View File
@@ -1,11 +1,11 @@
import { type Client, createClient } from '@libsql/client'
import { loggerService } from '@logger'
import { mcpApiService } from '@main/apiServer/services/mcp'
import type { ModelValidationError } from '@main/apiServer/utils'
import { validateModelId } from '@main/apiServer/utils'
import type { AgentType, MCPTool, SlashCommand, Tool } from '@types'
import { objectKeys } from '@types'
import { drizzle, type LibSQLDatabase } from 'drizzle-orm/libsql'
import Database from 'better-sqlite3'
import { type BetterSQLite3Database, drizzle } from 'drizzle-orm/better-sqlite3'
import fs from 'fs'
import path from 'path'
@@ -32,8 +32,8 @@ const logger = loggerService.withContext('BaseService')
* - Connection retry logic with exponential backoff
*/
export abstract class BaseService {
protected static client: Client | null = null
protected static db: LibSQLDatabase<typeof schema> | null = null
protected static client: Database.Database | null = null
protected static db: BetterSQLite3Database<typeof schema> | null = null
protected static isInitialized = false
protected static initializationPromise: Promise<void> | null = null
protected jsonFields: string[] = [
@@ -116,9 +116,7 @@ export abstract class BaseService {
fs.mkdirSync(dbDir, { recursive: true })
}
BaseService.client = createClient({
url: `file:${dbPath}`
})
BaseService.client = new Database(dbPath)
BaseService.db = drizzle(BaseService.client, { schema })
@@ -165,12 +163,12 @@ export abstract class BaseService {
}
}
protected get database(): LibSQLDatabase<typeof schema> {
protected get database(): BetterSQLite3Database<typeof schema> {
this.ensureInitialized()
return BaseService.db!
}
protected get rawClient(): Client {
protected get rawClient(): Database.Database {
this.ensureInitialized()
return BaseService.client!
}
@@ -1,7 +1,7 @@
import { type Client } from '@libsql/client'
import { loggerService } from '@logger'
import { getResourcePath } from '@main/utils'
import { type LibSQLDatabase } from 'drizzle-orm/libsql'
import type Database from 'better-sqlite3'
import { type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
import fs from 'fs'
import path from 'path'
@@ -23,11 +23,11 @@ interface MigrationJournal {
}
export class MigrationService {
private db: LibSQLDatabase<typeof schema>
private client: Client
private db: BetterSQLite3Database<typeof schema>
private client: Database.Database
private migrationDir: string
constructor(db: LibSQLDatabase<typeof schema>, client: Client) {
constructor(db: BetterSQLite3Database<typeof schema>, client: Database.Database) {
this.db = db
this.client = client
this.migrationDir = path.join(getResourcePath(), 'database', 'drizzle')
@@ -88,8 +88,8 @@ export class MigrationService {
private async migrationsTableExists(): Promise<boolean> {
try {
const table = await this.client.execute(`SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'`)
return table.rows.length > 0
const rows = this.client.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'`).all()
return rows.length > 0
} catch (error) {
logger.error('Failed to check migrations table status:', { error })
throw error
@@ -136,7 +136,7 @@ export class MigrationService {
// Read and execute SQL
const sqlContent = fs.readFileSync(sqlFilePath, 'utf-8')
await this.client.executeMultiple(sqlContent)
this.client.exec(sqlContent)
// Record migration as applied (store journal idx as version for tracking)
const newMigration: NewMigration = {
@@ -91,17 +91,18 @@ class AgentMessageRepository extends BaseService {
return tx ?? this.database
}
private async findExistingMessageRow(
private findExistingMessageRow(
writer: TxClient,
sessionId: string,
role: string,
messageId: string
): Promise<SessionMessageRow | null> {
const candidateRows: SessionMessageRow[] = await writer
): SessionMessageRow | null {
const candidateRows: SessionMessageRow[] = writer
.select()
.from(sessionMessagesTable)
.where(and(eq(sessionMessagesTable.session_id, sessionId), eq(sessionMessagesTable.role, role)))
.orderBy(asc(sessionMessagesTable.created_at))
.all()
for (const row of candidateRows) {
if (!row?.content) continue
@@ -119,12 +120,9 @@ class AgentMessageRepository extends BaseService {
return null
}
private async upsertMessage(
private upsertMessageSync(
params: PersistUserMessageParams | PersistAssistantMessageParams
): Promise<AgentSessionMessageEntity> {
await AgentMessageRepository.initialize()
this.ensureInitialized()
): AgentSessionMessageEntity {
const { sessionId, agentSessionId = '', payload, metadata, createdAt, tx } = params
if (!payload?.message?.role) {
@@ -140,13 +138,13 @@ class AgentMessageRepository extends BaseService {
const serializedPayload = this.serializeMessage(payload)
const serializedMetadata = this.serializeMetadata(metadata)
const existingRow = await this.findExistingMessageRow(writer, sessionId, payload.message.role, payload.message.id)
const existingRow = this.findExistingMessageRow(writer, sessionId, payload.message.role, payload.message.id)
if (existingRow) {
const metadataToPersist = serializedMetadata ?? existingRow.metadata ?? undefined
const agentSessionToPersist = agentSessionId || existingRow.agent_session_id || ''
await writer
writer
.update(sessionMessagesTable)
.set({
content: serializedPayload,
@@ -155,6 +153,7 @@ class AgentMessageRepository extends BaseService {
updated_at: now
})
.where(eq(sessionMessagesTable.id, existingRow.id))
.run()
return this.deserialize({
...existingRow,
@@ -175,11 +174,19 @@ class AgentMessageRepository extends BaseService {
updated_at: now
}
const [saved] = await writer.insert(sessionMessagesTable).values(insertData).returning()
const [saved] = writer.insert(sessionMessagesTable).values(insertData).returning().all()
return this.deserialize(saved)
}
private async upsertMessage(
params: PersistUserMessageParams | PersistAssistantMessageParams
): Promise<AgentSessionMessageEntity> {
await AgentMessageRepository.initialize()
this.ensureInitialized()
return this.upsertMessageSync(params)
}
async persistUserMessage(params: PersistUserMessageParams): Promise<AgentSessionMessageEntity> {
return this.upsertMessage({ ...params, agentSessionId: params.agentSessionId ?? '' })
}
@@ -194,11 +201,11 @@ class AgentMessageRepository extends BaseService {
const { sessionId, agentSessionId, user, assistant } = params
const result = await this.database.transaction(async (tx) => {
const result = this.database.transaction((tx) => {
const exchangeResult: PersistExchangeResult = {}
if (user?.payload) {
exchangeResult.userMessage = await this.persistUserMessage({
exchangeResult.userMessage = this.upsertMessageSync({
sessionId,
agentSessionId,
payload: user.payload,
@@ -209,7 +216,7 @@ class AgentMessageRepository extends BaseService {
}
if (assistant?.payload) {
exchangeResult.assistantMessage = await this.persistAssistantMessage({
exchangeResult.assistantMessage = this.upsertMessageSync({
sessionId,
agentSessionId,
payload: assistant.payload,
+1 -1
View File
@@ -24,7 +24,7 @@ export default defineConfig({
schema: './src/main/services/agents/database/schema/index.ts',
out: './resources/database/drizzle',
dbCredentials: {
url: `file:${resolvedDbPath}`
url: resolvedDbPath
},
verbose: true,
strict: true
@@ -202,9 +202,9 @@ export class AgentService extends BaseService {
async deleteAgent(id: string): Promise<boolean> {
this.ensureInitialized()
const result = await this.database.delete(agentsTable).where(eq(agentsTable.id, id))
const result = this.database.delete(agentsTable).where(eq(agentsTable.id, id)).run()
return result.rowsAffected > 0
return result.changes > 0
}
async agentExists(id: string): Promise<boolean> {
@@ -148,11 +148,12 @@ export class SessionMessageService extends BaseService {
async deleteSessionMessage(sessionId: string, messageId: number): Promise<boolean> {
this.ensureInitialized()
const result = await this.database
const result = this.database
.delete(sessionMessagesTable)
.where(and(eq(sessionMessagesTable.id, messageId), eq(sessionMessagesTable.session_id, sessionId)))
.run()
return result.rowsAffected > 0
return result.changes > 0
}
async createSessionMessage(
@@ -270,11 +270,12 @@ export class SessionService extends BaseService {
async deleteSession(agentId: string, id: string): Promise<boolean> {
this.ensureInitialized()
const result = await this.database
const result = this.database
.delete(sessionsTable)
.where(and(eq(sessionsTable.id, id), eq(sessionsTable.agent_id, agentId)))
.run()
return result.rowsAffected > 0
return result.changes > 0
}
async sessionExists(agentId: string, id: string): Promise<boolean> {
@@ -21,11 +21,16 @@ describe('stripLocalCommandTags', () => {
'<local-command-stdout>line1</local-command-stdout>\nkeep\n<local-command-stderr>Error</local-command-stderr>'
expect(stripLocalCommandTags(input)).toBe('line1\nkeep\nError')
})
it('if no tags present, returns original string', () => {
const input = 'just some normal text'
expect(stripLocalCommandTags(input)).toBe(input)
})
})
describe('Claude → AiSDK transform', () => {
it('handles tool call streaming lifecycle', () => {
const state = new ClaudeStreamState()
const state = new ClaudeStreamState({ agentSessionId: baseStreamMetadata.session_id })
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
const messages: SDKMessage[] = [
@@ -182,14 +187,119 @@ describe('Claude → AiSDK transform', () => {
(typeof parts)[number],
{ type: 'tool-result' }
>
expect(toolResult.toolCallId).toBe('tool-1')
expect(toolResult.toolCallId).toBe('session-123:tool-1')
expect(toolResult.toolName).toBe('Bash')
expect(toolResult.input).toEqual({ command: 'ls' })
expect(toolResult.output).toBe('ok')
})
it('handles tool calls without streaming events (no content_block_start/stop)', () => {
const state = new ClaudeStreamState({ agentSessionId: '12344' })
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
const messages: SDKMessage[] = [
{
...baseStreamMetadata,
type: 'assistant',
uuid: uuid(20),
message: {
id: 'msg-tool-no-stream',
type: 'message',
role: 'assistant',
model: 'claude-test',
content: [
{
type: 'tool_use',
id: 'tool-read',
name: 'Read',
input: { file_path: '/test.txt' }
},
{
type: 'tool_use',
id: 'tool-bash',
name: 'Bash',
input: { command: 'ls -la' }
}
],
stop_reason: 'tool_use',
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20
}
}
} as unknown as SDKMessage,
{
...baseStreamMetadata,
type: 'user',
uuid: uuid(21),
message: {
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-read',
content: 'file contents',
is_error: false
}
]
}
} as SDKMessage,
{
...baseStreamMetadata,
type: 'user',
uuid: uuid(22),
message: {
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-bash',
content: 'total 42\n...',
is_error: false
}
]
}
} 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(['tool-call', 'tool-call', 'tool-result', 'tool-result'])
const toolCalls = parts.filter((part) => part.type === 'tool-call') as Extract<
(typeof parts)[number],
{ type: 'tool-call' }
>[]
expect(toolCalls).toHaveLength(2)
expect(toolCalls[0].toolName).toBe('Read')
expect(toolCalls[0].toolCallId).toBe('12344:tool-read')
expect(toolCalls[1].toolName).toBe('Bash')
expect(toolCalls[1].toolCallId).toBe('12344:tool-bash')
const toolResults = parts.filter((part) => part.type === 'tool-result') as Extract<
(typeof parts)[number],
{ type: 'tool-result' }
>[]
expect(toolResults).toHaveLength(2)
// This is the key assertion - toolName should NOT be 'unknown'
expect(toolResults[0].toolName).toBe('Read')
expect(toolResults[0].toolCallId).toBe('12344:tool-read')
expect(toolResults[0].input).toEqual({ file_path: '/test.txt' })
expect(toolResults[0].output).toBe('file contents')
expect(toolResults[1].toolName).toBe('Bash')
expect(toolResults[1].toolCallId).toBe('12344:tool-bash')
expect(toolResults[1].input).toEqual({ command: 'ls -la' })
expect(toolResults[1].output).toBe('total 42\n...')
})
it('handles streaming text completion', () => {
const state = new ClaudeStreamState()
const state = new ClaudeStreamState({ agentSessionId: baseStreamMetadata.session_id })
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
const messages: SDKMessage[] = [
@@ -300,4 +410,87 @@ describe('Claude → AiSDK transform', () => {
expect(finishStep.finishReason).toBe('stop')
expect(finishStep.usage).toEqual({ inputTokens: 2, outputTokens: 4, totalTokens: 6 })
})
it('emits fallback text when Claude sends a snapshot instead of deltas', () => {
const state = new ClaudeStreamState({ agentSessionId: '12344' })
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
const messages: SDKMessage[] = [
{
...baseStreamMetadata,
type: 'stream_event',
uuid: uuid(30),
event: {
type: 'message_start',
message: {
id: 'msg-fallback',
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(31),
event: {
type: 'content_block_start',
index: 0,
content_block: {
type: 'text',
text: ''
}
}
} as unknown as SDKMessage,
{
...baseStreamMetadata,
type: 'assistant',
uuid: uuid(32),
message: {
id: 'msg-fallback-content',
type: 'message',
role: 'assistant',
model: 'claude-test',
content: [
{
type: 'text',
text: 'Final answer without streaming deltas.'
}
],
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 3,
output_tokens: 7
}
}
} as unknown 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-end', 'finish-step'])
const delta = parts.find((part) => part.type === 'text-delta') as Extract<
(typeof parts)[number],
{ type: 'text-delta' }
>
expect(delta.text).toBe('Final answer without streaming deltas.')
const finish = parts.find((part) => part.type === 'finish-step') as Extract<
(typeof parts)[number],
{ type: 'finish-step' }
>
expect(finish.usage).toEqual({ inputTokens: 3, outputTokens: 7, totalTokens: 10 })
expect(finish.finishReason).toBe('stop')
})
})
@@ -10,8 +10,21 @@
* 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 { loggerService } from '@logger'
import type { FinishReason, LanguageModelUsage, ProviderMetadata } from 'ai'
/**
* Builds a namespaced tool call ID by combining session ID with raw tool call ID.
* This ensures tool calls from different sessions don't conflict even if they have
* the same raw ID from the SDK.
*
* @param sessionId - The agent session ID
* @param rawToolCallId - The raw tool call ID from SDK (e.g., "WebFetch_0")
*/
export function buildNamespacedToolCallId(sessionId: string, rawToolCallId: string): string {
return `${sessionId}:${rawToolCallId}`
}
/**
* Shared fields for every block that Claude can stream (text, reasoning, tool).
*/
@@ -34,6 +47,7 @@ type ReasoningBlockState = BaseBlockState & {
type ToolBlockState = BaseBlockState & {
kind: 'tool'
toolCallId: string
rawToolCallId: string
toolName: string
inputBuffer: string
providerMetadata?: ProviderMetadata
@@ -48,12 +62,17 @@ type PendingUsageState = {
}
type PendingToolCall = {
rawToolCallId: string
toolCallId: string
toolName: string
input: unknown
providerMetadata?: ProviderMetadata
}
type ClaudeStreamStateOptions = {
agentSessionId: string
}
/**
* Tracks the lifecycle of Claude streaming blocks (text, thinking, tool calls)
* across individual websocket events. The transformer relies on this class to
@@ -61,12 +80,20 @@ type PendingToolCall = {
* usage/finish metadata once Anthropic closes a message.
*/
export class ClaudeStreamState {
private logger
private readonly agentSessionId: string
private blocksByIndex = new Map<number, BlockState>()
private toolIndexById = new Map<string, number>()
private toolIndexByNamespacedId = new Map<string, number>()
private pendingUsage: PendingUsageState = {}
private pendingToolCalls = new Map<string, PendingToolCall>()
private stepActive = false
constructor(options: ClaudeStreamStateOptions) {
this.logger = loggerService.withContext('ClaudeStreamState')
this.agentSessionId = options.agentSessionId
this.logger.silly('ClaudeStreamState', options)
}
/** Marks the beginning of a new AiSDK step. */
beginStep(): void {
this.stepActive = true
@@ -104,19 +131,21 @@ export class ClaudeStreamState {
/** Caches tool metadata so subsequent input deltas and results can find it. */
openToolBlock(
index: number,
params: { toolCallId: string; toolName: string; providerMetadata?: ProviderMetadata }
params: { rawToolCallId: string; toolName: string; providerMetadata?: ProviderMetadata }
): ToolBlockState {
const toolCallId = buildNamespacedToolCallId(this.agentSessionId, params.rawToolCallId)
const block: ToolBlockState = {
kind: 'tool',
id: params.toolCallId,
id: toolCallId,
index,
toolCallId: params.toolCallId,
toolCallId,
rawToolCallId: params.rawToolCallId,
toolName: params.toolName,
inputBuffer: '',
providerMetadata: params.providerMetadata
}
this.blocksByIndex.set(index, block)
this.toolIndexById.set(params.toolCallId, index)
this.toolIndexByNamespacedId.set(toolCallId, index)
return block
}
@@ -124,14 +153,32 @@ export class ClaudeStreamState {
return this.blocksByIndex.get(index)
}
getFirstOpenTextBlock(): TextBlockState | undefined {
const candidates: TextBlockState[] = []
for (const block of this.blocksByIndex.values()) {
if (block.kind === 'text') {
candidates.push(block)
}
}
if (candidates.length === 0) {
return undefined
}
candidates.sort((a, b) => a.index - b.index)
return candidates[0]
}
getToolBlockById(toolCallId: string): ToolBlockState | undefined {
const index = this.toolIndexById.get(toolCallId)
const index = this.toolIndexByNamespacedId.get(toolCallId)
if (index === undefined) return undefined
const block = this.blocksByIndex.get(index)
if (!block || block.kind !== 'tool') return undefined
return block
}
getToolBlockByRawId(rawToolCallId: string): ToolBlockState | undefined {
return this.getToolBlockById(buildNamespacedToolCallId(this.agentSessionId, rawToolCallId))
}
/** 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)
@@ -158,10 +205,12 @@ export class ClaudeStreamState {
/** Records a tool call to be consumed once its result arrives from the user. */
registerToolCall(
toolCallId: string,
rawToolCallId: string,
payload: { toolName: string; input: unknown; providerMetadata?: ProviderMetadata }
): void {
this.pendingToolCalls.set(toolCallId, {
const toolCallId = buildNamespacedToolCallId(this.agentSessionId, rawToolCallId)
this.pendingToolCalls.set(rawToolCallId, {
rawToolCallId,
toolCallId,
toolName: payload.toolName,
input: payload.input,
@@ -170,10 +219,10 @@ export class ClaudeStreamState {
}
/** Retrieves and clears the buffered tool call metadata for the given id. */
consumePendingToolCall(toolCallId: string): PendingToolCall | undefined {
const entry = this.pendingToolCalls.get(toolCallId)
consumePendingToolCall(rawToolCallId: string): PendingToolCall | undefined {
const entry = this.pendingToolCalls.get(rawToolCallId)
if (entry) {
this.pendingToolCalls.delete(toolCallId)
this.pendingToolCalls.delete(rawToolCallId)
}
return entry
}
@@ -182,13 +231,13 @@ export class ClaudeStreamState {
* 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 {
completeToolBlock(toolCallId: string, toolName: string, input: unknown, providerMetadata?: ProviderMetadata): void {
const block = this.getToolBlockByRawId(toolCallId)
this.registerToolCall(toolCallId, {
toolName: this.getToolBlockById(toolCallId)?.toolName ?? 'unknown',
toolName,
input,
providerMetadata
})
const block = this.getToolBlockById(toolCallId)
if (block) {
block.resolvedInput = input
}
@@ -200,7 +249,7 @@ export class ClaudeStreamState {
if (!block) return undefined
this.blocksByIndex.delete(index)
if (block.kind === 'tool') {
this.toolIndexById.delete(block.toolCallId)
this.toolIndexByNamespacedId.delete(block.toolCallId)
}
return block
}
@@ -227,7 +276,7 @@ export class ClaudeStreamState {
/** Drops cached block metadata for the currently active message. */
resetBlocks(): void {
this.blocksByIndex.clear()
this.toolIndexById.clear()
this.toolIndexByNamespacedId.clear()
}
/** Resets the entire step lifecycle after emitting a terminal frame. */
@@ -236,6 +285,10 @@ export class ClaudeStreamState {
this.resetPendingUsage()
this.stepActive = false
}
getNamespacedToolCallId(rawToolCallId: string): string {
return buildNamespacedToolCallId(this.agentSessionId, rawToolCallId)
}
}
export type { PendingToolCall }
@@ -13,6 +13,7 @@ import { app } from 'electron'
import type { GetAgentSessionResponse } from '../..'
import type { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
import { sessionService } from '../SessionService'
import { buildNamespacedToolCallId } from './claude-stream-state'
import { promptForToolApproval } from './tool-permissions'
import { ClaudeStreamState, transformSDKMessageToStreamParts } from './transform'
@@ -150,7 +151,10 @@ class ClaudeCodeService implements AgentServiceInterface {
return { behavior: 'allow', updatedInput: input }
}
return promptForToolApproval(toolName, input, options)
return promptForToolApproval(toolName, input, {
...options,
toolCallId: buildNamespacedToolCallId(session.id, options.toolUseID)
})
}
// Build SDK options from parameters
@@ -346,7 +350,7 @@ class ClaudeCodeService implements AgentServiceInterface {
const jsonOutput: SDKMessage[] = []
let hasCompleted = false
const startTime = Date.now()
const streamState = new ClaudeStreamState()
const streamState = new ClaudeStreamState({ agentSessionId: sessionId })
try {
for await (const message of query({ prompt: promptStream, options })) {
@@ -410,23 +414,6 @@ class ClaudeCodeService implements AgentServiceInterface {
}
}
if (message.type === 'assistant' || message.type === 'user') {
logger.silly('claude response', {
message,
content: JSON.stringify(message.message.content)
})
} else if (message.type === 'stream_event') {
// logger.silly('Claude stream event', {
// message,
// event: JSON.stringify(message.event)
// })
} else {
logger.silly('Claude response', {
message,
event: JSON.stringify(message)
})
}
const chunks = transformSDKMessageToStreamParts(message, streamState)
for (const chunk of chunks) {
stream.emit('data', {
@@ -37,6 +37,7 @@ type RendererPermissionRequestPayload = {
requestId: string
toolName: string
toolId: string
toolCallId: string
description?: string
requiresPermissions: boolean
input: Record<string, unknown>
@@ -206,10 +207,19 @@ const ensureIpcHandlersRegistered = () => {
})
}
type PromptForToolApprovalOptions = {
signal: AbortSignal
suggestions?: PermissionUpdate[]
// NOTICE: This ID is namespaced with session ID, not the raw SDK tool call ID.
// Format: `${sessionId}:${rawToolCallId}`, e.g., `session_123:WebFetch_0`
toolCallId: string
}
export async function promptForToolApproval(
toolName: string,
input: Record<string, unknown>,
options?: { signal: AbortSignal; suggestions?: PermissionUpdate[] }
options: PromptForToolApprovalOptions
): Promise<PermissionResult> {
if (shouldAutoApproveTools) {
logger.debug('promptForToolApproval auto-approving tool for test', {
@@ -245,6 +255,7 @@ export async function promptForToolApproval(
logger.info('Requesting user approval for tool usage', {
requestId,
toolName,
toolCallId: options.toolCallId,
description: toolMetadata?.description
})
@@ -252,6 +263,7 @@ export async function promptForToolApproval(
requestId,
toolName,
toolId: toolMetadata?.id ?? toolName,
toolCallId: options.toolCallId,
description: toolMetadata?.description,
requiresPermissions: toolMetadata?.requirePermissions ?? false,
input: sanitizedInput,
@@ -266,6 +278,7 @@ export async function promptForToolApproval(
logger.debug('Registering tool permission request', {
requestId,
toolName,
toolCallId: options.toolCallId,
requiresPermissions: requestPayload.requiresPermissions,
timeoutMs: TOOL_APPROVAL_TIMEOUT_MS,
suggestionCount: sanitizedSuggestions.length
@@ -273,7 +286,11 @@ export async function promptForToolApproval(
return new Promise<PermissionResult>((resolve) => {
const timeout = setTimeout(() => {
logger.info('User tool permission request timed out', { requestId, toolName })
logger.info('User tool permission request timed out', {
requestId,
toolName,
toolCallId: options.toolCallId
})
finalizeRequest(requestId, { behavior: 'deny', message: 'Timed out waiting for approval' }, 'timeout')
}, TOOL_APPROVAL_TIMEOUT_MS)
@@ -287,7 +304,11 @@ export async function promptForToolApproval(
if (options?.signal) {
const abortListener = () => {
logger.info('Tool permission request aborted before user responded', { requestId, toolName })
logger.info('Tool permission request aborted before user responded', {
requestId,
toolName,
toolCallId: options.toolCallId
})
finalizeRequest(requestId, defaultDenyUpdate, 'aborted')
}
@@ -110,7 +110,7 @@ const sdkMessageToProviderMetadata = (message: SDKMessage): ProviderMetadata =>
* blocks across calls so that incremental deltas can be correlated correctly.
*/
export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage, state: ClaudeStreamState): AgentStreamPart[] {
logger.silly('Transforming SDKMessage', { message: sdkMessage })
logger.silly('Transforming SDKMessage', { message: JSON.stringify(sdkMessage) })
switch (sdkMessage.type) {
case 'assistant':
return handleAssistantMessage(sdkMessage, state)
@@ -186,14 +186,13 @@ function handleAssistantMessage(
for (const block of content) {
switch (block.type) {
case 'text':
if (!isStreamingActive) {
const sanitizedText = stripLocalCommandTags(block.text)
if (sanitizedText) {
textBlocks.push(sanitizedText)
}
case 'text': {
const sanitizedText = stripLocalCommandTags(block.text)
if (sanitizedText) {
textBlocks.push(sanitizedText)
}
break
}
case 'tool_use':
handleAssistantToolUse(block as ToolUseContent, providerMetadata, state, chunks)
break
@@ -203,7 +202,16 @@ function handleAssistantMessage(
}
}
if (!isStreamingActive && textBlocks.length > 0) {
if (textBlocks.length === 0) {
return chunks
}
const combinedText = textBlocks.join('')
if (!combinedText) {
return chunks
}
if (!isStreamingActive) {
const id = message.uuid?.toString() || generateMessageId()
state.beginStep()
chunks.push({
@@ -219,7 +227,7 @@ function handleAssistantMessage(
chunks.push({
type: 'text-delta',
id,
text: textBlocks.join(''),
text: combinedText,
providerMetadata
})
chunks.push({
@@ -230,7 +238,27 @@ function handleAssistantMessage(
return finalizeNonStreamingStep(message, state, chunks)
}
return chunks
const existingTextBlock = state.getFirstOpenTextBlock()
const fallbackId = existingTextBlock?.id || message.uuid?.toString() || generateMessageId()
if (!existingTextBlock) {
chunks.push({
type: 'text-start',
id: fallbackId,
providerMetadata
})
}
chunks.push({
type: 'text-delta',
id: fallbackId,
text: combinedText,
providerMetadata
})
chunks.push({
type: 'text-end',
id: fallbackId,
providerMetadata
})
return finalizeNonStreamingStep(message, state, chunks)
}
/**
@@ -243,15 +271,16 @@ function handleAssistantToolUse(
state: ClaudeStreamState,
chunks: AgentStreamPart[]
): void {
const toolCallId = state.getNamespacedToolCallId(block.id)
chunks.push({
type: 'tool-call',
toolCallId: block.id,
toolCallId,
toolName: block.name,
input: block.input,
providerExecuted: true,
providerMetadata
})
state.completeToolBlock(block.id, block.input, providerMetadata)
state.completeToolBlock(block.id, block.name, block.input, providerMetadata)
}
/**
@@ -331,10 +360,11 @@ function handleUserMessage(
if (block.type === 'tool_result') {
const toolResult = block as ToolResultContent
const pendingCall = state.consumePendingToolCall(toolResult.tool_use_id)
const toolCallId = pendingCall?.toolCallId ?? state.getNamespacedToolCallId(toolResult.tool_use_id)
if (toolResult.is_error) {
chunks.push({
type: 'tool-error',
toolCallId: toolResult.tool_use_id,
toolCallId,
toolName: pendingCall?.toolName ?? 'unknown',
input: pendingCall?.input,
error: toolResult.content,
@@ -343,7 +373,7 @@ function handleUserMessage(
} else {
chunks.push({
type: 'tool-result',
toolCallId: toolResult.tool_use_id,
toolCallId,
toolName: pendingCall?.toolName ?? 'unknown',
input: pendingCall?.input,
output: toolResult.content,
@@ -457,6 +487,9 @@ function handleStreamEvent(
}
case 'message_stop': {
if (!state.hasActiveStep()) {
break
}
const pending = state.getPendingUsage()
chunks.push({
type: 'finish-step',
@@ -514,7 +547,7 @@ function handleContentBlockStart(
}
case 'tool_use': {
const block = state.openToolBlock(index, {
toolCallId: contentBlock.id,
rawToolCallId: contentBlock.id,
toolName: contentBlock.name,
providerMetadata
})
@@ -6,28 +6,20 @@
import { loggerService } from '@logger'
import { isImageEnhancementModel, isVisionModel } from '@renderer/config/models'
import type { Message, Model } from '@renderer/types'
import type {
FileMessageBlock,
ImageMessageBlock,
ThinkingMessageBlock,
ToolMessageBlock
} from '@renderer/types/newMessage'
import type { FileMessageBlock, ImageMessageBlock, ThinkingMessageBlock } from '@renderer/types/newMessage'
import {
findFileBlocks,
findImageBlocks,
findThinkingBlocks,
findToolBlocks,
getMainTextContent
} from '@renderer/utils/messageUtils/find'
import type {
AssistantContent,
AssistantModelMessage,
FilePart,
ImagePart,
ModelMessage,
SystemModelMessage,
TextPart,
ToolCallPart,
ToolResultPart,
UserModelMessage
} from 'ai'
@@ -48,11 +40,10 @@ export async function convertMessageToSdkParam(
const fileBlocks = findFileBlocks(message)
const imageBlocks = findImageBlocks(message)
const reasoningBlocks = findThinkingBlocks(message)
const toolBlocks = findToolBlocks(message)
if (message.role === 'user' || message.role === 'system') {
return convertMessageToUserModelMessage(content, fileBlocks, imageBlocks, isVisionModel, model)
} else {
return convertMessageToAssistantAndToolMessages(content, fileBlocks, toolBlocks, reasoningBlocks, model)
return convertMessageToAssistantModelMessage(content, fileBlocks, reasoningBlocks, model)
}
}
@@ -156,65 +147,30 @@ async function convertMessageToUserModelMessage(
}
}
function convertToolBlockToToolCallPart(toolBlock: ToolMessageBlock): ToolCallPart {
return {
type: 'tool-call',
toolCallId: toolBlock.toolId,
toolName: toolBlock.toolName || 'unknown',
input: toolBlock.arguments || {}
}
}
function convertToolBlockToToolResultPart(toolBlock: ToolMessageBlock): ToolResultPart {
const content = toolBlock.content
let output: ToolResultPart['output']
if (content === undefined || content === null) {
output = { type: 'text', value: '' }
} else if (typeof content === 'string') {
output = { type: 'text', value: content }
} else {
output = { type: 'json', value: JSON.parse(JSON.stringify(content)) }
}
return {
type: 'tool-result',
toolCallId: toolBlock.toolId,
toolName: toolBlock.toolName || 'unknown',
output
}
}
function hasToolResult(toolBlock: ToolMessageBlock): boolean {
return toolBlock.content !== undefined && toolBlock.content !== null
}
async function convertMessageToAssistantAndToolMessages(
/**
* 转换为助手模型消息
*/
async function convertMessageToAssistantModelMessage(
content: string,
fileBlocks: FileMessageBlock[],
toolBlocks: ToolMessageBlock[],
thinkingBlocks: ThinkingMessageBlock[],
model?: Model
): Promise<ModelMessage | ModelMessage[]> {
const assistantParts: AssistantContent = []
// 添加文本内容
): Promise<AssistantModelMessage> {
const parts: Array<TextPart | FilePart> = []
if (content) {
assistantParts.push({ type: 'text', text: content })
parts.push({ type: 'text', text: content })
}
// 添加推理内容
for (const thinkingBlock of thinkingBlocks) {
assistantParts.push({ type: 'reasoning', text: thinkingBlock.content })
parts.push({ type: 'text', text: thinkingBlock.content })
}
// 处理文件
for (const fileBlock of fileBlocks) {
// 优先尝试原生文件支持(PDF等)
if (model) {
const filePart = await convertFileBlockToFilePart(fileBlock, model)
if (filePart) {
assistantParts.push(filePart)
parts.push(filePart)
continue
}
}
@@ -222,33 +178,13 @@ async function convertMessageToAssistantAndToolMessages(
// 回退到文本处理
const textPart = await convertFileBlockToTextPart(fileBlock)
if (textPart) {
assistantParts.push(textPart)
}
}
// 如果没有 tool blocks,直接返回 assistant 消息
if (toolBlocks.length === 0) {
return {
role: 'assistant',
content: assistantParts
}
}
// 处理 tool blocks
// 将 tool calls 和 tool results 都添加到 assistant 消息的 content 中
for (const toolBlock of toolBlocks) {
// 添加 tool call
assistantParts.push(convertToolBlockToToolCallPart(toolBlock))
// 如果有结果,添加 tool result
if (hasToolResult(toolBlock)) {
assistantParts.push(convertToolBlockToToolResultPart(toolBlock))
parts.push(textPart)
}
}
return {
role: 'assistant',
content: assistantParts
content: parts
}
}
+3 -3
View File
@@ -99,9 +99,6 @@ export function buildProviderOptions(
serviceTier: serviceTierSetting
}
break
case 'huggingface':
providerSpecificOptions = buildOpenAIProviderOptions(assistant, model, capabilities)
break
case 'anthropic':
providerSpecificOptions = buildAnthropicProviderOptions(assistant, model, capabilities)
break
@@ -144,6 +141,9 @@ export function buildProviderOptions(
case 'bedrock':
providerSpecificOptions = buildBedrockProviderOptions(assistant, model, capabilities)
break
case 'huggingface':
providerSpecificOptions = buildOpenAIProviderOptions(assistant, model, capabilities)
break
default:
// 对于其他 provider,使用通用的构建逻辑
providerSpecificOptions = {
@@ -1,35 +1,120 @@
import 'emoji-picker-element'
import TwemojiCountryFlagsWoff2 from '@renderer/assets/fonts/country-flag-fonts/TwemojiCountryFlags.woff2?url'
import { useTheme } from '@renderer/context/ThemeProvider'
import type { LanguageVarious } from '@renderer/types'
import { polyfillCountryFlagEmojis } from 'country-flag-emoji-polyfill'
// i18n translations from emoji-picker-element
import de from 'emoji-picker-element/i18n/de'
import en from 'emoji-picker-element/i18n/en'
import es from 'emoji-picker-element/i18n/es'
import fr from 'emoji-picker-element/i18n/fr'
import ja from 'emoji-picker-element/i18n/ja'
import pt_PT from 'emoji-picker-element/i18n/pt_PT'
import ru_RU from 'emoji-picker-element/i18n/ru_RU'
import zh_CN from 'emoji-picker-element/i18n/zh_CN'
import type Picker from 'emoji-picker-element/picker'
import type { EmojiClickEvent, NativeEmoji } from 'emoji-picker-element/shared'
// Emoji data from emoji-picker-element-data (local, no CDN)
// Using CLDR format for full multi-language search support (28 languages)
import dataDE from 'emoji-picker-element-data/de/cldr/data.json?url'
import dataEN from 'emoji-picker-element-data/en/cldr/data.json?url'
import dataES from 'emoji-picker-element-data/es/cldr/data.json?url'
import dataFR from 'emoji-picker-element-data/fr/cldr/data.json?url'
import dataJA from 'emoji-picker-element-data/ja/cldr/data.json?url'
import dataPT from 'emoji-picker-element-data/pt/cldr/data.json?url'
import dataRU from 'emoji-picker-element-data/ru/cldr/data.json?url'
import dataZH from 'emoji-picker-element-data/zh/cldr/data.json?url'
import dataZH_HANT from 'emoji-picker-element-data/zh-hant/cldr/data.json?url'
import type { FC } from 'react'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
onEmojiClick: (emoji: string) => void
}
// Mapping from app locale to emoji-picker-element i18n
const i18nMap: Record<LanguageVarious, typeof en> = {
'en-US': en,
'zh-CN': zh_CN,
'zh-TW': zh_CN, // Closest available
'de-DE': de,
'el-GR': en, // No Greek available, fallback to English
'es-ES': es,
'fr-FR': fr,
'ja-JP': ja,
'pt-PT': pt_PT,
'ru-RU': ru_RU
}
// Mapping from app locale to emoji data URL
// Using CLDR format provides native language search support for all locales
const dataSourceMap: Record<LanguageVarious, string> = {
'en-US': dataEN,
'zh-CN': dataZH,
'zh-TW': dataZH_HANT,
'de-DE': dataDE,
'el-GR': dataEN, // No Greek CLDR available, fallback to English
'es-ES': dataES,
'fr-FR': dataFR,
'ja-JP': dataJA,
'pt-PT': dataPT,
'ru-RU': dataRU
}
// Mapping from app locale to emoji-picker-element locale string
// Must match the data source locale for proper IndexedDB caching
const localeMap: Record<LanguageVarious, string> = {
'en-US': 'en',
'zh-CN': 'zh',
'zh-TW': 'zh-hant',
'de-DE': 'de',
'el-GR': 'en',
'es-ES': 'es',
'fr-FR': 'fr',
'ja-JP': 'ja',
'pt-PT': 'pt',
'ru-RU': 'ru'
}
const EmojiPicker: FC<Props> = ({ onEmojiClick }) => {
const { theme } = useTheme()
const ref = useRef<HTMLDivElement>(null)
const { i18n } = useTranslation()
const ref = useRef<Picker>(null)
const currentLocale = i18n.language as LanguageVarious
useEffect(() => {
polyfillCountryFlagEmojis('Twemoji Mozilla', TwemojiCountryFlagsWoff2)
}, [])
// Configure picker with i18n and dataSource
useEffect(() => {
const refValue = ref.current
const picker = ref.current
if (picker) {
picker.i18n = i18nMap[currentLocale] || en
picker.dataSource = dataSourceMap[currentLocale] || dataEN
picker.locale = localeMap[currentLocale] || 'en'
}
}, [currentLocale])
if (refValue) {
const handleEmojiClick = (event: any) => {
useEffect(() => {
const picker = ref.current
if (picker) {
const handleEmojiClick = (event: EmojiClickEvent) => {
event.stopPropagation()
onEmojiClick(event.detail.unicode || event.detail.emoji.unicode)
const { detail } = event
// Use detail.unicode (processed with skin tone) or fallback to emoji's unicode for native emoji
const unicode = detail.unicode || ('unicode' in detail.emoji ? (detail.emoji as NativeEmoji).unicode : '')
onEmojiClick(unicode)
}
// 添加事件监听器
refValue.addEventListener('emoji-click', handleEmojiClick)
picker.addEventListener('emoji-click', handleEmojiClick)
// 清理事件监听器
return () => {
refValue.removeEventListener('emoji-click', handleEmojiClick)
picker.removeEventListener('emoji-click', handleEmojiClick)
}
}
return
@@ -1,5 +1,4 @@
import { loggerService } from '@logger'
import ClaudeIcon from '@renderer/assets/images/models/claude.png'
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
import { TopView } from '@renderer/components/TopView'
import { permissionModeCards } from '@renderer/config/agent'
@@ -9,7 +8,6 @@ import SelectAgentBaseModelButton from '@renderer/pages/home/components/SelectAg
import type {
AddAgentForm,
AgentEntity,
AgentType,
ApiModel,
BaseAgentForm,
PermissionMode,
@@ -17,30 +15,22 @@ import type {
UpdateAgentForm
} from '@renderer/types'
import { AgentConfigurationSchema, isAgentType } from '@renderer/types'
import { Avatar, Button, Input, Modal, Select } from 'antd'
import { Button, Input, Modal, Select } from 'antd'
import { AlertTriangleIcon } from 'lucide-react'
import type { ChangeEvent, FormEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import type { BaseOption } from './shared'
const { TextArea } = Input
const logger = loggerService.withContext('AddAgentPopup')
interface AgentTypeOption extends BaseOption {
type: 'type'
key: AgentEntity['type']
name: AgentEntity['name']
}
type AgentWithTools = AgentEntity & { tools?: Tool[] }
const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({
type: existing?.type ?? 'claude-code',
name: existing?.name ?? 'Claude Code',
name: existing?.name ?? 'Agent',
description: existing?.description,
instructions: existing?.instructions,
model: existing?.model ?? '',
@@ -100,54 +90,6 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
})
}, [])
// add supported agents type here.
const agentConfig = useMemo(
() =>
[
{
type: 'type',
key: 'claude-code',
label: 'Claude Code',
name: 'Claude Code',
avatar: ClaudeIcon
}
] as const satisfies AgentTypeOption[],
[]
)
const agentOptions = useMemo(
() =>
agentConfig.map((option) => ({
value: option.key,
label: (
<OptionWrapper>
<Avatar src={option.avatar} size={24} />
<span>{option.label}</span>
</OptionWrapper>
)
})),
[agentConfig]
)
const onAgentTypeChange = useCallback(
(value: AgentType) => {
const prevConfig = agentConfig.find((config) => config.key === form.type)
let newName: string | undefined = form.name
if (prevConfig && prevConfig.name === form.name) {
const newConfig = agentConfig.find((config) => config.key === value)
if (newConfig) {
newName = newConfig.name
}
}
setForm((prev) => ({
...prev,
type: value,
name: newName
}))
},
[agentConfig, form.name, form.type]
)
const onNameChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({
...prev,
@@ -155,12 +97,12 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
}))
}, [])
const onDescChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
setForm((prev) => ({
...prev,
description: e.target.value
}))
}, [])
// const onDescChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
// setForm((prev) => ({
// ...prev,
// description: e.target.value
// }))
// }, [])
const onInstChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
setForm((prev) => ({
@@ -334,16 +276,6 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
<StyledForm onSubmit={onSubmit}>
<FormContent>
<FormRow>
<FormItem style={{ flex: 1 }}>
<Label>{t('agent.type.label')}</Label>
<Select
value={form.type}
onChange={onAgentTypeChange}
options={agentOptions}
disabled={isEditing(agent)}
style={{ width: '100%' }}
/>
</FormItem>
<FormItem style={{ flex: 1 }}>
<Label>
{t('common.name')} <RequiredMark>*</RequiredMark>
@@ -363,7 +295,7 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
avatarSize={24}
iconSize={16}
buttonStyle={{
padding: '8px 12px',
padding: '3px 8px',
width: '100%',
border: '1px solid var(--color-border)',
borderRadius: 6,
@@ -382,7 +314,6 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
onChange={onPermissionModeChange}
style={{ width: '100%' }}
placeholder={t('agent.settings.tooling.permissionMode.placeholder', 'Select permission mode')}
dropdownStyle={{ minWidth: '500px' }}
optionLabelProp="label">
{permissionModeCards.map((item) => (
<Select.Option key={item.mode} value={item.mode} label={t(item.titleKey, item.titleFallback)}>
@@ -438,10 +369,10 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
<TextArea rows={3} value={form.instructions ?? ''} onChange={onInstChange} />
</FormItem>
<FormItem>
{/* <FormItem>
<Label>{t('common.description')}</Label>
<TextArea rows={2} value={form.description ?? ''} onChange={onDescChange} />
</FormItem>
<TextArea rows={1} value={form.description ?? ''} onChange={onDescChange} />
</FormItem> */}
</FormContent>
<FormFooter>
@@ -575,14 +506,7 @@ const FormFooter = styled.div`
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
border-top: 1px solid var(--color-border);
`
const OptionWrapper = styled.div`
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
`
const PermissionOptionWrapper = styled.div`
+1 -1
View File
@@ -686,7 +686,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
name: 'AI Gateway',
type: 'ai-gateway',
apiKey: '',
apiHost: 'https://ai-gateway.vercel.sh/v1',
apiHost: 'https://ai-gateway.vercel.sh/v1/ai',
models: [],
isSystem: true,
enabled: false
@@ -1,7 +1,6 @@
import type { CollapseProps } from 'antd'
import { Tag } from 'antd'
import { CheckCircle, Terminal, XCircle } from 'lucide-react'
import { useMemo } from 'react'
import { ToolTitle } from './GenericTools'
import type { BashOutputToolInput, BashOutputToolOutput } from './types'
@@ -16,6 +15,63 @@ interface ParsedBashOutput {
tool_use_error?: string
}
const parseBashOutput = (output?: BashOutputToolOutput): ParsedBashOutput | null => {
if (!output) return null
try {
const parser = new DOMParser()
const hasToolError = output.includes('<tool_use_error>')
const xmlStr = output.includes('<status>') || hasToolError ? `<root>${output}</root>` : output
const xmlDoc = parser.parseFromString(xmlStr, 'application/xml')
const parserError = xmlDoc.querySelector('parsererror')
if (parserError) return null
const getElementText = (tagName: string): string | undefined => {
const element = xmlDoc.getElementsByTagName(tagName)[0]
return element?.textContent?.trim()
}
return {
status: getElementText('status'),
exit_code: getElementText('exit_code') ? parseInt(getElementText('exit_code')!) : undefined,
stdout: getElementText('stdout'),
stderr: getElementText('stderr'),
timestamp: getElementText('timestamp'),
tool_use_error: getElementText('tool_use_error')
}
} catch {
return null
}
}
const getStatusConfig = (parsedOutput: ParsedBashOutput | null) => {
if (!parsedOutput) return null
if (parsedOutput.tool_use_error) {
return {
color: 'danger',
icon: <XCircle className="h-3.5 w-3.5" />,
text: 'Error'
} as const
}
const isCompleted = parsedOutput.status === 'completed'
const isSuccess = parsedOutput.exit_code === 0
return {
color: isCompleted && isSuccess ? 'success' : isCompleted && !isSuccess ? 'danger' : 'warning',
icon:
isCompleted && isSuccess ? (
<CheckCircle className="h-3.5 w-3.5" />
) : isCompleted && !isSuccess ? (
<XCircle className="h-3.5 w-3.5" />
) : (
<Terminal className="h-3.5 w-3.5" />
),
text: isCompleted ? (isSuccess ? 'Success' : 'Failed') : 'Running'
} as const
}
export function BashOutputTool({
input,
output
@@ -23,73 +79,8 @@ export function BashOutputTool({
input: BashOutputToolInput
output?: BashOutputToolOutput
}): NonNullable<CollapseProps['items']>[number] {
// 解析 XML 输出
const parsedOutput = useMemo(() => {
if (!output) return null
try {
const parser = new DOMParser()
// 检查是否包含 tool_use_error 标签
const hasToolError = output.includes('<tool_use_error>')
// 包装成有效的 XML(如果还没有根元素)
const xmlStr = output.includes('<status>') || hasToolError ? `<root>${output}</root>` : output
const xmlDoc = parser.parseFromString(xmlStr, 'application/xml')
// 检查是否有解析错误
const parserError = xmlDoc.querySelector('parsererror')
if (parserError) {
return null
}
const getElementText = (tagName: string): string | undefined => {
const element = xmlDoc.getElementsByTagName(tagName)[0]
return element?.textContent?.trim()
}
const result: ParsedBashOutput = {
status: getElementText('status'),
exit_code: getElementText('exit_code') ? parseInt(getElementText('exit_code')!) : undefined,
stdout: getElementText('stdout'),
stderr: getElementText('stderr'),
timestamp: getElementText('timestamp'),
tool_use_error: getElementText('tool_use_error')
}
return result
} catch {
return null
}
}, [output])
// 获取状态配置
const statusConfig = useMemo(() => {
if (!parsedOutput) return null
// 如果有 tool_use_error,直接显示错误状态
if (parsedOutput.tool_use_error) {
return {
color: 'danger',
icon: <XCircle className="h-3.5 w-3.5" />,
text: 'Error'
} as const
}
const isCompleted = parsedOutput.status === 'completed'
const isSuccess = parsedOutput.exit_code === 0
return {
color: isCompleted && isSuccess ? 'success' : isCompleted && !isSuccess ? 'danger' : 'warning',
icon:
isCompleted && isSuccess ? (
<CheckCircle className="h-3.5 w-3.5" />
) : isCompleted && !isSuccess ? (
<XCircle className="h-3.5 w-3.5" />
) : (
<Terminal className="h-3.5 w-3.5" />
),
text: isCompleted ? (isSuccess ? 'Success' : 'Failed') : 'Running'
} as const
}, [parsedOutput])
const parsedOutput = parseBashOutput(output)
const statusConfig = getStatusConfig(parsedOutput)
const children = parsedOutput ? (
<div className="flex flex-col gap-4">
@@ -1,12 +1,47 @@
import type { CollapseProps } from 'antd'
import { FileText } from 'lucide-react'
import { useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import { ToolTitle } from './GenericTools'
import type { ReadToolInput as ReadToolInputType, ReadToolOutput as ReadToolOutputType, TextOutput } from './types'
import { AgentToolsType } from './types'
const removeSystemReminderTags = (text: string): string => {
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, '')
}
const normalizeOutputString = (output?: ReadToolOutputType): string | null => {
if (!output) return null
const toText = (item: TextOutput) => removeSystemReminderTags(item.text)
if (Array.isArray(output)) {
return output
.filter((item): item is TextOutput => item.type === 'text')
.map(toText)
.join('')
}
return removeSystemReminderTags(output)
}
const getOutputStats = (outputString: string | null) => {
if (!outputString) return null
const bytes = new Blob([outputString]).size
const formatSize = (size: number) => {
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
return `${(size / (1024 * 1024)).toFixed(1)} MB`
}
return {
lineCount: outputString.split('\n').length,
fileSize: bytes,
formatSize
}
}
export function ReadTool({
input,
output
@@ -14,50 +49,8 @@ export function ReadTool({
input: ReadToolInputType
output?: ReadToolOutputType
}): NonNullable<CollapseProps['items']>[number] {
// 移除 system-reminder 标签及其内容的辅助函数
const removeSystemReminderTags = (text: string): string => {
// 使用正则表达式匹配 <system-reminder> 标签及其内容,包括换行符
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, '')
}
// 将 output 统一转换为字符串
const outputString = useMemo(() => {
if (!output) return null
let processedOutput: string
// 如果是 TextOutput[] 类型,提取所有 text 内容
if (Array.isArray(output)) {
processedOutput = output
.filter((item): item is TextOutput => item.type === 'text')
.map((item) => removeSystemReminderTags(item.text))
.join('')
} else {
// 如果是字符串,直接使用
processedOutput = output
}
// 移除 system-reminder 标签及其内容
return removeSystemReminderTags(processedOutput)
}, [output])
// 如果有输出,计算统计信息
const stats = useMemo(() => {
if (!outputString) return null
const bytes = new Blob([outputString]).size
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
return {
lineCount: outputString.split('\n').length,
fileSize: bytes,
formatSize
}
}, [outputString])
const outputString = normalizeOutputString(output)
const stats = getOutputStats(outputString)
return {
key: AgentToolsType.Read,
@@ -11,11 +11,24 @@ interface UnknownToolProps {
output?: unknown
}
export function UnknownToolRenderer({
toolName = '',
input,
output
}: UnknownToolProps): NonNullable<CollapseProps['items']>[number] {
const getToolDisplayName = (name: string) => {
if (name.startsWith('mcp__')) {
const parts = name.substring(5).split('__')
if (parts.length >= 2) {
return `${parts[0]}:${parts.slice(1).join(':')}`
}
}
return name
}
const getToolDescription = (toolName: string) => {
if (toolName.startsWith('mcp__')) {
return 'MCP Server Tool'
}
return 'Tool'
}
const UnknownToolContent = ({ input, output }: { input?: unknown; output?: unknown }) => {
const { highlightCode } = useCodeStyle()
const [inputHtml, setInputHtml] = useState<string>('')
const [outputHtml, setOutputHtml] = useState<string>('')
@@ -34,58 +47,49 @@ export function UnknownToolRenderer({
}
}, [output, highlightCode])
const getToolDisplayName = (name: string) => {
if (name.startsWith('mcp__')) {
const parts = name.substring(5).split('__')
if (parts.length >= 2) {
return `${parts[0]}:${parts.slice(1).join(':')}`
}
}
return name
if (input === undefined && output === undefined) {
return <div className="text-foreground-500 text-xs">No data available for this tool</div>
}
const getToolDescription = () => {
if (toolName.startsWith('mcp__')) {
return 'MCP Server Tool'
}
return 'Tool'
}
return (
<div className="space-y-3">
{input !== undefined && (
<div>
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Input:</div>
<div
className="overflow-x-auto rounded bg-gray-50 dark:bg-gray-900"
dangerouslySetInnerHTML={{ __html: inputHtml }}
/>
</div>
)}
{output !== undefined && (
<div>
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Output:</div>
<div
className="rounded bg-gray-50 dark:bg-gray-900 [&>*]:whitespace-pre-line"
dangerouslySetInnerHTML={{ __html: outputHtml }}
/>
</div>
)}
</div>
)
}
export function UnknownToolRenderer({
toolName = '',
input,
output
}: UnknownToolProps): NonNullable<CollapseProps['items']>[number] {
return {
key: 'unknown-tool',
label: (
<ToolTitle
icon={<Wrench className="h-4 w-4" />}
label={getToolDisplayName(toolName)}
params={getToolDescription()}
params={getToolDescription(toolName)}
/>
),
children: (
<div className="space-y-3">
{input !== undefined && (
<div>
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Input:</div>
<div
className="overflow-x-auto rounded bg-gray-50 dark:bg-gray-900"
dangerouslySetInnerHTML={{ __html: inputHtml }}
/>
</div>
)}
{output !== undefined && (
<div>
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Output:</div>
<div
className="rounded bg-gray-50 dark:bg-gray-900 [&>*]:whitespace-pre-line"
dangerouslySetInnerHTML={{ __html: outputHtml }}
/>
</div>
)}
{input === undefined && output === undefined && (
<div className="text-foreground-500 text-xs">No data available for this tool</div>
)}
</div>
)
children: <UnknownToolContent input={input} output={output} />
}
}
@@ -6,8 +6,6 @@ import { Collapse } from 'antd'
// 导出所有类型
export * from './types'
import { useMemo } from 'react'
// 导入所有渲染器
import ToolPermissionRequestCard from '../ToolPermissionRequestCard'
import { BashOutputTool } from './BashOutputTool'
@@ -57,22 +55,19 @@ export function isValidAgentToolsType(toolName: unknown): toolName is AgentTools
return typeof toolName === 'string' && Object.values(AgentToolsType).includes(toolName as AgentToolsType)
}
// 统一的渲染函数
function renderToolContent(toolName: AgentToolsType, input: ToolInput, output?: ToolOutput) {
// 统一的渲染组件
function ToolContent({ toolName, input, output }: { toolName: AgentToolsType; input: ToolInput; output?: ToolOutput }) {
const Renderer = toolRenderers[toolName]
const renderedItem = Renderer
? Renderer({ input: input as any, output: output as any })
: UnknownToolRenderer({ input: input as any, output: output as any, toolName })
// eslint-disable-next-line react-hooks/rules-of-hooks
const toolContentItem = useMemo(() => {
const rendered = Renderer
? Renderer({ input: input as any, output: output as any })
: UnknownToolRenderer({ input: input as any, output: output as any, toolName })
return {
...rendered,
classNames: {
body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll'
} as NonNullable<CollapseProps['items']>[number]['classNames']
} as NonNullable<CollapseProps['items']>[number]
}, [Renderer, input, output, toolName])
const toolContentItem: NonNullable<CollapseProps['items']>[number] = {
...renderedItem,
classNames: {
body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll'
}
}
return (
<Collapse
@@ -98,5 +93,7 @@ export function MessageAgentTools({ toolResponse }: { toolResponse: NormalToolRe
return <ToolPermissionRequestCard toolResponse={toolResponse} />
}
return renderToolContent(tool.name as AgentToolsType, args as ToolInput, response as ToolOutput)
return (
<ToolContent toolName={tool.name as AgentToolsType} input={args as ToolInput} output={response as ToolOutput} />
)
}
@@ -1,7 +1,7 @@
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import { loggerService } from '@logger'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { selectPendingPermissionByToolName, toolPermissionsActions } from '@renderer/store/toolPermissions'
import { selectPendingPermission, toolPermissionsActions } from '@renderer/store/toolPermissions'
import type { NormalToolResponse } from '@renderer/types'
import { Button } from 'antd'
import { ChevronDown, CirclePlay, CircleX } from 'lucide-react'
@@ -17,9 +17,7 @@ interface Props {
export function ToolPermissionRequestCard({ toolResponse }: Props) {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const request = useAppSelector((state) =>
selectPendingPermissionByToolName(state.toolPermissions, toolResponse.tool.name)
)
const request = useAppSelector((state) => selectPendingPermission(state.toolPermissions, toolResponse.toolCallId))
const [now, setNow] = useState(() => Date.now())
const [showDetails, setShowDetails] = useState(false)
@@ -1,21 +1,13 @@
import { getAgentTypeAvatar } from '@renderer/config/agent'
import type { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import type { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { getAgentTypeLabel } from '@renderer/i18n/label'
import type { GetAgentResponse, GetAgentSessionResponse } from '@renderer/types'
import { isAgentEntity } from '@renderer/types'
import { Avatar } from 'antd'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { AccessibleDirsSetting } from './AccessibleDirsSetting'
import { AvatarSetting } from './AvatarSetting'
import { DescriptionSetting } from './DescriptionSetting'
import { ModelSetting } from './ModelSetting'
import { NameSetting } from './NameSetting'
import { SettingsContainer, SettingsItem, SettingsTitle } from './shared'
// const logger = loggerService.withContext('AgentEssentialSettings')
import { SettingsContainer } from './shared'
type EssentialSettingsProps =
| {
@@ -30,26 +22,10 @@ type EssentialSettingsProps =
}
const EssentialSettings: FC<EssentialSettingsProps> = ({ agentBase, update, showModelSetting = true }) => {
const { t } = useTranslation()
if (!agentBase) return null
const isAgent = isAgentEntity(agentBase)
return (
<SettingsContainer>
{isAgent && (
<SettingsItem inline>
<SettingsTitle>{t('agent.type.label')}</SettingsTitle>
<div className="flex items-center gap-2">
<Avatar size={24} src={getAgentTypeAvatar(agentBase.type)} className="h-6 w-6 text-lg" />
<span>{(agentBase?.name ?? agentBase?.type) ? getAgentTypeLabel(agentBase.type) : ''}</span>
</div>
</SettingsItem>
)}
{isAgent && (
<AvatarSetting agent={agentBase} update={update as ReturnType<typeof useUpdateAgent>['updateAgent']} />
)}
<NameSetting base={agentBase} update={update} />
{showModelSetting && <ModelSetting base={agentBase} update={update} />}
<AccessibleDirsSetting base={agentBase} update={update} />
@@ -1,6 +1,8 @@
import { EmojiAvatarWithPicker } from '@renderer/components/Avatar/EmojiAvatarWithPicker'
import type { AgentBaseWithId, UpdateAgentBaseForm, UpdateAgentFunctionUnion } from '@renderer/types'
import { AgentConfigurationSchema, isAgentEntity, isAgentType } from '@renderer/types'
import { Input } from 'antd'
import { useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingsItem, SettingsTitle } from './shared'
@@ -13,26 +15,61 @@ export interface NameSettingsProps {
export const NameSetting = ({ base, update }: NameSettingsProps) => {
const { t } = useTranslation()
const [name, setName] = useState<string | undefined>(base?.name?.trim())
const updateName = async (name: UpdateAgentBaseForm['name']) => {
if (!base) return
return update({ id: base.id, name: name?.trim() })
}
// Avatar logic
const isAgent = isAgentEntity(base)
const isDefault = isAgent ? isAgentType(base.configuration?.avatar) : false
const [emoji, setEmoji] = useState(isAgent && !isDefault ? (base.configuration?.avatar ?? '⭐️') : '⭐️')
const updateAvatar = useCallback(
(avatar: string) => {
if (!isAgent || !base) return
const parsedConfiguration = AgentConfigurationSchema.parse(base.configuration ?? {})
const payload = {
id: base.id,
configuration: {
...parsedConfiguration,
avatar
}
}
update(payload)
},
[base, update, isAgent]
)
if (!base) return null
return (
<SettingsItem inline>
<SettingsTitle>{t('common.name')}</SettingsTitle>
<Input
placeholder={t('common.agent_one') + t('common.name')}
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={() => {
if (name !== base.name) {
updateName(name)
}
}}
className="max-w-70 flex-1"
/>
<div className="flex max-w-70 flex-1 items-center gap-1">
{isAgent && (
<EmojiAvatarWithPicker
emoji={emoji}
onPick={(emoji: string) => {
setEmoji(emoji)
if (isAgent && emoji === base?.configuration?.avatar) return
updateAvatar(emoji)
}}
/>
)}
<Input
placeholder={t('common.agent_one') + t('common.name')}
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={() => {
if (name !== base.name) {
updateName(name)
}
}}
className="flex-1"
/>
</div>
</SettingsItem>
)
}
@@ -109,7 +109,6 @@ const InstallNpxUv: FC<Props> = ({ mini = false }) => {
<Container>
<Alert
type={isUvInstalled ? 'success' : 'warning'}
banner
style={{ borderRadius: 'var(--list-item-border-radius)' }}
description={
<VStack>
@@ -140,7 +139,6 @@ const InstallNpxUv: FC<Props> = ({ mini = false }) => {
/>
<Alert
type={isBunInstalled ? 'success' : 'warning'}
banner
style={{ borderRadius: 'var(--list-item-border-radius)' }}
description={
<VStack>
@@ -140,7 +140,7 @@ const MCPSettings: FC = () => {
<Route
path="mcp-install"
element={
<SettingContainer theme={theme}>
<SettingContainer style={{ backgroundColor: 'inherit' }}>
<InstallNpxUv />
</SettingContainer>
}
@@ -1,5 +1,3 @@
import 'emoji-picker-element'
import { CheckOutlined, LoadingOutlined, RollbackOutlined, ThunderboltOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import EmojiPicker from '@renderer/components/EmojiPicker'
+3 -1
View File
@@ -585,9 +585,11 @@ const fetchAndProcessAgentResponseImpl = async (
return
}
// Only mark as cleared if there was a previous session ID (not initial assignment)
sessionWasCleared = !!latestAgentSessionId
latestAgentSessionId = sessionId
agentSession.agentSessionId = sessionId
sessionWasCleared = true
logger.debug(`Agent session ID updated`, {
topicId,
+4 -3
View File
@@ -6,6 +6,7 @@ export type ToolPermissionRequestPayload = {
requestId: string
toolName: string
toolId: string
toolCallId: string
description?: string
requiresPermissions: boolean
input: Record<string, unknown>
@@ -82,12 +83,12 @@ export const selectActiveToolPermission = (state: ToolPermissionsState): ToolPer
return activeEntries[0]
}
export const selectPendingPermissionByToolName = (
export const selectPendingPermission = (
state: ToolPermissionsState,
toolName: string
toolCallId: string
): ToolPermissionEntry | undefined => {
const activeEntries = Object.values(state.requests)
.filter((entry) => entry.toolName === toolName)
.filter((entry) => entry.toolCallId === toolCallId)
.filter(
(entry) => entry.status === 'pending' || entry.status === 'submitting-allow' || entry.status === 'submitting-deny'
)
@@ -9,7 +9,6 @@ import type {
Message,
MessageBlock,
ThinkingMessageBlock,
ToolMessageBlock,
TranslationMessageBlock
} from '@renderer/types/newMessage'
import { MessageBlockType } from '@renderer/types/newMessage'
@@ -109,26 +108,6 @@ export const findFileBlocks = (message: Message): FileMessageBlock[] => {
return fileBlocks
}
/**
* Finds all ToolMessageBlocks associated with a given message.
* @param message - The message object.
* @returns An array of ToolMessageBlocks (empty if none found).
*/
export const findToolBlocks = (message: Message): ToolMessageBlock[] => {
if (!message || !message.blocks || message.blocks.length === 0) {
return []
}
const state = store.getState()
const toolBlocks: ToolMessageBlock[] = []
for (const blockId of message.blocks) {
const block = messageBlocksSelectors.selectById(state, blockId)
if (block && block.type === MessageBlockType.TOOL) {
toolBlocks.push(block as ToolMessageBlock)
}
}
return toolBlocks
}
/**
* Gets the concatenated content string from all MainTextMessageBlocks of a message, in order.
* @param message - The message object.
+54 -7
View File
@@ -1891,30 +1891,30 @@ __metadata:
languageName: node
linkType: hard
"@cherrystudio/ai-core@workspace:^1.0.0-alpha.18, @cherrystudio/ai-core@workspace:packages/aiCore":
"@cherrystudio/ai-core@workspace:^1.0.9, @cherrystudio/ai-core@workspace:packages/aiCore":
version: 0.0.0-use.local
resolution: "@cherrystudio/ai-core@workspace:packages/aiCore"
dependencies:
"@ai-sdk/anthropic": "npm:^2.0.43"
"@ai-sdk/azure": "npm:^2.0.66"
"@ai-sdk/deepseek": "npm:^1.0.27"
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.36#~/.yarn/patches/@ai-sdk-google-npm-2.0.36-6f3cc06026.patch"
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch"
"@ai-sdk/openai-compatible": "npm:^1.0.26"
"@ai-sdk/provider": "npm:^2.0.0"
"@ai-sdk/provider-utils": "npm:^3.0.16"
"@ai-sdk/xai": "npm:^2.0.31"
"@cherrystudio/ai-sdk-provider": "workspace:*"
tsdown: "npm:^0.12.9"
typescript: "npm:^5.0.0"
vitest: "npm:^3.2.4"
zod: "npm:^4.1.5"
peerDependencies:
"@ai-sdk/google": ^2.0.36
"@ai-sdk/openai": ^2.0.64
"@cherrystudio/ai-sdk-provider": ^0.1.2
ai: ^5.0.26
languageName: unknown
linkType: soft
"@cherrystudio/ai-sdk-provider@workspace:*, @cherrystudio/ai-sdk-provider@workspace:packages/ai-sdk-provider":
"@cherrystudio/ai-sdk-provider@workspace:packages/ai-sdk-provider":
version: 0.0.0-use.local
resolution: "@cherrystudio/ai-sdk-provider@workspace:packages/ai-sdk-provider"
dependencies:
@@ -8082,6 +8082,15 @@ __metadata:
languageName: node
linkType: hard
"@types/better-sqlite3@npm:^7.6.12":
version: 7.6.13
resolution: "@types/better-sqlite3@npm:7.6.13"
dependencies:
"@types/node": "npm:*"
checksum: 10c0/c4336e7b92343eb0e988ded007c53fa9887b98a38d61175226e86124a1a2c28b1a4e3892873c5041e350b7bfa2901f85c82db1542c4f0eed1d3a899682c92106
languageName: node
linkType: hard
"@types/body-parser@npm:*":
version: 1.19.6
resolution: "@types/body-parser@npm:1.19.6"
@@ -9910,6 +9919,7 @@ __metadata:
"@ai-sdk/google-vertex": "npm:^3.0.68"
"@ai-sdk/huggingface": "patch:@ai-sdk/huggingface@npm%3A0.0.8#~/.yarn/patches/@ai-sdk-huggingface-npm-0.0.8-d4d0aaac93.patch"
"@ai-sdk/mistral": "npm:^2.0.23"
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch"
"@ai-sdk/perplexity": "npm:^2.0.17"
"@ant-design/v5-patch-for-react-19": "npm:^1.0.3"
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.30#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.patch"
@@ -9919,7 +9929,7 @@ __metadata:
"@aws-sdk/client-bedrock-runtime": "npm:^3.910.0"
"@aws-sdk/client-s3": "npm:^3.910.0"
"@biomejs/biome": "npm:2.2.4"
"@cherrystudio/ai-core": "workspace:^1.0.0-alpha.18"
"@cherrystudio/ai-core": "workspace:^1.0.9"
"@cherrystudio/embedjs": "npm:^0.1.31"
"@cherrystudio/embedjs-libsql": "npm:^0.1.31"
"@cherrystudio/embedjs-loader-csv": "npm:^0.1.31"
@@ -9999,6 +10009,7 @@ __metadata:
"@tiptap/y-tiptap": "npm:^3.0.0"
"@truto/turndown-plugin-gfm": "npm:^1.0.2"
"@tryfabric/martian": "npm:^1.2.4"
"@types/better-sqlite3": "npm:^7.6.12"
"@types/cli-progress": "npm:^3"
"@types/content-type": "npm:^1.1.9"
"@types/cors": "npm:^2.8.19"
@@ -10042,6 +10053,7 @@ __metadata:
archiver: "npm:^7.0.1"
async-mutex: "npm:^0.5.0"
axios: "npm:^1.7.3"
better-sqlite3: "npm:12.4.1"
browser-image-compression: "npm:^2.0.2"
chardet: "npm:^2.1.0"
check-disk-space: "npm:3.4.0"
@@ -10073,6 +10085,7 @@ __metadata:
electron-window-state: "npm:^5.0.3"
emittery: "npm:^1.0.3"
emoji-picker-element: "npm:^1.22.1"
emoji-picker-element-data: "npm:^1"
epub: "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch"
eslint: "npm:^9.22.0"
eslint-plugin-import-zod: "npm:^1.2.0"
@@ -10900,6 +10913,17 @@ __metadata:
languageName: node
linkType: hard
"better-sqlite3@npm:12.4.1":
version: 12.4.1
resolution: "better-sqlite3@npm:12.4.1"
dependencies:
bindings: "npm:^1.5.0"
node-gyp: "npm:latest"
prebuild-install: "npm:^7.1.1"
checksum: 10c0/88773a75d996b4171e5690a38459b05dc814a792701b224bd9909ee084dc0b4c64aaffbdbcf4bbbc6d4e247faf19e91b2a56cf4175d746d3bd9ff14764eb05aa
languageName: node
linkType: hard
"bignumber.js@npm:^9.0.0":
version: 9.2.1
resolution: "bignumber.js@npm:9.2.1"
@@ -10914,6 +10938,15 @@ __metadata:
languageName: node
linkType: hard
"bindings@npm:^1.5.0":
version: 1.5.0
resolution: "bindings@npm:1.5.0"
dependencies:
file-uri-to-path: "npm:1.0.0"
checksum: 10c0/3dab2491b4bb24124252a91e656803eac24292473e56554e35bbfe3cc1875332cfa77600c3bac7564049dc95075bf6fcc63a4609920ff2d64d0fe405fcf0d4ba
languageName: node
linkType: hard
"birecord@npm:^0.1.1":
version: 0.1.1
resolution: "birecord@npm:0.1.1"
@@ -13654,6 +13687,13 @@ __metadata:
languageName: node
linkType: hard
"emoji-picker-element-data@npm:^1":
version: 1.8.0
resolution: "emoji-picker-element-data@npm:1.8.0"
checksum: 10c0/c8976b636205a0cc90d2690859a1193add71a948dadf743962b47c338a4c3715768404d0ccbc02156608b44abf41f3e1d51756e06f1bbed9d164dd4cb1752103
languageName: node
linkType: hard
"emoji-picker-element@npm:^1.22.1":
version: 1.26.3
resolution: "emoji-picker-element@npm:1.26.3"
@@ -14921,6 +14961,13 @@ __metadata:
languageName: node
linkType: hard
"file-uri-to-path@npm:1.0.0":
version: 1.0.0
resolution: "file-uri-to-path@npm:1.0.0"
checksum: 10c0/3b545e3a341d322d368e880e1c204ef55f1d45cdea65f7efc6c6ce9e0c4d22d802d5629320eb779d006fe59624ac17b0e848d83cc5af7cd101f206cb704f5519
languageName: node
linkType: hard
"filelist@npm:^1.0.4":
version: 1.0.4
resolution: "filelist@npm:1.0.4"
@@ -20651,7 +20698,7 @@ __metadata:
languageName: node
linkType: hard
"prebuild-install@npm:^7.1.2":
"prebuild-install@npm:^7.1.1, prebuild-install@npm:^7.1.2":
version: 7.1.3
resolution: "prebuild-install@npm:7.1.3"
dependencies: