Merge branch 'v2' into feat/v2-context-menu

This commit is contained in:
MyPrototypeWhat 2026-01-13 11:07:08 +08:00 committed by GitHub
commit ae65a66345
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
238 changed files with 10245 additions and 2837 deletions

View File

@ -90,3 +90,30 @@ jobs:
- name: 📢 Notify if no changes
if: steps.git_status.outputs.has_changes != 'true'
run: echo "Bot script ran, but no changes were detected. No PR created."
- name: Send failure notification to Feishu
if: always() && (failure() || cancelled())
shell: bash
env:
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
JOB_STATUS: ${{ job.status }}
run: |
# Determine status and color
if [ "$JOB_STATUS" = "cancelled" ]; then
STATUS_TEXT="已取消"
COLOR="orange"
else
STATUS_TEXT="失败"
COLOR="red"
fi
# Build description using printf
DESCRIPTION=$(printf "**状态:** %s\n\n**工作流:** [查看详情](%s)" "$STATUS_TEXT" "$RUN_URL")
# Send notification
pnpm tsx scripts/feishu-notify.ts send \
-t "自动国际化${STATUS_TEXT}" \
-d "$DESCRIPTION" \
-c "${COLOR}"

View File

@ -58,14 +58,34 @@ jobs:
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm dependencies
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install dependencies
if: steps.check_time.outputs.should_delay == 'false'
run: pnpm install
- name: Process issue with Claude
if: steps.check_time.outputs.should_delay == 'false'
uses: anthropics/claude-code-action@main
uses: anthropics/claude-code-action@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
allowed_non_write_users: "*"
anthropic_api_key: ${{ secrets.CLAUDE_TRANSLATOR_APIKEY }}
claude_args: "--allowed-tools Bash(gh issue:*),Bash(node scripts/feishu-notify.js)"
claude_args: "--allowed-tools Bash(gh issue:*),Bash(pnpm tsx scripts/feishu-notify.ts*)"
prompt: |
你是一个GitHub Issue自动化处理助手。请完成以下任务
@ -74,9 +94,14 @@ jobs:
- 标题:${{ github.event.issue.title }}
- 作者:${{ github.event.issue.user.login }}
- URL${{ github.event.issue.html_url }}
- 内容:${{ github.event.issue.body }}
- 标签:${{ join(github.event.issue.labels.*.name, ', ') }}
### Issue body
`````md
${{ github.event.issue.body }}
`````
## 任务步骤
1. **分析并总结issue**
@ -86,20 +111,20 @@ jobs:
- 重要的技术细节
2. **发送飞书通知**
使用以下命令发送飞书通知注意ISSUE_SUMMARY需要用引号包裹
使用CLI工具发送飞书通知参考以下示例
```bash
ISSUE_URL="${{ github.event.issue.html_url }}" \
ISSUE_NUMBER="${{ github.event.issue.number }}" \
ISSUE_TITLE="${{ github.event.issue.title }}" \
ISSUE_AUTHOR="${{ github.event.issue.user.login }}" \
ISSUE_LABELS="${{ join(github.event.issue.labels.*.name, ',') }}" \
ISSUE_SUMMARY="<你生成的中文总结>" \
node scripts/feishu-notify.js
pnpm tsx scripts/feishu-notify.ts issue \
-u "${{ github.event.issue.html_url }}" \
-n "${{ github.event.issue.number }}" \
-t "${{ github.event.issue.title }}" \
-a "${{ github.event.issue.user.login }}" \
-l "${{ join(github.event.issue.labels.*.name, ',') }}" \
-m "<你生成的中文总结>"
```
## 注意事项
- 总结必须使用简体中文
- ISSUE_SUMMARY 在传递给 node 命令时需要正确转义特殊字符
- 命令行参数需要正确转义特殊字符
- 如果issue内容为空也要提供一个简短的说明
请开始执行任务!
@ -125,13 +150,32 @@ jobs:
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm dependencies
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install dependencies
run: pnpm install
- name: Process pending issues with Claude
uses: anthropics/claude-code-action@main
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.CLAUDE_TRANSLATOR_APIKEY }}
allowed_non_write_users: "*"
github_token: ${{ secrets.GITHUB_TOKEN }}
claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:*),Bash(node scripts/feishu-notify.js)"
claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:*),Bash(pnpm tsx scripts/feishu-notify.ts*)"
prompt: |
你是一个GitHub Issue自动化处理助手。请完成以下任务
@ -153,15 +197,15 @@ jobs:
- 重要的技术细节
3. **发送飞书通知**
对于每个issue使用以下命令发送飞书通知
使用CLI工具发送飞书通知参考以下示例
```bash
ISSUE_URL="<issue的html_url>" \
ISSUE_NUMBER="<issue编号>" \
ISSUE_TITLE="<issue标题>" \
ISSUE_AUTHOR="<issue作者>" \
ISSUE_LABELS="<逗号分隔的标签列表排除pending-feishu-notification>" \
ISSUE_SUMMARY="<你生成的中文总结>" \
node scripts/feishu-notify.js
pnpm tsx scripts/feishu-notify.ts issue \
-u "<issue的html_url>" \
-n "<issue编号>" \
-t "<issue标题>" \
-a "<issue作者>" \
-l "<逗号分隔的标签列表排除pending-feishu-notification>" \
-m "<你生成的中文总结>"
```
4. **移除标签**

View File

@ -79,7 +79,7 @@ jobs:
shell: bash
run: |
echo "Built Windows artifacts:"
ls -la dist/*.exe dist/*.blockmap dist/latest*.yml
ls -la dist/*.exe dist/latest*.yml
- name: Download GitHub release assets
shell: bash
@ -112,12 +112,10 @@ jobs:
fi
# Remove unsigned Windows files from downloaded assets
# *.exe, *.exe.blockmap, latest.yml (Windows only)
rm -f release-assets/*.exe release-assets/*.exe.blockmap release-assets/latest.yml 2>/dev/null || true
rm -f release-assets/*.exe release-assets/latest.yml 2>/dev/null || true
# Copy signed Windows files with error checking
cp dist/*.exe release-assets/ || { echo "ERROR: Failed to copy .exe files"; exit 1; }
cp dist/*.exe.blockmap release-assets/ || { echo "ERROR: Failed to copy .blockmap files"; exit 1; }
cp dist/latest.yml release-assets/ || { echo "ERROR: Failed to copy latest.yml"; exit 1; }
echo "Final release assets:"
@ -302,3 +300,31 @@ jobs:
run: |
rm -f /tmp/release_payload.json /tmp/upload_headers.txt release_body.txt
rm -rf release-assets/
- name: Send failure notification to Feishu
if: always() && (failure() || cancelled())
shell: bash
env:
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }}
TAG_NAME: ${{ steps.get-tag.outputs.tag }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
JOB_STATUS: ${{ job.status }}
run: |
# Determine status and color
if [ "$JOB_STATUS" = "cancelled" ]; then
STATUS_TEXT="已取消"
COLOR="orange"
else
STATUS_TEXT="失败"
COLOR="red"
fi
# Build description using printf
DESCRIPTION=$(printf "**标签:** %s\n\n**状态:** %s\n\n**工作流:** [查看详情](%s)" "$TAG_NAME" "$STATUS_TEXT" "$RUN_URL")
# Send notification
pnpm tsx scripts/feishu-notify.ts send \
-t "GitCode 同步${STATUS_TEXT}" \
-d "$DESCRIPTION" \
-c "${COLOR}"

View File

@ -154,9 +154,10 @@ jobs:
with:
node-version: 22
- name: Install pnpm
- name: Enable corepack
if: steps.check.outputs.should_run == 'true'
uses: pnpm/action-setup@v4
working-directory: main
run: corepack enable pnpm
- name: Install dependencies
if: steps.check.outputs.should_run == 'true'

View File

@ -52,6 +52,7 @@
"!src/main/integration/**",
"!**/tailwind.css",
"!**/package.json",
"!src/renderer/src/routeTree.gen.ts",
"!.zed/**"
],
"indentStyle": "space",
@ -82,7 +83,7 @@
},
"linter": {
"enabled": true,
"includes": ["!**/tailwind.css", "src/renderer/**/*.{tsx,ts}"],
"includes": ["!**/tailwind.css", "!src/renderer/src/routeTree.gen.ts", "src/renderer/**/*.{tsx,ts}"],
// only enable sorted tailwind css rule. used as formatter instead of linter
"rules": {
"nursery": {

View File

@ -9,16 +9,18 @@ This guide covers how to use the Cache system in React components and services.
Memory cache is lost on app restart. Best for temporary computed results.
```typescript
import { useCache } from '@data/hooks/useCache'
import { useCache } from "@data/hooks/useCache";
// Basic usage with default value
const [counter, setCounter] = useCache('ui.counter', 0)
const [counter, setCounter] = useCache("ui.counter", 0);
// Update the value
setCounter(counter + 1)
setCounter(counter + 1);
// With TTL (30 seconds)
const [searchResults, setSearchResults] = useCache('search.results', [], { ttl: 30000 })
const [searchResults, setSearchResults] = useCache("search.results", [], {
ttl: 30000,
});
```
### useSharedCache (Cross-Window Cache)
@ -26,13 +28,16 @@ const [searchResults, setSearchResults] = useCache('search.results', [], { ttl:
Shared cache syncs across all windows, lost on app restart.
```typescript
import { useSharedCache } from '@data/hooks/useCache'
import { useSharedCache } from "@data/hooks/useCache";
// Cross-window state
const [layout, setLayout] = useSharedCache('window.layout', defaultLayout)
const [layout, setLayout] = useSharedCache("window.layout", defaultLayout);
// Sidebar state shared between windows
const [sidebarCollapsed, setSidebarCollapsed] = useSharedCache('ui.sidebar.collapsed', false)
const [sidebarCollapsed, setSidebarCollapsed] = useSharedCache(
"ui.sidebar.collapsed",
false
);
```
### usePersistCache (Persistent Cache)
@ -40,13 +45,13 @@ const [sidebarCollapsed, setSidebarCollapsed] = useSharedCache('ui.sidebar.colla
Persist cache survives app restarts via localStorage.
```typescript
import { usePersistCache } from '@data/hooks/useCache'
import { usePersistCache } from "@data/hooks/useCache";
// Recent files list (survives restart)
const [recentFiles, setRecentFiles] = usePersistCache('app.recent_files', [])
const [recentFiles, setRecentFiles] = usePersistCache("app.recent_files", []);
// Search history
const [searchHistory, setSearchHistory] = usePersistCache('search.history', [])
const [searchHistory, setSearchHistory] = usePersistCache("search.history", []);
```
## CacheService Direct Usage
@ -56,54 +61,54 @@ For non-React code or more control, use CacheService directly.
### Memory Cache
```typescript
import { cacheService } from '@data/CacheService'
import { cacheService } from "@data/CacheService";
// Type-safe (schema key)
cacheService.set('temp.calculation', result)
const result = cacheService.get('temp.calculation')
cacheService.set("temp.calculation", result);
const result = cacheService.get("temp.calculation");
// With TTL (30 seconds)
cacheService.set('temp.calculation', result, 30000)
cacheService.set("temp.calculation", result, 30000);
// Casual (dynamic key, manual type)
cacheService.setCasual<TopicCache>(`topic:${id}`, topicData)
const topic = cacheService.getCasual<TopicCache>(`topic:${id}`)
cacheService.setCasual<TopicCache>(`topic:${id}`, topicData);
const topic = cacheService.getCasual<TopicCache>(`topic:${id}`);
// Check existence
if (cacheService.has('temp.calculation')) {
if (cacheService.has("temp.calculation")) {
// ...
}
// Delete
cacheService.delete('temp.calculation')
cacheService.deleteCasual(`topic:${id}`)
cacheService.delete("temp.calculation");
cacheService.deleteCasual(`topic:${id}`);
```
### Shared Cache
```typescript
// Type-safe (schema key)
cacheService.setShared('window.layout', layoutConfig)
const layout = cacheService.getShared('window.layout')
cacheService.setShared("window.layout", layoutConfig);
const layout = cacheService.getShared("window.layout");
// Casual (dynamic key)
cacheService.setSharedCasual<WindowState>(`window:${windowId}`, state)
const state = cacheService.getSharedCasual<WindowState>(`window:${windowId}`)
cacheService.setSharedCasual<WindowState>(`window:${windowId}`, state);
const state = cacheService.getSharedCasual<WindowState>(`window:${windowId}`);
// Delete
cacheService.deleteShared('window.layout')
cacheService.deleteSharedCasual(`window:${windowId}`)
cacheService.deleteShared("window.layout");
cacheService.deleteSharedCasual(`window:${windowId}`);
```
### Persist Cache
```typescript
// Schema keys only (no Casual methods for persist)
cacheService.setPersist('app.recent_files', recentFiles)
const files = cacheService.getPersist('app.recent_files')
cacheService.setPersist("app.recent_files", recentFiles);
const files = cacheService.getPersist("app.recent_files");
// Delete
cacheService.deletePersist('app.recent_files')
cacheService.deletePersist("app.recent_files");
```
## Main Process Usage
@ -113,22 +118,22 @@ Main process CacheService provides SharedCache for cross-window state management
### SharedCache in Main Process
```typescript
import { cacheService } from '@main/data/CacheService'
import { cacheService } from "@main/data/CacheService";
// Type-safe (schema key) - matches Renderer's type system
cacheService.setShared('window.layout', layoutConfig)
const layout = cacheService.getShared('window.layout')
cacheService.setShared("window.layout", layoutConfig);
const layout = cacheService.getShared("window.layout");
// With TTL (30 seconds)
cacheService.setShared('temp.state', state, 30000)
cacheService.setShared("temp.state", state, 30000);
// Check existence
if (cacheService.hasShared('window.layout')) {
if (cacheService.hasShared("window.layout")) {
// ...
}
// Delete
cacheService.deleteShared('window.layout')
cacheService.deleteShared("window.layout");
```
**Note**: Main CacheService does NOT support Casual methods (`getSharedCasual`, etc.). Only schema-based type-safe access is available in Main process.
@ -142,16 +147,18 @@ cacheService.deleteShared('window.layout')
## Type-Safe vs Casual Methods
### Type-Safe Methods
- Use predefined keys from cache schema
- Full auto-completion and type inference
- Compile-time key validation
```typescript
// Key 'ui.counter' must exist in schema
const [counter, setCounter] = useCache('ui.counter', 0)
const [counter, setCounter] = useCache("ui.counter", 0);
```
### Casual Methods
- Use dynamically constructed keys
- Require manual type specification via generics
- No compile-time key validation
@ -159,11 +166,11 @@ const [counter, setCounter] = useCache('ui.counter', 0)
```typescript
// Dynamic key, must specify type
const topic = cacheService.getCasual<TopicCache>(`my.custom.key`)
const topic = cacheService.getCasual<TopicCache>(`my.custom.key`);
// Compile error: cannot use schema keys with Casual methods
cacheService.getCasual('app.user.avatar') // Error: matches fixed key
cacheService.getCasual('scroll.position.topic123') // Error: matches template key
cacheService.getCasual("app.user.avatar"); // Error: matches fixed key
cacheService.getCasual("scroll.position.topic123"); // Error: matches template key
```
### Template Keys
@ -178,56 +185,56 @@ Template keys provide type-safe caching for dynamic key patterns. Define a templ
// packages/shared/data/cache/cacheSchemas.ts
export type UseCacheSchema = {
// Fixed key
'app.user.avatar': string
"app.user.avatar": string;
// Template keys - use ${variable} for dynamic segments
// Must follow dot-separated pattern like fixed keys
'scroll.position.${topicId}': number
'entity.cache.${type}_${id}': EntityData
}
"scroll.position.${topicId}": number;
"entity.cache.${type}_${id}": EntityData;
};
// Default values for templates (shared by all instances)
export const DefaultUseCache: UseCacheSchema = {
'app.user.avatar': '',
'scroll.position.${topicId}': 0,
'entity.cache.${type}_${id}': { loaded: false }
}
"app.user.avatar": "",
"scroll.position.${topicId}": 0,
"entity.cache.${type}_${id}": { loaded: false },
};
```
#### Using Template Keys
```typescript
// TypeScript infers the value type from schema
const [scrollPos, setScrollPos] = useCache('scroll.position.topic123')
const [scrollPos, setScrollPos] = useCache("scroll.position.topic123");
// scrollPos is inferred as `number`
const [entity, setEntity] = useCache('entity.cache.user_456')
const [entity, setEntity] = useCache("entity.cache.user_456");
// entity is inferred as `EntityData`
// Direct CacheService usage
cacheService.set('scroll.position.mytopic', 150) // OK: value must be number
cacheService.set('scroll.position.mytopic', 'hi') // Error: type mismatch
cacheService.set("scroll.position.mytopic", 150); // OK: value must be number
cacheService.set("scroll.position.mytopic", "hi"); // Error: type mismatch
```
#### Template Key Benefits
| Feature | Fixed Keys | Template Keys | Casual Methods |
|---------|-----------|---------------|----------------|
| Type inference | ✅ Automatic | ✅ Automatic | ❌ Manual |
| Auto-completion | ✅ Full | ✅ Partial (prefix) | ❌ None |
| Compile-time validation | ✅ Yes | ✅ Yes | ❌ No |
| Dynamic IDs | ❌ No | ✅ Yes | ✅ Yes |
| Default values | ✅ Yes | ✅ Shared per template | ❌ No |
| Feature | Fixed Keys | Template Keys | Casual Methods |
| ----------------------- | ------------ | ---------------------- | -------------- |
| Type inference | ✅ Automatic | ✅ Automatic | ❌ Manual |
| Auto-completion | ✅ Full | ✅ Partial (prefix) | ❌ None |
| Compile-time validation | ✅ Yes | ✅ Yes | ❌ No |
| Dynamic IDs | ❌ No | ✅ Yes | ✅ Yes |
| Default values | ✅ Yes | ✅ Shared per template | ❌ No |
### When to Use Which
| Scenario | Method | Example |
|----------|--------|---------|
| Fixed cache keys | Type-safe | `useCache('ui.counter')` |
| Dynamic keys with known pattern | Template key | `useCache('scroll.position.topic123')` |
| Entity caching by ID | Template key | `get('entity.cache.user_456')` |
| Completely dynamic keys | Casual | `getCasual<T>(\`custom.dynamic.${x}\`)` |
| UI state | Type-safe | `useSharedCache('window.layout')` |
| Scenario | Method | Example |
| ------------------------------- | ------------ | --------------------------------------- |
| Fixed cache keys | Type-safe | `useCache('ui.counter')` |
| Dynamic keys with known pattern | Template key | `useCache('scroll.position.topic123')` |
| Entity caching by ID | Template key | `get('entity.cache.user_456')` |
| Completely dynamic keys | Casual | `getCasual<T>(\`custom.dynamic.${x}\`)` |
| UI state | Type-safe | `useSharedCache('window.layout')` |
## Common Patterns
@ -235,16 +242,16 @@ cacheService.set('scroll.position.mytopic', 'hi') // Error: type mismatch
```typescript
function useExpensiveData(input: string) {
const [cached, setCached] = useCache(`computed:${input}`, null)
const [cached, setCached] = useCache(`computed:${input}`, null);
useEffect(() => {
if (cached === null) {
const result = expensiveComputation(input)
setCached(result)
const result = expensiveComputation(input);
setCached(result);
}
}, [input, cached, setCached])
}, [input, cached, setCached]);
return cached
return cached;
}
```
@ -252,51 +259,55 @@ function useExpensiveData(input: string) {
```typescript
// Window A: Update shared state
const [activeFile, setActiveFile] = useSharedCache('editor.activeFile', null)
setActiveFile(selectedFile)
const [activeFile, setActiveFile] = useSharedCache("editor.activeFile", null);
setActiveFile(selectedFile);
// Window B: Reacts to change automatically
const [activeFile] = useSharedCache('editor.activeFile', null)
const [activeFile] = useSharedCache("editor.activeFile", null);
// activeFile updates when Window A changes it
```
### Recent Items with Limit
```typescript
const [recentItems, setRecentItems] = usePersistCache('app.recentItems', [])
const [recentItems, setRecentItems] = usePersistCache("app.recentItems", []);
const addRecentItem = (item: Item) => {
setRecentItems(prev => {
const filtered = prev.filter(i => i.id !== item.id)
return [item, ...filtered].slice(0, 10) // Keep last 10
})
}
setRecentItems((prev) => {
const filtered = prev.filter((i) => i.id !== item.id);
return [item, ...filtered].slice(0, 10); // Keep last 10
});
};
```
### Cache with Expiration Check
```typescript
interface CachedData<T> {
data: T
timestamp: number
data: T;
timestamp: number;
}
function useCachedWithExpiry<T>(key: string, fetcher: () => Promise<T>, maxAge: number) {
const [cached, setCached] = useCache<CachedData<T> | null>(key, null)
const [data, setData] = useState<T | null>(cached?.data ?? null)
function useCachedWithExpiry<T>(
key: string,
fetcher: () => Promise<T>,
maxAge: number
) {
const [cached, setCached] = useCache<CachedData<T> | null>(key, null);
const [data, setData] = useState<T | null>(cached?.data ?? null);
useEffect(() => {
const isExpired = !cached || Date.now() - cached.timestamp > maxAge
const isExpired = !cached || Date.now() - cached.timestamp > maxAge;
if (isExpired) {
fetcher().then(result => {
setCached({ data: result, timestamp: Date.now() })
setData(result)
})
fetcher().then((result) => {
setCached({ data: result, timestamp: Date.now() });
setData(result);
});
}
}, [key, maxAge])
}, [key, maxAge]);
return data
return data;
}
```
@ -310,13 +321,13 @@ function useCachedWithExpiry<T>(key: string, fetcher: () => Promise<T>, maxAge:
// packages/shared/data/cache/cacheSchemas.ts
export type UseCacheSchema = {
// Existing keys...
'myFeature.data': MyDataType
}
"myFeature.data": MyDataType;
};
export const DefaultUseCache: UseCacheSchema = {
// Existing defaults...
'myFeature.data': { items: [], lastUpdated: 0 }
}
"myFeature.data": { items: [], lastUpdated: 0 },
};
```
#### 2. Define Value Type (if complex)
@ -324,8 +335,8 @@ export const DefaultUseCache: UseCacheSchema = {
```typescript
// packages/shared/data/cache/cacheValueTypes.ts
export interface MyDataType {
items: string[]
lastUpdated: number
items: string[];
lastUpdated: number;
}
```
@ -333,7 +344,7 @@ export interface MyDataType {
```typescript
// Now type-safe
const [data, setData] = useCache('myFeature.data')
const [data, setData] = useCache("myFeature.data");
```
### Adding Template Keys
@ -345,25 +356,25 @@ const [data, setData] = useCache('myFeature.data')
export type UseCacheSchema = {
// Existing keys...
// Template key with dynamic segment
'scroll.position.${topicId}': number
}
"scroll.position.${topicId}": number;
};
export const DefaultUseCache: UseCacheSchema = {
// Existing defaults...
// Default shared by all instances of this template
'scroll.position.${topicId}': 0
}
"scroll.position.${topicId}": 0,
};
```
#### 2. Use in Code
```typescript
// TypeScript infers number from template pattern
const [scrollPos, setScrollPos] = useCache(`scroll.position.${topicId}`)
const [scrollPos, setScrollPos] = useCache(`scroll.position.${topicId}`);
// Works with any string in the dynamic segment
const [pos1, setPos1] = useCache('scroll.position.topic123')
const [pos2, setPos2] = useCache('scroll.position.conversationabc')
const [pos1, setPos1] = useCache("scroll.position.topic123");
const [pos2, setPos2] = useCache("scroll.position.conversationabc");
```
### Key Naming Convention
@ -389,7 +400,7 @@ All keys (fixed and template) must follow the same naming convention:
Renderer CacheService provides ready state tracking for SharedCache initialization sync.
```typescript
import { cacheService } from '@data/CacheService'
import { cacheService } from "@data/CacheService";
// Check if shared cache is ready
if (cacheService.isSharedCacheReady()) {
@ -399,19 +410,34 @@ if (cacheService.isSharedCacheReady()) {
// Register callback when ready
const unsubscribe = cacheService.onSharedCacheReady(() => {
// Called immediately if already ready, or when sync completes
console.log('SharedCache ready!')
})
console.log("SharedCache ready!");
});
// Cleanup
unsubscribe()
unsubscribe();
```
**Behavior notes**:
- `getShared()` returns `undefined` before ready (expected behavior)
- `setShared()` works immediately and broadcasts to Main (Main updates its cache)
- Hooks like `useSharedCache` work normally - they set initial values and update when sync completes
- Main-priority override: when sync completes, Main's values override local values
## Cache Statistics
For debugging purposes, CacheService provides a `getStats()` method to inspect cache state:
```typescript
// Get summary statistics
const stats = cacheService.getStats();
// Get detailed per-entry information
const fullStats = cacheService.getStats(true);
```
Returns statistics including entry counts, TTL status, hook references, and estimated memory usage for all cache tiers (memory, shared, persist).
## Best Practices
1. **Choose the right tier**: Memory for temp, Shared for cross-window, Persist for survival

View File

@ -1,5 +1,35 @@
# Database Schema Guidelines
## Schema File Organization
### Principles
| Scenario | Approach |
| -------------------------------------- | ------------------- |
| Strongly related tables in same domain | Merge into one file |
| Core tables / Complex business logic | One file per table |
| Tables that may cross multiple domains | One file per table |
### Decision Criteria
**Merge when:**
- Tables have strong foreign key relationships (e.g., many-to-many)
- Tables belong to the same business domain
- Tables are unlikely to evolve independently
**Separate (one file per table) when:**
- Core table with many fields and complex logic
- Has a dedicated Service layer counterpart
- May expand independently in the future
### File Naming
- **Single-table files**: named after the table export name (`message.ts` for `messageTable`, `topic.ts` for `topicTable`)
- **Multi-table files**: lowercase, named by domain (`tagging.ts` for `tagTable` + `entityTagTable`)
- **Helper utilities**: underscore prefix (`_columnHelpers.ts`) to indicate non-table definitions
## Naming Conventions
- **Table names**: Use **singular** form with snake_case (e.g., `topic`, `message`, `app_state`)
@ -8,19 +38,19 @@
## Column Helpers
All helpers are exported from `./schemas/columnHelpers.ts`.
All helpers are exported from `./schemas/_columnHelpers.ts`.
### Primary Keys
| Helper | UUID Version | Use Case |
|--------|--------------|----------|
| `uuidPrimaryKey()` | v4 (random) | General purpose tables |
| Helper | UUID Version | Use Case |
| ------------------------- | ----------------- | ------------------------------------ |
| `uuidPrimaryKey()` | v4 (random) | General purpose tables |
| `uuidPrimaryKeyOrdered()` | v7 (time-ordered) | Large tables with time-based queries |
**Usage:**
```typescript
import { uuidPrimaryKey, uuidPrimaryKeyOrdered } from './columnHelpers'
import { uuidPrimaryKey, uuidPrimaryKeyOrdered } from './_columnHelpers'
// General purpose table
export const topicTable = sqliteTable('topic', {
@ -45,29 +75,32 @@ export const messageTable = sqliteTable('message', {
### Timestamps
| Helper | Fields | Use Case |
|--------|--------|----------|
| `createUpdateTimestamps` | `createdAt`, `updatedAt` | Tables without soft delete |
| `createUpdateDeleteTimestamps` | `createdAt`, `updatedAt`, `deletedAt` | Tables with soft delete |
| Helper | Fields | Use Case |
| ------------------------------ | ------------------------------------- | -------------------------- |
| `createUpdateTimestamps` | `createdAt`, `updatedAt` | Tables without soft delete |
| `createUpdateDeleteTimestamps` | `createdAt`, `updatedAt`, `deletedAt` | Tables with soft delete |
**Usage:**
```typescript
import { createUpdateTimestamps, createUpdateDeleteTimestamps } from './columnHelpers'
import {
createUpdateTimestamps,
createUpdateDeleteTimestamps,
} from "./_columnHelpers";
// Without soft delete
export const tagTable = sqliteTable('tag', {
export const tagTable = sqliteTable("tag", {
id: uuidPrimaryKey(),
name: text(),
...createUpdateTimestamps
})
...createUpdateTimestamps,
});
// With soft delete
export const topicTable = sqliteTable('topic', {
export const topicTable = sqliteTable("topic", {
id: uuidPrimaryKey(),
name: text(),
...createUpdateDeleteTimestamps
})
...createUpdateDeleteTimestamps,
});
```
**Behavior:**
@ -81,7 +114,7 @@ export const topicTable = sqliteTable('topic', {
For JSON column support, use `{ mode: 'json' }`:
```typescript
data: text({ mode: 'json' }).$type<MyDataType>()
data: text({ mode: "json" }).$type<MyDataType>();
```
Drizzle handles JSON serialization/deserialization automatically.
@ -92,10 +125,10 @@ Drizzle handles JSON serialization/deserialization automatically.
```typescript
// SET NULL: preserve record when referenced record is deleted
groupId: text().references(() => groupTable.id, { onDelete: 'set null' })
groupId: text().references(() => groupTable.id, { onDelete: "set null" });
// CASCADE: delete record when referenced record is deleted
topicId: text().references(() => topicTable.id, { onDelete: 'cascade' })
topicId: text().references(() => topicTable.id, { onDelete: "cascade" });
```
### Self-Referencing Foreign Keys
@ -103,23 +136,26 @@ topicId: text().references(() => topicTable.id, { onDelete: 'cascade' })
For self-referencing foreign keys (e.g., tree structures with parentId), **always use the `foreignKey` operator** in the table's third parameter:
```typescript
import { foreignKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { foreignKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const messageTable = sqliteTable(
'message',
"message",
{
id: uuidPrimaryKeyOrdered(),
parentId: text(), // Do NOT use .references() here
parentId: text(), // Do NOT use .references() here
// ...other fields
},
(t) => [
// Use foreignKey operator for self-referencing
foreignKey({ columns: [t.parentId], foreignColumns: [t.id] }).onDelete('set null')
foreignKey({ columns: [t.parentId], foreignColumns: [t.id] }).onDelete(
"set null"
),
]
)
);
```
**Why this approach:**
- Avoids TypeScript circular reference issues (no need for `AnySQLiteColumn` type annotation)
- More explicit and readable
- Allows chaining `.onDelete()` / `.onUpdate()` actions
@ -142,21 +178,22 @@ If you encounter a scenario that seems to require circular references:
```typescript
// ✅ GOOD: Break the cycle by handling one side at application layer
export const topicTable = sqliteTable('topic', {
export const topicTable = sqliteTable("topic", {
id: uuidPrimaryKey(),
// Application-managed reference (no FK constraint)
// Validated by TopicService.setCurrentMessage()
currentMessageId: text(),
})
});
export const messageTable = sqliteTable('message', {
export const messageTable = sqliteTable("message", {
id: uuidPrimaryKeyOrdered(),
// Database-enforced FK
topicId: text().references(() => topicTable.id, { onDelete: 'cascade' }),
})
topicId: text().references(() => topicTable.id, { onDelete: "cascade" }),
});
```
**Why soft references for SQLite:**
- SQLite does not support `DEFERRABLE` constraints (unlike PostgreSQL/Oracle)
- Application-layer validation provides equivalent data integrity
- Simplifies insert/update operations without transaction ordering concerns
@ -185,12 +222,12 @@ Always use `.returning()` to get inserted/updated data instead of re-querying:
```typescript
// Good: Use returning()
const [row] = await db.insert(table).values(data).returning()
return rowToEntity(row)
const [row] = await db.insert(table).values(data).returning();
return rowToEntity(row);
// Avoid: Re-query after insert (unnecessary database round-trip)
await db.insert(table).values({ id, ...data })
return this.getById(id)
await db.insert(table).values({ id, ...data });
return this.getById(id);
```
### Soft delete support

View File

@ -5,6 +5,7 @@ The Preference system provides centralized management for user configuration and
## Purpose
PreferenceService handles data that:
- Is a **user-modifiable setting that affects app behavior**
- Has a **fixed key structure** with stable value types
- Needs to **persist permanently** until explicitly changed
@ -13,16 +14,19 @@ PreferenceService handles data that:
## Key Characteristics
### Fixed Key Structure
- Predefined keys in the schema (users modify values, not keys)
- Supports 158 configuration items
- Nested key paths supported (e.g., `app.theme.mode`)
### Atomic Values
- Each preference item represents one logical setting
- Values are typically: boolean, string, number, or simple array/object
- Changes are independent (updating one doesn't affect others)
### Cross-Window Synchronization
- Changes automatically broadcast to all windows
- Consistent state across main window, mini window, etc.
- Conflict resolution handled by Main process
@ -30,19 +34,23 @@ PreferenceService handles data that:
## Update Strategies
### Optimistic Updates (Default)
```typescript
// UI updates immediately, then syncs to database
await preferenceService.set('app.theme.mode', 'dark')
await preferenceService.set("app.theme.mode", "dark");
```
- Best for: frequent, non-critical settings
- Behavior: Local state updates first, then persists
- Rollback: Automatic revert if persistence fails
### Pessimistic Updates
```typescript
// Waits for database confirmation before updating UI
await preferenceService.set('api.key', 'secret', { optimistic: false })
await preferenceService.set("api.key", "secret", { optimistic: false });
```
- Best for: critical settings (API keys, security options)
- Behavior: Persists first, then updates local state
- No rollback needed: UI only updates on success
@ -86,6 +94,7 @@ await preferenceService.set('api.key', 'secret', { optimistic: false })
## Main vs Renderer Responsibilities
### Main Process PreferenceService
- **Source of truth** for all preferences
- Full memory cache for fast access
- SQLite persistence via preference table
@ -93,12 +102,25 @@ await preferenceService.set('api.key', 'secret', { optimistic: false })
- Handles batch operations and transactions
### Renderer Process PreferenceService
- Local cache for read performance
- Proxies write operations to Main
- Manages React hook subscriptions
- Handles optimistic update rollbacks
- Listens for cross-window updates
### Statistics (Debug)
Main process provides `getStats(details?)` for debugging subscription status:
- Returns total keys, main process subscriptions, and window subscriptions
- Pass `details=true` for per-key breakdown
- **Warning**: Resource-intensive, recommended for development only
```typescript
const stats = preferenceService.getStats(true);
```
## Database Schema
Preferences are stored in the `preference` table:
@ -106,29 +128,33 @@ Preferences are stored in the `preference` table:
```typescript
// Simplified schema
{
scope: string // e.g., 'default', 'user'
key: string // e.g., 'app.theme.mode'
value: json // The preference value
createdAt: number
updatedAt: number
scope: string; // e.g., 'default', 'user'
key: string; // e.g., 'app.theme.mode'
value: json; // The preference value
createdAt: number;
updatedAt: number;
}
```
## Preference Categories
### Application Settings
- Theme mode, language, font sizes
- Window behavior, startup options
### Feature Toggles
- Show/hide UI elements
- Enable/disable features
### User Customization
- Keyboard shortcuts
- Default values for operations
### Provider Configuration
- AI provider settings
- API endpoints and tokens
@ -136,9 +162,9 @@ Preferences are stored in the `preference` table:
For detailed code examples and API usage, see [Preference Usage Guide](./preference-usage.md).
| Operation | Hook | Service Method |
|-----------|------|----------------|
| Read single | `usePreference(key)` | `preferenceService.get(key)` |
| Write single | `setPreference(value)` | `preferenceService.set(key, value)` |
| Read multiple | `usePreferences([...keys])` | `preferenceService.getMultiple([...keys])` |
| Write multiple | - | `preferenceService.setMultiple({...})` |
| Operation | Hook | Service Method |
| -------------- | --------------------------- | ------------------------------------------ |
| Read single | `usePreference(key)` | `preferenceService.get(key)` |
| Write single | `setPreference(value)` | `preferenceService.set(key, value)` |
| Read multiple | `usePreferences([...keys])` | `preferenceService.getMultiple([...keys])` |
| Write multiple | - | `preferenceService.setMultiple({...})` |

View File

@ -0,0 +1,155 @@
# Feishu Notification Script
`scripts/feishu-notify.ts` is a CLI tool for sending notifications to Feishu (Lark) Webhook. This script is primarily used in GitHub Actions workflows to enable automatic notifications.
## Features
- Subcommand-based CLI structure for different notification types
- HMAC-SHA256 signature verification
- Sends Feishu interactive card messages
- Full TypeScript type support
- Credentials via environment variables for security
## Usage
### Prerequisites
```bash
pnpm install
```
### CLI Structure
```bash
pnpm tsx scripts/feishu-notify.ts [command] [options]
```
### Environment Variables (Required)
| Variable | Description |
|----------|-------------|
| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL |
| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret |
## Commands
### `send` - Send Simple Notification
Send a generic notification without business-specific logic.
```bash
pnpm tsx scripts/feishu-notify.ts send [options]
```
| Option | Short | Description | Required |
|--------|-------|-------------|----------|
| `--title` | `-t` | Card title | Yes |
| `--description` | `-d` | Card description (supports markdown) | Yes |
| `--color` | `-c` | Header color template | No (default: turquoise) |
**Available colors:** `blue`, `wathet`, `turquoise`, `green`, `yellow`, `orange`, `red`, `carmine`, `violet`, `purple`, `indigo`, `grey`, `default`
#### Example
```bash
# Use $'...' syntax for proper newlines
pnpm tsx scripts/feishu-notify.ts send \
-t "Deployment Completed" \
-d $'**Status:** Success\n\n**Environment:** Production\n\n**Version:** v1.2.3' \
-c green
```
```bash
# Send an error alert (red color)
pnpm tsx scripts/feishu-notify.ts send \
-t "Error Alert" \
-d $'**Error Type:** Connection failed\n\n**Severity:** High\n\nPlease check the system status' \
-c red
```
**Note:** For proper newlines in the description, use bash's `$'...'` syntax. Do not use literal `\n` in double quotes, as it will be displayed as-is in the Feishu card.
### `issue` - Send GitHub Issue Notification
```bash
pnpm tsx scripts/feishu-notify.ts issue [options]
```
| Option | Short | Description | Required |
|--------|-------|-------------|----------|
| `--url` | `-u` | GitHub issue URL | Yes |
| `--number` | `-n` | Issue number | Yes |
| `--title` | `-t` | Issue title | Yes |
| `--summary` | `-m` | Issue summary | Yes |
| `--author` | `-a` | Issue author | No (default: "Unknown") |
| `--labels` | `-l` | Issue labels (comma-separated) | No |
#### Example
```bash
pnpm tsx scripts/feishu-notify.ts issue \
-u "https://github.com/owner/repo/issues/123" \
-n "123" \
-t "Bug: Something is broken" \
-m "This is a bug report about a feature" \
-a "username" \
-l "bug,high-priority"
```
## Usage in GitHub Actions
This script is primarily used in `.github/workflows/github-issue-tracker.yml`:
```yaml
- name: Install dependencies
run: pnpm install
- name: Send notification
run: |
pnpm tsx scripts/feishu-notify.ts issue \
-u "${{ github.event.issue.html_url }}" \
-n "${{ github.event.issue.number }}" \
-t "${{ github.event.issue.title }}" \
-a "${{ github.event.issue.user.login }}" \
-l "${{ join(github.event.issue.labels.*.name, ',') }}" \
-m "Issue summary content"
env:
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }}
```
## Feishu Card Message Format
The `issue` command sends an interactive card containing:
- **Header**: `#<issue_number> - <issue_title>`
- **Author**: Issue creator
- **Labels**: Issue labels (if any)
- **Summary**: Issue content summary
- **Action Button**: "View Issue" button linking to the GitHub Issue page
## Configuring Feishu Webhook
1. Add a custom bot to your Feishu group
2. Obtain the Webhook URL and signing secret
3. Configure them in GitHub Secrets:
- `FEISHU_WEBHOOK_URL`: Webhook address
- `FEISHU_WEBHOOK_SECRET`: Signing secret
## Error Handling
The script exits with a non-zero code when:
- Required environment variables are missing (`FEISHU_WEBHOOK_URL`, `FEISHU_WEBHOOK_SECRET`)
- Required command options are missing
- Feishu API returns a non-2xx status code
- Network request fails
## Extending with New Commands
The CLI is designed to support multiple notification types. To add a new command:
1. Define the command options interface
2. Create a card builder function
3. Add a new command handler
4. Register the command with `program.command()`

View File

@ -0,0 +1,155 @@
# 飞书通知脚本
`scripts/feishu-notify.ts` 是一个 CLI 工具,用于向飞书 Webhook 发送通知。该脚本主要在 GitHub Actions 工作流中使用,实现自动通知功能。
## 功能特性
- 基于子命令的 CLI 结构,支持不同类型的通知
- 使用 HMAC-SHA256 签名验证
- 发送飞书交互式卡片消息
- 完整的 TypeScript 类型支持
- 通过环境变量传递凭证,确保安全性
## 使用方式
### 前置依赖
```bash
pnpm install
```
### CLI 结构
```bash
pnpm tsx scripts/feishu-notify.ts [command] [options]
```
### 环境变量(必需)
| 变量 | 说明 |
|------|------|
| `FEISHU_WEBHOOK_URL` | 飞书 Webhook URL |
| `FEISHU_WEBHOOK_SECRET` | 飞书 Webhook 签名密钥 |
## 命令
### `send` - 发送简单通知
发送通用通知,不涉及具体业务逻辑。
```bash
pnpm tsx scripts/feishu-notify.ts send [options]
```
| 参数 | 短选项 | 说明 | 必需 |
|------|--------|------|------|
| `--title` | `-t` | 卡片标题 | 是 |
| `--description` | `-d` | 卡片描述(支持 markdown | 是 |
| `--color` | `-c` | 标题栏颜色模板 | 否默认turquoise |
**可用颜色:** `blue`(蓝色), `wathet`(浅蓝), `turquoise`(青绿), `green`(绿色), `yellow`(黄色), `orange`(橙色), `red`(红色), `carmine`(深红), `violet`(紫罗兰), `purple`(紫色), `indigo`(靛蓝), `grey`(灰色), `default`(默认)
#### 示例
```bash
# 使用 $'...' 语法实现正确的换行
pnpm tsx scripts/feishu-notify.ts send \
-t "部署完成" \
-d $'**状态:** 成功\n\n**环境:** 生产环境\n\n**版本:** v1.2.3' \
-c green
```
```bash
# 发送错误警报(红色)
pnpm tsx scripts/feishu-notify.ts send \
-t "错误警报" \
-d $'**错误类型:** 连接失败\n\n**严重程度:** 高\n\n请及时检查系统状态' \
-c red
```
**注意:** 如需在描述中换行,请使用 bash 的 `$'...'` 语法。不要在双引号中使用字面量 `\n`,否则会原样显示在飞书卡片中。
### `issue` - 发送 GitHub Issue 通知
```bash
pnpm tsx scripts/feishu-notify.ts issue [options]
```
| 参数 | 短选项 | 说明 | 必需 |
|------|--------|------|------|
| `--url` | `-u` | GitHub Issue URL | 是 |
| `--number` | `-n` | Issue 编号 | 是 |
| `--title` | `-t` | Issue 标题 | 是 |
| `--summary` | `-m` | Issue 摘要 | 是 |
| `--author` | `-a` | Issue 作者 | 否(默认:"Unknown" |
| `--labels` | `-l` | Issue 标签(逗号分隔) | 否 |
#### 示例
```bash
pnpm tsx scripts/feishu-notify.ts issue \
-u "https://github.com/owner/repo/issues/123" \
-n "123" \
-t "Bug: Something is broken" \
-m "这是一个关于某功能的 bug 报告" \
-a "username" \
-l "bug,high-priority"
```
## 在 GitHub Actions 中使用
该脚本主要在 `.github/workflows/github-issue-tracker.yml` 工作流中使用:
```yaml
- name: Install dependencies
run: pnpm install
- name: Send notification
run: |
pnpm tsx scripts/feishu-notify.ts issue \
-u "${{ github.event.issue.html_url }}" \
-n "${{ github.event.issue.number }}" \
-t "${{ github.event.issue.title }}" \
-a "${{ github.event.issue.user.login }}" \
-l "${{ join(github.event.issue.labels.*.name, ',') }}" \
-m "Issue 摘要内容"
env:
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }}
```
## 飞书卡片消息格式
`issue` 命令发送的交互式卡片包含以下内容:
- **标题**: `#<issue编号> - <issue标题>`
- **作者**: Issue 创建者
- **标签**: Issue 标签列表(如有)
- **摘要**: Issue 内容摘要
- **操作按钮**: "View Issue" 按钮,点击跳转到 GitHub Issue 页面
## 配置飞书 Webhook
1. 在飞书群组中添加自定义机器人
2. 获取 Webhook URL 和签名密钥
3. 将 URL 和密钥配置到 GitHub Secrets
- `FEISHU_WEBHOOK_URL`: Webhook 地址
- `FEISHU_WEBHOOK_SECRET`: 签名密钥
## 错误处理
脚本在以下情况会返回非零退出码:
- 缺少必需的环境变量(`FEISHU_WEBHOOK_URL`、`FEISHU_WEBHOOK_SECRET`
- 缺少必需的命令参数
- 飞书 API 返回非 2xx 状态码
- 网络请求失败
## 扩展新命令
CLI 设计支持多种通知类型。添加新命令的步骤:
1. 定义命令选项接口
2. 创建卡片构建函数
3. 添加新的命令处理函数
4. 使用 `program.command()` 注册命令

View File

@ -28,6 +28,12 @@ files:
- "!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}"
- "!**/{.editorconfig,.jekyll-metadata}"
- "!src"
- "!config"
- "!patches"
- "!app-upgrade-config.json"
- "!**/node_modules/**/*.cpp"
- "!**/node_modules/node-addon-api/**"
- "!**/node_modules/prebuild-install/**"
- "!scripts"
- "!local"
- "!docs"
@ -91,6 +97,7 @@ nsis:
oneClick: false
include: build/nsis-installer.nsh
buildUniversalInstaller: false
differentialPackage: false
portable:
artifactName: ${productName}-${version}-${arch}-portable.${ext}
buildUniversalInstaller: false
@ -106,6 +113,8 @@ mac:
target:
- target: dmg
- target: zip
dmg:
writeUpdateInfo: false
linux:
artifactName: ${productName}-${version}-${arch}.${ext}
target:
@ -135,44 +144,30 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
<!--LANG:en-->
Cherry Studio 1.7.9 - New Features & Bug Fixes
Cherry Studio 1.7.13 - Security & Bug Fixes
✨ New Features
- [Agent] Add 302.AI provider support
- [Browser] Browser data now persists and supports multiple tabs
- [Language] Add Romanian language support
- [Search] Add fuzzy search for file list
- [Models] Add latest Zhipu models
- [Image] Improve text-to-image functionality
🔒 Security
- [Plugin] Fix security vulnerability in DXT plugin system on Windows
🐛 Bug Fixes
- [Mac] Fix mini window unexpected closing issue
- [Preview] Fix HTML preview controls not working in fullscreen
- [Translate] Fix translation duplicate execution issue
- [Zoom] Fix page zoom reset issue during navigation
- [Agent] Fix crash when switching between agent and assistant
- [Agent] Fix navigation in agent mode
- [Copy] Fix markdown copy button issue
- [Windows] Fix compatibility issues on non-Windows systems
- [Agent] Fix Agent not working when Node.js is not installed on system
- [Chat] Fix app crash when opening certain agents
- [Chat] Fix reasoning process not displaying correctly for some providers
- [Chat] Fix memory leak issue during streaming conversations
- [MCP] Fix timeout field not accepting string format in MCP configuration
- [Settings] Add careers section in About page
<!--LANG:zh-CN-->
Cherry Studio 1.7.9 - 新功能与问题修复
Cherry Studio 1.7.13 - 安全与问题修复
✨ 新功能
- [Agent] 新增 302.AI 服务商支持
- [浏览器] 浏览器数据现在可以保存,支持多标签页
- [语言] 新增罗马尼亚语支持
- [搜索] 文件列表新增模糊搜索功能
- [模型] 新增最新智谱模型
- [图片] 优化文生图功能
🔒 安全修复
- [插件] 修复 Windows 系统 DXT 插件的安全漏洞
🐛 问题修复
- [Mac] 修复迷你窗口意外关闭的问题
- [预览] 修复全屏模式下 HTML 预览控件无法使用的问题
- [翻译] 修复翻译重复执行的问题
- [缩放] 修复页面导航时缩放被重置的问题
- [智能体] 修复在智能体和助手间切换时崩溃的问题
- [智能体] 修复智能体模式下的导航问题
- [复制] 修复 Markdown 复制按钮问题
- [兼容性] 修复非 Windows 系统的兼容性问题
- [Agent] 修复系统未安装 Node.js 时 Agent 功能无法使用的问题
- [对话] 修复打开某些智能体时应用崩溃的问题
- [对话] 修复部分服务商推理过程无法正确显示的问题
- [对话] 修复流式对话时的内存泄漏问题
- [MCP] 修复 MCP 配置的 timeout 字段不支持字符串格式的问题
- [设置] 关于页面新增招聘入口
<!--LANG:END-->

View File

@ -1,3 +1,4 @@
import { tanstackRouter } from '@tanstack/router-plugin/vite'
import react from '@vitejs/plugin-react-swc'
import { CodeInspectorPlugin } from 'code-inspector-plugin'
import { defineConfig } from 'electron-vite'
@ -80,6 +81,12 @@ export default defineConfig({
},
renderer: {
plugins: [
tanstackRouter({
target: 'react',
autoCodeSplitting: true,
routesDirectory: resolve('src/renderer/src/routes'),
generatedRouteTree: resolve('src/renderer/src/routeTree.gen.ts')
}),
(async () => (await import('@tailwindcss/vite')).default())(),
react({
tsDecorators: true

View File

@ -67,6 +67,7 @@ export default defineConfig([
'src/main/integration/cherryai/index.js',
'src/main/integration/nutstore/sso/lib/**',
'src/renderer/src/ui/**',
'src/renderer/src/routeTree.gen.ts',
'packages/**/dist'
]
},

View File

@ -42,7 +42,7 @@
"i18n:check": "dotenv -e .env -- tsx scripts/check-i18n.ts",
"i18n:sync": "dotenv -e .env -- tsx scripts/sync-i18n.ts",
"i18n:translate": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts",
"i18n:all": "pnpm i18n:check && pnpm i18n:sync && pnpm i18n:translate",
"i18n:all": "pnpm i18n:sync && pnpm i18n:translate",
"update:languages": "tsx scripts/update-languages.ts",
"update:upgrade-config": "tsx scripts/update-app-upgrade-config.ts",
"test": "vitest run --silent",
@ -68,7 +68,7 @@
"release:ai-sdk-provider": "pnpm --filter @cherrystudio/ai-sdk-provider version patch && pnpm --filter @cherrystudio/ai-sdk-provider build && pnpm --filter @cherrystudio/ai-sdk-provider publish --access public"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.1.62",
"@anthropic-ai/claude-agent-sdk": "0.1.76",
"@libsql/client": "0.14.0",
"@napi-rs/system-ocr": "1.0.2",
"@paymoapp/electron-shutdown-handler": "1.1.2",
@ -89,9 +89,6 @@
"turndown": "7.2.0"
},
"devDependencies": {
"js-yaml": "4.1.0",
"bonjour-service": "1.3.0",
"emoji-picker-element-data": "1",
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
@ -181,7 +178,9 @@
"@swc/plugin-styled-components": "^8.0.4",
"@tailwindcss/vite": "^4.1.13",
"@tanstack/react-query": "^5.85.5",
"@tanstack/react-router": "^1.139.3",
"@tanstack/react-virtual": "^3.13.12",
"@tanstack/router-plugin": "^1.139.3",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
@ -258,6 +257,7 @@
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
"axios": "^1.7.3",
"bonjour-service": "1.3.0",
"browser-image-compression": "^2.0.2",
"builder-util-runtime": "9.5.0",
"chalk": "4.1.2",
@ -271,6 +271,7 @@
"code-inspector-plugin": "^0.20.14",
"codemirror-lang-mermaid": "0.5.0",
"color": "^5.0.0",
"commander": "^14.0.2",
"concurrently": "^9.2.1",
"cors": "2.8.5",
"country-flag-emoji-polyfill": "0.1.8",
@ -294,6 +295,7 @@
"electron-window-state": "^5.0.3",
"emittery": "^1.0.3",
"emoji-picker-element": "^1.22.1",
"emoji-picker-element-data": "1",
"epub": "1.3.0",
"eslint": "^9.22.0",
"eslint-plugin-import-zod": "^1.2.0",
@ -323,6 +325,7 @@
"jaison": "^2.0.2",
"jest-styled-components": "^7.2.0",
"js-base64": "3.7.7",
"js-yaml": "4.1.0",
"json-schema": "0.4.0",
"katex": "0.16.22",
"ky": "1.8.1",
@ -364,8 +367,6 @@
"react-markdown": "^10.1.0",
"react-player": "^3.3.1",
"react-redux": "^9.1.2",
"react-router": "6",
"react-router-dom": "6",
"react-spinners": "^0.14.1",
"react-transition-group": "^4.4.5",
"redux": "^5.0.1",
@ -439,7 +440,6 @@
"@ai-sdk/openai-compatible@1.0.27": "1.0.28"
},
"patchedDependencies": {
"@anthropic-ai/claude-agent-sdk@0.1.62": "patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch",
"@napi-rs/system-ocr@1.0.2": "patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
"tesseract.js@6.0.1": "patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"@ai-sdk/google@2.0.49": "patches/@ai-sdk-google-npm-2.0.49-84720f41bd.patch",
@ -457,7 +457,8 @@
"file-stream-rotator@0.6.1": "patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
"libsql@0.4.7": "patches/libsql-npm-0.4.7-444e260fb1.patch",
"pdf-parse@1.1.1": "patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
"@ai-sdk/openai-compatible@1.0.28": "patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch"
"@ai-sdk/openai-compatible@1.0.28": "patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch",
"@anthropic-ai/claude-agent-sdk@0.1.76": "patches/@anthropic-ai__claude-agent-sdk@0.1.76.patch"
},
"onlyBuiltDependencies": [
"@kangfenmao/keyv-storage",
@ -485,5 +486,27 @@
"*.{json,yml,yaml,css,html}": [
"biome format --write --no-errors-on-unmatched"
]
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.3",
"@img/sharp-darwin-x64": "0.34.3",
"@img/sharp-libvips-darwin-arm64": "1.2.0",
"@img/sharp-libvips-darwin-x64": "1.2.0",
"@img/sharp-libvips-linux-arm64": "1.2.0",
"@img/sharp-libvips-linux-x64": "1.2.0",
"@img/sharp-linux-arm64": "0.34.3",
"@img/sharp-linux-x64": "0.34.3",
"@img/sharp-win32-arm64": "0.34.3",
"@img/sharp-win32-x64": "0.34.3",
"@libsql/darwin-arm64": "0.4.7",
"@libsql/darwin-x64": "0.4.7",
"@libsql/linux-arm64-gnu": "0.4.7",
"@libsql/linux-x64-gnu": "0.4.7",
"@libsql/win32-x64-msvc": "0.4.7",
"@napi-rs/system-ocr-darwin-arm64": "1.0.2",
"@napi-rs/system-ocr-darwin-x64": "1.0.2",
"@napi-rs/system-ocr-win32-arm64-msvc": "1.0.2",
"@napi-rs/system-ocr-win32-x64-msvc": "1.0.2",
"@strongtz/win32-arm64-msvc": "0.4.7"
}
}

View File

@ -21,9 +21,6 @@ const TOOL_USE_TAG_CONFIG: TagConfig = {
separator: '\n'
}
/**
*
*/
export const DEFAULT_SYSTEM_PROMPT = `In this environment you have access to a set of tools you can use to answer the user's question. \
You can use one or more tools per message, and will receive the result of that tool use in the user's response. You use tools step-by-step to accomplish a given task, with each tool use informed by the result of the previous tool use.
@ -38,10 +35,16 @@ Tool use is formatted using XML-style tags. The tool name is enclosed in opening
The tool name should be the exact name of the tool you are using, and the arguments should be a JSON object containing the parameters required by that tool. For example:
<tool_use>
<name>python_interpreter</name>
<arguments>{"code": "5 + 3 + 1294.678"}</arguments>
<name>search</name>
<arguments>{ "query": "browser,fetch" }</arguments>
</tool_use>
<tool_use>
<name>exec</name>
<arguments>{ "code": "const page = await CherryBrowser_fetch({ url: "https://example.com" })\nreturn page" }</arguments>
</tool_use>
The user will respond with the result of the tool use, which should be formatted as follows:
<tool_use_result>
@ -59,13 +62,6 @@ For example, if the result of the tool use is an image file, you can use it in t
Always adhere to this format for the tool use to ensure proper parsing and execution.
## Tool Use Examples
{{ TOOL_USE_EXAMPLES }}
## Tool Use Available Tools
Above example were using notional tools that might not exist for you. You only have access to these tools:
{{ AVAILABLE_TOOLS }}
## Tool Use Rules
Here are the rules you should always follow to solve your task:
1. Always use the right arguments for the tools. Never use variable names as the action arguments, use the value instead.
@ -74,6 +70,8 @@ Here are the rules you should always follow to solve your task:
4. Never re-do a tool call that you previously did with the exact same parameters.
5. For tool use, MAKE SURE use XML tag format as shown in the examples above. Do not use any other format.
{{ TOOLS_INFO }}
## Response rules
Respond in the language of the user's query, unless the user instructions specify additional requirements for the language to be used.
@ -154,7 +152,8 @@ User: <tool_use_result>
<name>search</name>
<result>26 million (2019)</result>
</tool_use_result>
Assistant: The population of Shanghai is 26 million, while Guangzhou has a population of 15 million. Therefore, Shanghai has the highest population.`
A: The population of Shanghai is 26 million, while Guangzhou has a population of 15 million. Therefore, Shanghai has the highest population.`
/**
* Cherry Studio
@ -184,13 +183,30 @@ ${result}
/**
* Cherry Studio
*/
function defaultBuildSystemPrompt(userSystemPrompt: string, tools: ToolSet): string {
function defaultBuildSystemPrompt(userSystemPrompt: string, tools: ToolSet, mcpMode?: string): string {
const availableTools = buildAvailableTools(tools)
if (availableTools === null) return userSystemPrompt
const fullPrompt = DEFAULT_SYSTEM_PROMPT.replace('{{ TOOL_USE_EXAMPLES }}', DEFAULT_TOOL_USE_EXAMPLES)
if (mcpMode == 'auto') {
return DEFAULT_SYSTEM_PROMPT.replace('{{ TOOLS_INFO }}', '').replace(
'{{ USER_SYSTEM_PROMPT }}',
userSystemPrompt || ''
)
}
const toolsInfo = `
## Tool Use Examples
{{ TOOL_USE_EXAMPLES }}
## Tool Use Available Tools
Above example were using notional tools that might not exist for you. You only have access to these tools:
{{ AVAILABLE_TOOLS }}`
.replace('{{ TOOL_USE_EXAMPLES }}', DEFAULT_TOOL_USE_EXAMPLES)
.replace('{{ AVAILABLE_TOOLS }}', availableTools)
.replace('{{ USER_SYSTEM_PROMPT }}', userSystemPrompt || '')
const fullPrompt = DEFAULT_SYSTEM_PROMPT.replace('{{ TOOLS_INFO }}', toolsInfo).replace(
'{{ USER_SYSTEM_PROMPT }}',
userSystemPrompt || ''
)
return fullPrompt
}
@ -223,7 +239,17 @@ function defaultParseToolUse(content: string, tools: ToolSet): { results: ToolUs
// Find all tool use blocks
while ((match = toolUsePattern.exec(contentToProcess)) !== null) {
const fullMatch = match[0]
const toolName = match[2].trim()
let toolName = match[2].trim()
switch (toolName.toLowerCase()) {
case 'search':
toolName = 'mcp__CherryHub__search'
break
case 'exec':
toolName = 'mcp__CherryHub__exec'
break
default:
break
}
const toolArgs = match[4].trim()
// Try to parse the arguments as JSON
@ -255,7 +281,12 @@ function defaultParseToolUse(content: string, tools: ToolSet): { results: ToolUs
}
export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
const { enabled = true, buildSystemPrompt = defaultBuildSystemPrompt, parseToolUse = defaultParseToolUse } = config
const {
enabled = true,
buildSystemPrompt = defaultBuildSystemPrompt,
parseToolUse = defaultParseToolUse,
mcpMode
} = config
return definePlugin({
name: 'built-in:prompt-tool-use',
@ -285,7 +316,7 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
// 构建系统提示符(只包含非 provider-defined 工具)
const userSystemPrompt = typeof params.system === 'string' ? params.system : ''
const systemPrompt = buildSystemPrompt(userSystemPrompt, promptTools)
const systemPrompt = buildSystemPrompt(userSystemPrompt, promptTools, mcpMode)
let systemMessage: string | null = systemPrompt
if (config.createSystemMessage) {
// 🎯 如果用户提供了自定义处理函数,使用它

View File

@ -23,6 +23,7 @@ export interface PromptToolUseConfig extends BaseToolUsePluginConfig {
// 自定义工具解析函数(可选,有默认实现)
parseToolUse?: (content: string, tools: ToolSet) => { results: ToolUseResult[]; content: string }
createSystemMessage?: (systemPrompt: string, originalParams: any, context: AiRequestContext) => string | null
mcpMode?: string
}
/**

View File

@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest'
import { isBase64ImageDataUrl, isDataUrl, parseDataUrl } from '../utils'
describe('parseDataUrl', () => {
it('parses a standard base64 image data URL', () => {
const result = parseDataUrl('data:image/png;base64,iVBORw0KGgo=')
expect(result).toEqual({
mediaType: 'image/png',
isBase64: true,
data: 'iVBORw0KGgo='
})
})
it('parses a base64 data URL with additional parameters', () => {
const result = parseDataUrl('data:image/jpeg;name=foo;base64,/9j/4AAQ')
expect(result).toEqual({
mediaType: 'image/jpeg',
isBase64: true,
data: '/9j/4AAQ'
})
})
it('parses a plain text data URL (non-base64)', () => {
const result = parseDataUrl('data:text/plain,Hello%20World')
expect(result).toEqual({
mediaType: 'text/plain',
isBase64: false,
data: 'Hello%20World'
})
})
it('parses a data URL with empty media type', () => {
const result = parseDataUrl('data:;base64,SGVsbG8=')
expect(result).toEqual({
mediaType: undefined,
isBase64: true,
data: 'SGVsbG8='
})
})
it('returns null for non-data URLs', () => {
const result = parseDataUrl('https://example.com/image.png')
expect(result).toBeNull()
})
it('returns null for malformed data URL without comma', () => {
const result = parseDataUrl('data:image/png;base64')
expect(result).toBeNull()
})
it('handles empty string', () => {
const result = parseDataUrl('')
expect(result).toBeNull()
})
it('handles large base64 data without performance issues', () => {
// Simulate a 4K image base64 string (about 1MB)
const largeData = 'A'.repeat(1024 * 1024)
const dataUrl = `data:image/png;base64,${largeData}`
const start = performance.now()
const result = parseDataUrl(dataUrl)
const duration = performance.now() - start
expect(result).not.toBeNull()
expect(result?.mediaType).toBe('image/png')
expect(result?.isBase64).toBe(true)
expect(result?.data).toBe(largeData)
// Should complete in under 10ms (string operations are fast)
expect(duration).toBeLessThan(10)
})
it('parses SVG data URL', () => {
const result = parseDataUrl('data:image/svg+xml;base64,PHN2Zz4=')
expect(result).toEqual({
mediaType: 'image/svg+xml',
isBase64: true,
data: 'PHN2Zz4='
})
})
it('parses JSON data URL', () => {
const result = parseDataUrl('data:application/json,{"key":"value"}')
expect(result).toEqual({
mediaType: 'application/json',
isBase64: false,
data: '{"key":"value"}'
})
})
})
describe('isDataUrl', () => {
it('returns true for valid data URLs', () => {
expect(isDataUrl('data:image/png;base64,ABC')).toBe(true)
expect(isDataUrl('data:text/plain,hello')).toBe(true)
expect(isDataUrl('data:,simple')).toBe(true)
})
it('returns false for non-data URLs', () => {
expect(isDataUrl('https://example.com')).toBe(false)
expect(isDataUrl('file:///path/to/file')).toBe(false)
expect(isDataUrl('')).toBe(false)
})
it('returns false for malformed data URLs', () => {
expect(isDataUrl('data:')).toBe(false)
expect(isDataUrl('data:image/png')).toBe(false)
})
})
describe('isBase64ImageDataUrl', () => {
it('returns true for base64 image data URLs', () => {
expect(isBase64ImageDataUrl('data:image/png;base64,ABC')).toBe(true)
expect(isBase64ImageDataUrl('data:image/jpeg;base64,/9j/')).toBe(true)
expect(isBase64ImageDataUrl('data:image/gif;base64,R0lG')).toBe(true)
expect(isBase64ImageDataUrl('data:image/webp;base64,UklG')).toBe(true)
})
it('returns false for non-base64 image data URLs', () => {
expect(isBase64ImageDataUrl('data:image/svg+xml,<svg></svg>')).toBe(false)
})
it('returns false for non-image data URLs', () => {
expect(isBase64ImageDataUrl('data:text/plain;base64,SGVsbG8=')).toBe(false)
expect(isBase64ImageDataUrl('data:application/json,{}')).toBe(false)
})
it('returns false for regular URLs', () => {
expect(isBase64ImageDataUrl('https://example.com/image.png')).toBe(false)
expect(isBase64ImageDataUrl('file:///image.png')).toBe(false)
})
it('returns false for malformed data URLs', () => {
expect(isBase64ImageDataUrl('data:image/png')).toBe(false)
expect(isBase64ImageDataUrl('')).toBe(false)
})
})

View File

@ -149,11 +149,11 @@ export type UseCacheSchema = {
// TODO [v2]: Replace `any` with proper types after newMessage.ts types are
// migrated to packages/shared/data/types/message.ts
// Current types:
// - StreamingSession: defined locally in StreamingService.ts
// - StreamingTask: defined locally in StreamingService.ts
// - Message: src/renderer/src/types/newMessage.ts (renderer format, not shared/Message)
// - MessageBlock: src/renderer/src/types/newMessage.ts
'message.streaming.session.${messageId}': any // StreamingSession
'message.streaming.topic_sessions.${topicId}': string[]
'message.streaming.task.${messageId}': any // StreamingTask
'message.streaming.topic_tasks.${topicId}': string[]
'message.streaming.content.${messageId}': any // Message (renderer format)
'message.streaming.block.${blockId}': any // MessageBlock
'message.streaming.siblings_counter.${topicId}': number
@ -202,8 +202,8 @@ export const DefaultUseCache: UseCacheSchema = {
'entity.cache.${type}_${id}': { loaded: false, data: null },
// Message Streaming Cache
'message.streaming.session.${messageId}': null,
'message.streaming.topic_sessions.${topicId}': [],
'message.streaming.task.${messageId}': null,
'message.streaming.topic_tasks.${topicId}': [],
'message.streaming.content.${messageId}': null,
'message.streaming.block.${blockId}': null,
'message.streaming.siblings_counter.${topicId}': 0
@ -225,11 +225,11 @@ export const DefaultSharedCache: SharedCacheSchema = {
* This ensures type safety and prevents key conflicts
*/
export type RendererPersistCacheSchema = {
'example_scope.example_key': string
'ui.tab.state': CacheValueTypes.TabsState
}
export const DefaultRendererPersistCache: RendererPersistCacheSchema = {
'example_scope.example_key': 'example default value'
'ui.tab.state': { tabs: [], activeTabId: '' }
}
// ============================================================================

View File

@ -41,3 +41,77 @@ export interface CacheSyncBatchMessage {
* Cache subscription callback
*/
export type CacheSubscriber = () => void
// ============ Cache Statistics Types ============
/**
* Summary statistics for a single cache tier
*/
export interface CacheTierSummary {
/** Total number of entries in this tier */
totalCount: number
/** Number of valid (non-expired) entries */
validCount: number
/** Number of expired entries (lazy cleanup pending) */
expiredCount: number
/** Number of entries with TTL configured */
withTTLCount: number
/** Total hook reference count for this tier */
hookReferences: number
/** Estimated memory size in bytes (rough estimate via JSON serialization) */
estimatedBytes: number
}
/**
* Detailed information for a single cache entry
*/
export interface CacheEntryDetail {
/** Cache key */
key: string
/** Whether the entry has a value */
hasValue: boolean
/** Whether TTL is configured */
hasTTL: boolean
/** Whether the entry is expired */
isExpired: boolean
/** Absolute expiration timestamp (ms since epoch) */
expireAt?: number
/** Remaining time until expiration (ms), undefined if no TTL */
remainingTTL?: number
/** Number of hooks currently referencing this key */
hookCount: number
}
/**
* Complete cache statistics
*/
export interface CacheStats {
/** Timestamp when stats were collected */
collectedAt: number
/** Summary statistics */
summary: {
memory: CacheTierSummary
shared: CacheTierSummary
persist: CacheTierSummary
/** Aggregated totals across all tiers */
total: {
totalCount: number
validCount: number
expiredCount: number
withTTLCount: number
hookReferences: number
/** Total estimated memory in bytes */
estimatedBytes: number
/** Human-readable memory size (e.g., "1.5 KB", "2.3 MB") */
estimatedSize: string
}
}
/** Detailed per-entry information (optional, for debugging) */
details: {
memory: CacheEntryDetail[]
shared: CacheEntryDetail[]
persist: CacheEntryDetail[]
}
}

View File

@ -17,3 +17,38 @@ export type CacheActiveSearches = Record<string, WebSearchStatus>
// The actual type checking will be done at runtime by the cache system
export type CacheMinAppType = MinAppType
export type CacheTopic = Topic
/**
* Tab type for browser-like tabs
*
* - 'route': Internal app routes rendered via MemoryRouter
* - 'webview': External web content rendered via Electron webview
*/
export type TabType = 'route' | 'webview'
/**
* Tab saved state for hibernation recovery
*/
export interface TabSavedState {
scrollPosition?: number
// 其他必要草稿字段可在此扩展
}
export interface Tab {
id: string
type: TabType
url: string
title: string
icon?: string
metadata?: Record<string, unknown>
// LRU 字段
lastAccessTime?: number // open/switch 时更新
isDormant?: boolean // 是否已休眠
isPinned?: boolean // 是否置顶(豁免 LRU
savedState?: TabSavedState // 休眠前保存的状态
}
export interface TabsState {
tabs: Tab[]
activeTabId: string
}

116
packages/shared/mcp.ts Normal file
View File

@ -0,0 +1,116 @@
/**
* Convert a string to camelCase, ensuring it's a valid JavaScript identifier.
*
* - Normalizes to lowercase first, then capitalizes word boundaries
* - Non-alphanumeric characters are treated as word separators
* - Non-ASCII characters are dropped (ASCII-only output)
* - If result starts with a digit, prefixes with underscore
*
* @example
* toCamelCase('my-server') // 'myServer'
* toCamelCase('MY_SERVER') // 'myServer'
* toCamelCase('123tool') // '_123tool'
*/
export function toCamelCase(str: string): string {
let result = str
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+(.)/g, (_, char) => char.toUpperCase())
.replace(/[^a-zA-Z0-9]/g, '')
if (result && !/^[a-zA-Z_]/.test(result)) {
result = '_' + result
}
return result
}
export type McpToolNameOptions = {
/** Prefix added before the name (e.g., 'mcp__'). Must be JS-identifier-safe. */
prefix?: string
/** Delimiter between server and tool parts (e.g., '_' or '__'). Must be JS-identifier-safe. */
delimiter?: string
/** Maximum length of the final name. Suffix numbers for uniqueness are included in this limit. */
maxLength?: number
/** Mutable Set for collision detection. The final name will be added to this Set. */
existingNames?: Set<string>
}
/**
* Build a valid JavaScript function name from server and tool names.
* Uses camelCase for both parts.
*
* @param serverName - The MCP server name (optional)
* @param toolName - The tool name
* @param options - Configuration options
* @returns A valid JS identifier
*/
export function buildMcpToolName(
serverName: string | undefined,
toolName: string,
options: McpToolNameOptions = {}
): string {
const { prefix = '', delimiter = '_', maxLength, existingNames } = options
const serverPart = serverName ? toCamelCase(serverName) : ''
const toolPart = toCamelCase(toolName)
const baseName = serverPart ? `${prefix}${serverPart}${delimiter}${toolPart}` : `${prefix}${toolPart}`
if (!existingNames) {
return maxLength ? truncateToLength(baseName, maxLength) : baseName
}
let name = maxLength ? truncateToLength(baseName, maxLength) : baseName
let counter = 1
while (existingNames.has(name)) {
const suffix = String(counter)
const truncatedBase = maxLength ? truncateToLength(baseName, maxLength - suffix.length) : baseName
name = `${truncatedBase}${suffix}`
counter++
}
existingNames.add(name)
return name
}
function truncateToLength(str: string, maxLength: number): string {
if (str.length <= maxLength) {
return str
}
return str.slice(0, maxLength).replace(/_+$/, '')
}
/**
* Generate a unique function name from server name and tool name.
* Format: serverName_toolName (camelCase)
*
* @example
* generateMcpToolFunctionName('github', 'search_issues') // 'github_searchIssues'
*/
export function generateMcpToolFunctionName(
serverName: string | undefined,
toolName: string,
existingNames?: Set<string>
): string {
return buildMcpToolName(serverName, toolName, { existingNames })
}
/**
* Builds a valid JavaScript function name for MCP tool calls.
* Format: mcp__{serverName}__{toolName}
*
* @param serverName - The MCP server name
* @param toolName - The tool name from the server
* @returns A valid JS identifier in format mcp__{server}__{tool}, max 63 chars
*
* @example
* buildFunctionCallToolName('github', 'search_issues') // 'mcp__github__searchIssues'
*/
export function buildFunctionCallToolName(serverName: string, toolName: string): string {
return buildMcpToolName(serverName, toolName, {
prefix: 'mcp__',
delimiter: '__',
maxLength: 63
})
}

View File

@ -88,3 +88,81 @@ const TRAILING_VERSION_REGEX = /\/v\d+(?:alpha|beta)?\/?$/i
export function withoutTrailingApiVersion(url: string): string {
return url.replace(TRAILING_VERSION_REGEX, '')
}
export interface DataUrlParts {
/** The media type (e.g., 'image/png', 'text/plain') */
mediaType?: string
/** Whether the data is base64 encoded */
isBase64: boolean
/** The data portion (everything after the comma). This is the raw string, not decoded. */
data: string
}
/**
* Parses a data URL into its component parts without using regex on the data portion.
* This is memory-safe for large data URLs (e.g., 4K images) as it uses indexOf instead of regex.
*
* Data URL format: data:[<mediatype>][;base64],<data>
*
* @param url - The data URL string to parse
* @returns DataUrlParts if valid, null if invalid
*
* @example
* parseDataUrl('data:image/png;base64,iVBORw0KGgo...')
* // { mediaType: 'image/png', isBase64: true, data: 'iVBORw0KGgo...' }
*
* parseDataUrl('data:text/plain,Hello')
* // { mediaType: 'text/plain', isBase64: false, data: 'Hello' }
*
* parseDataUrl('invalid-url')
* // null
*/
export function parseDataUrl(url: string): DataUrlParts | null {
if (!url.startsWith('data:')) {
return null
}
const commaIndex = url.indexOf(',')
if (commaIndex === -1) {
return null
}
const header = url.slice(5, commaIndex)
const isBase64 = header.includes(';base64')
const semicolonIndex = header.indexOf(';')
const mediaType = (semicolonIndex === -1 ? header : header.slice(0, semicolonIndex)).trim() || undefined
const data = url.slice(commaIndex + 1)
return { mediaType, isBase64, data }
}
/**
* Checks if a string is a data URL.
*
* @param url - The string to check
* @returns true if the string is a valid data URL
*/
export function isDataUrl(url: string): boolean {
return url.startsWith('data:') && url.includes(',')
}
/**
* Checks if a data URL contains base64-encoded image data.
*
* @param url - The data URL to check
* @returns true if the URL is a base64-encoded image data URL
*/
export function isBase64ImageDataUrl(url: string): boolean {
if (!url.startsWith('data:image/')) {
return false
}
const commaIndex = url.indexOf(',')
if (commaIndex === -1) {
return false
}
const header = url.slice(5, commaIndex)
return header.includes(';base64')
}

View File

@ -49,6 +49,7 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/primitive": "^1.1.3",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-context-menu": "^2.2.6",
"@radix-ui/react-dialog": "^1.1.15",
@ -58,6 +59,7 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-callback-ref": "^1.1.1",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -70,6 +72,7 @@
"@heroui/react": "^2.8.4",
"@storybook/addon-docs": "^10.0.5",
"@storybook/addon-themes": "^10.0.5",
"@storybook/react": "^10.1.11",
"@storybook/react-vite": "^10.0.5",
"@svgr/core": "^8.1.0",
"@svgr/plugin-jsx": "^8.1.0",
@ -94,11 +97,6 @@
"typescript": "^5.6.2",
"vitest": "^3.2.4"
},
"resolutions": {
"@codemirror/language": "6.11.3",
"@codemirror/lint": "6.8.5",
"@codemirror/view": "6.38.1"
},
"sideEffects": false,
"engines": {
"node": ">=18.0.0"
@ -147,6 +145,5 @@
"./styles/tokens.css": "./src/styles/tokens.css",
"./styles/theme.css": "./src/styles/theme.css",
"./styles/index.css": "./src/styles/index.css"
},
"packageManager": "yarn@4.9.1"
}
}

View File

@ -1,35 +0,0 @@
diff --git a/sdk.mjs b/sdk.mjs
index dea7766a3432a1e809f12d6daba4f2834a219689..e0b02ef73da177ba32b903887d7bbbeaa08cc6d3 100755
--- a/sdk.mjs
+++ b/sdk.mjs
@@ -6250,7 +6250,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
}
// ../src/transport/ProcessTransport.ts
-import { spawn } from "child_process";
+import { fork } from "child_process";
import { createInterface } from "readline";
// ../src/utils/fsOperations.ts
@@ -6644,18 +6644,11 @@ class ProcessTransport {
const errorMessage = isNativeBinary(pathToClaudeCodeExecutable) ? `Claude Code native binary not found at ${pathToClaudeCodeExecutable}. Please ensure Claude Code is installed via native installer or specify a valid path with options.pathToClaudeCodeExecutable.` : `Claude Code executable not found at ${pathToClaudeCodeExecutable}. Is options.pathToClaudeCodeExecutable set?`;
throw new ReferenceError(errorMessage);
}
- const isNative = isNativeBinary(pathToClaudeCodeExecutable);
- const spawnCommand = isNative ? pathToClaudeCodeExecutable : executable;
- const spawnArgs = isNative ? [...executableArgs, ...args] : [...executableArgs, pathToClaudeCodeExecutable, ...args];
- const spawnMessage = isNative ? `Spawning Claude Code native binary: ${spawnCommand} ${spawnArgs.join(" ")}` : `Spawning Claude Code process: ${spawnCommand} ${spawnArgs.join(" ")}`;
- logForSdkDebugging(spawnMessage);
- if (stderr) {
- stderr(spawnMessage);
- }
+ logForSdkDebugging(`Forking Claude Code Node.js process: ${pathToClaudeCodeExecutable} ${args.join(" ")}`);
const stderrMode = env.DEBUG_CLAUDE_AGENT_SDK || stderr ? "pipe" : "ignore";
- this.child = spawn(spawnCommand, spawnArgs, {
+ this.child = fork(pathToClaudeCodeExecutable, args, {
cwd,
- stdio: ["pipe", "pipe", stderrMode],
+ stdio: stderrMode === "pipe" ? ["pipe", "pipe", "pipe", "ipc"] : ["pipe", "pipe", "ignore", "ipc"],
signal: this.abortController.signal,
env
});

View File

@ -0,0 +1,33 @@
diff --git a/sdk.mjs b/sdk.mjs
index 1e1c3e4e3f81db622fb2789d17f3d421f212306e..5d193cdb6a43c7799fd5eff2d8af80827bfbdf1e 100755
--- a/sdk.mjs
+++ b/sdk.mjs
@@ -11985,7 +11985,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
}
// ../src/transport/ProcessTransport.ts
-import { spawn } from "child_process";
+import { fork } from "child_process";
import { createInterface } from "readline";
// ../src/utils/fsOperations.ts
@@ -12999,14 +12999,14 @@ class ProcessTransport {
return isRunningWithBun() ? "bun" : "node";
}
spawnLocalProcess(spawnOptions) {
- const { command, args, cwd: cwd2, env, signal } = spawnOptions;
+ const { args, cwd: cwd2, env, signal } = spawnOptions;
const stderrMode = env.DEBUG_CLAUDE_AGENT_SDK || this.options.stderr ? "pipe" : "ignore";
- const childProcess = spawn(command, args, {
+ logForSdkDebugging(`Forking Claude Code Node.js process: ${args[0]} ${args.slice(1).join(" ")}`);
+ const childProcess = fork(args[0], args.slice(1), {
cwd: cwd2,
- stdio: ["pipe", "pipe", stderrMode],
+ stdio: stderrMode === "pipe" ? ["pipe", "pipe", "pipe", "ipc"] : ["pipe", "pipe", "ignore", "ipc"],
signal,
- env,
- windowsHide: true
+ env
});
if (env.DEBUG_CLAUDE_AGENT_SDK || this.options.stderr) {
childProcess.stderr.on("data", (data) => {

585
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,2 +1,8 @@
packages:
- 'packages/*'
supportedArchitectures:
os:
- current
cpu:
- current

View File

@ -1,42 +1,35 @@
const { Arch } = require('electron-builder')
const { downloadNpmPackage } = require('./utils')
const { execSync } = require('child_process')
const fs = require('fs')
const path = require('path')
const yaml = require('js-yaml')
const workspaceConfigPath = path.join(__dirname, '..', 'pnpm-workspace.yaml')
// if you want to add new prebuild binaries packages with different architectures, you can add them here
// please add to allX64 and allArm64 from pnpm-lock.yaml
const allArm64 = {
'@img/sharp-darwin-arm64': '0.34.3',
'@img/sharp-win32-arm64': '0.34.3',
'@img/sharp-linux-arm64': '0.34.3',
'@img/sharp-libvips-darwin-arm64': '1.2.4',
'@img/sharp-libvips-linux-arm64': '1.2.4',
'@libsql/darwin-arm64': '0.4.7',
'@libsql/linux-arm64-gnu': '0.4.7',
'@strongtz/win32-arm64-msvc': '0.4.7',
'@napi-rs/system-ocr-darwin-arm64': '1.0.2',
'@napi-rs/system-ocr-win32-arm64-msvc': '1.0.2'
}
const allX64 = {
'@img/sharp-darwin-x64': '0.34.3',
'@img/sharp-linux-x64': '0.34.3',
'@img/sharp-win32-x64': '0.34.3',
'@img/sharp-libvips-darwin-x64': '1.2.4',
'@img/sharp-libvips-linux-x64': '1.2.4',
'@libsql/darwin-x64': '0.4.7',
'@libsql/linux-x64-gnu': '0.4.7',
'@libsql/win32-x64-msvc': '0.4.7',
'@napi-rs/system-ocr-darwin-x64': '1.0.2',
'@napi-rs/system-ocr-win32-x64-msvc': '1.0.2'
}
const claudeCodeVenderPath = '@anthropic-ai/claude-agent-sdk/vendor'
const claudeCodeVenders = ['arm64-darwin', 'arm64-linux', 'x64-darwin', 'x64-linux', 'x64-win32']
const packages = [
'@img/sharp-darwin-arm64',
'@img/sharp-darwin-x64',
'@img/sharp-linux-arm64',
'@img/sharp-linux-x64',
'@img/sharp-win32-arm64',
'@img/sharp-win32-x64',
'@img/sharp-libvips-darwin-arm64',
'@img/sharp-libvips-darwin-x64',
'@img/sharp-libvips-linux-arm64',
'@img/sharp-libvips-linux-x64',
'@libsql/darwin-arm64',
'@libsql/darwin-x64',
'@libsql/linux-arm64-gnu',
'@libsql/linux-x64-gnu',
'@libsql/win32-x64-msvc',
'@napi-rs/system-ocr-darwin-arm64',
'@napi-rs/system-ocr-darwin-x64',
'@napi-rs/system-ocr-win32-arm64-msvc',
'@napi-rs/system-ocr-win32-x64-msvc',
'@strongtz/win32-arm64-msvc'
]
const platformToArch = {
mac: 'darwin',
@ -45,61 +38,82 @@ const platformToArch = {
}
exports.default = async function (context) {
const arch = context.arch
const archType = arch === Arch.arm64 ? 'arm64' : 'x64'
const platform = context.packager.platform.name
const arch = context.arch === Arch.arm64 ? 'arm64' : 'x64'
const platformName = context.packager.platform.name
const platform = platformToArch[platformName]
const downloadPackages = async (packages) => {
console.log('downloading packages ......')
const downloadPromises = []
for (const name of Object.keys(packages)) {
if (name.includes(`${platformToArch[platform]}`) && name.includes(`-${archType}`)) {
downloadPromises.push(
downloadNpmPackage(
name,
`https://registry.npmjs.org/${name}/-/${name.split('/').pop()}-${packages[name]}.tgz`
)
)
}
const downloadPackages = async () => {
// Skip if target platform and architecture match current system
if (platform === process.platform && arch === process.arch) {
console.log(`Skipping install: target (${platform}/${arch}) matches current system`)
return
}
await Promise.all(downloadPromises)
console.log(`Installing packages for target platform=${platform} arch=${arch}...`)
// Backup and modify pnpm-workspace.yaml to add target platform support
const originalWorkspaceConfig = fs.readFileSync(workspaceConfigPath, 'utf-8')
const workspaceConfig = yaml.load(originalWorkspaceConfig)
// Add target platform to supportedArchitectures.os
if (!workspaceConfig.supportedArchitectures.os.includes(platform)) {
workspaceConfig.supportedArchitectures.os.push(platform)
}
// Add target architecture to supportedArchitectures.cpu
if (!workspaceConfig.supportedArchitectures.cpu.includes(arch)) {
workspaceConfig.supportedArchitectures.cpu.push(arch)
}
const modifiedWorkspaceConfig = yaml.dump(workspaceConfig)
console.log('Modified workspace config:', modifiedWorkspaceConfig)
fs.writeFileSync(workspaceConfigPath, modifiedWorkspaceConfig)
try {
execSync(`pnpm install`, { stdio: 'inherit' })
} finally {
// Restore original pnpm-workspace.yaml
fs.writeFileSync(workspaceConfigPath, originalWorkspaceConfig)
}
}
const changeFilters = async (filtersToExclude, filtersToInclude) => {
// remove filters for the target architecture (allow inclusion)
let filters = context.packager.config.files[0].filter
filters = filters.filter((filter) => !filtersToInclude.includes(filter))
await downloadPackages()
const excludePackages = async (packagesToExclude) => {
// 从项目根目录的 electron-builder.yml 读取 files 配置,避免多次覆盖配置导致出错
const electronBuilderConfigPath = path.join(__dirname, '..', 'electron-builder.yml')
const electronBuilderConfig = yaml.load(fs.readFileSync(electronBuilderConfigPath, 'utf-8'))
let filters = electronBuilderConfig.files
// add filters for other architectures (exclude them)
filters.push(...filtersToExclude)
filters.push(...packagesToExclude)
context.packager.config.files[0].filter = filters
}
await downloadPackages(arch === Arch.arm64 ? allArm64 : allX64)
const arm64KeepPackages = packages.filter((p) => p.includes('arm64') && p.includes(platform))
const arm64ExcludePackages = packages
.filter((p) => !arm64KeepPackages.includes(p))
.map((p) => '!node_modules/' + p + '/**')
const arm64Filters = Object.keys(allArm64).map((f) => '!node_modules/' + f + '/**')
const x64Filters = Object.keys(allX64).map((f) => '!node_modules/' + f + '/*')
const excludeClaudeCodeRipgrepFilters = claudeCodeVenders
.filter((f) => f !== `${archType}-${platformToArch[platform]}`)
.map((f) => '!node_modules/' + claudeCodeVenderPath + '/ripgrep/' + f + '/**')
const excludeClaudeCodeJBPlutins = ['!node_modules/' + claudeCodeVenderPath + '/' + 'claude-code-jetbrains-plugin']
const x64KeepPackages = packages.filter((p) => p.includes('x64') && p.includes(platform))
const x64ExcludePackages = packages
.filter((p) => !x64KeepPackages.includes(p))
.map((p) => '!node_modules/' + p + '/**')
const includeClaudeCodeFilters = [
'!node_modules/' + claudeCodeVenderPath + '/ripgrep/' + `${archType}-${platformToArch[platform]}/**`
]
const excludeRipgrepFilters = ['arm64-darwin', 'arm64-linux', 'x64-darwin', 'x64-linux', 'x64-win32']
.filter((f) => {
// On Windows ARM64, also keep x64-win32 for emulation compatibility
if (platform === 'win32' && context.arch === Arch.arm64 && f === 'x64-win32') {
return false
}
return f !== `${arch}-${platform}`
})
.map((f) => '!node_modules/@anthropic-ai/claude-agent-sdk/vendor/ripgrep/' + f + '/**')
if (arch === Arch.arm64) {
await changeFilters(
[...x64Filters, ...excludeClaudeCodeRipgrepFilters, ...excludeClaudeCodeJBPlutins],
[...arm64Filters, ...includeClaudeCodeFilters]
)
if (context.arch === Arch.arm64) {
await excludePackages([...arm64ExcludePackages, ...excludeRipgrepFilters])
} else {
await changeFilters(
[...arm64Filters, ...excludeClaudeCodeRipgrepFilters, ...excludeClaudeCodeJBPlutins],
[...x64Filters, ...includeClaudeCodeFilters]
)
await excludePackages([...x64ExcludePackages, ...excludeRipgrepFilters])
}
}

View File

@ -1,211 +0,0 @@
/**
* Feishu (Lark) Webhook Notification Script
* Sends GitHub issue summaries to Feishu with signature verification
*/
const crypto = require('crypto')
const https = require('https')
/**
* Generate Feishu webhook signature
* @param {string} secret - Feishu webhook secret
* @param {number} timestamp - Unix timestamp in seconds
* @returns {string} Base64 encoded signature
*/
function generateSignature(secret, timestamp) {
const stringToSign = `${timestamp}\n${secret}`
const hmac = crypto.createHmac('sha256', stringToSign)
return hmac.digest('base64')
}
/**
* Send message to Feishu webhook
* @param {string} webhookUrl - Feishu webhook URL
* @param {string} secret - Feishu webhook secret
* @param {object} content - Message content
* @returns {Promise<void>}
*/
function sendToFeishu(webhookUrl, secret, content) {
return new Promise((resolve, reject) => {
const timestamp = Math.floor(Date.now() / 1000)
const sign = generateSignature(secret, timestamp)
const payload = JSON.stringify({
timestamp: timestamp.toString(),
sign: sign,
msg_type: 'interactive',
card: content
})
const url = new URL(webhookUrl)
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
}
const req = https.request(options, (res) => {
let data = ''
res.on('data', (chunk) => {
data += chunk
})
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
console.log('✅ Successfully sent to Feishu:', data)
resolve()
} else {
reject(new Error(`Feishu API error: ${res.statusCode} - ${data}`))
}
})
})
req.on('error', (error) => {
reject(error)
})
req.write(payload)
req.end()
})
}
/**
* Create Feishu card message from issue data
* @param {object} issueData - GitHub issue data
* @returns {object} Feishu card content
*/
function createIssueCard(issueData) {
const { issueUrl, issueNumber, issueTitle, issueSummary, issueAuthor, labels } = issueData
// Build labels section if labels exist
const labelElements =
labels && labels.length > 0
? labels.map((label) => ({
tag: 'markdown',
content: `\`${label}\``
}))
: []
return {
elements: [
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**👤 Author:** ${issueAuthor}`
}
},
...(labelElements.length > 0
? [
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**🏷️ Labels:** ${labels.join(', ')}`
}
}
]
: []),
{
tag: 'hr'
},
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**📋 Summary:**\n${issueSummary}`
}
},
{
tag: 'hr'
},
{
tag: 'action',
actions: [
{
tag: 'button',
text: {
tag: 'plain_text',
content: '🔗 View Issue'
},
type: 'primary',
url: issueUrl
}
]
}
],
header: {
template: 'blue',
title: {
tag: 'plain_text',
content: `#${issueNumber} - ${issueTitle}`
}
}
}
}
/**
* Main function
*/
async function main() {
try {
// Get environment variables
const webhookUrl = process.env.FEISHU_WEBHOOK_URL
const secret = process.env.FEISHU_WEBHOOK_SECRET
const issueUrl = process.env.ISSUE_URL
const issueNumber = process.env.ISSUE_NUMBER
const issueTitle = process.env.ISSUE_TITLE
const issueSummary = process.env.ISSUE_SUMMARY
const issueAuthor = process.env.ISSUE_AUTHOR
const labelsStr = process.env.ISSUE_LABELS || ''
// Validate required environment variables
if (!webhookUrl) {
throw new Error('FEISHU_WEBHOOK_URL environment variable is required')
}
if (!secret) {
throw new Error('FEISHU_WEBHOOK_SECRET environment variable is required')
}
if (!issueUrl || !issueNumber || !issueTitle || !issueSummary) {
throw new Error('Issue data environment variables are required')
}
// Parse labels
const labels = labelsStr
? labelsStr
.split(',')
.map((l) => l.trim())
.filter(Boolean)
: []
// Create issue data object
const issueData = {
issueUrl,
issueNumber,
issueTitle,
issueSummary,
issueAuthor: issueAuthor || 'Unknown',
labels
}
// Create card content
const card = createIssueCard(issueData)
console.log('📤 Sending notification to Feishu...')
console.log(`Issue #${issueNumber}: ${issueTitle}`)
// Send to Feishu
await sendToFeishu(webhookUrl, secret, card)
console.log('✅ Notification sent successfully!')
} catch (error) {
console.error('❌ Error:', error.message)
process.exit(1)
}
}
// Run main function
main()

421
scripts/feishu-notify.ts Normal file
View File

@ -0,0 +1,421 @@
#!/usr/bin/env npx tsx
/**
* @fileoverview Feishu (Lark) Webhook Notification CLI Tool
* @description Sends notifications to Feishu with signature verification.
* Supports subcommands for different notification types.
* @module feishu-notify
* @example
* // Send GitHub issue notification
* pnpm tsx feishu-notify.ts issue -u "https://..." -n "123" -t "Title" -m "Summary"
*
* // Using environment variables for credentials
* FEISHU_WEBHOOK_URL="..." FEISHU_WEBHOOK_SECRET="..." pnpm tsx feishu-notify.ts issue ...
*/
import { Command } from 'commander'
import crypto from 'crypto'
import dotenv from 'dotenv'
import https from 'https'
import * as z from 'zod'
// Load environment variables from .env file
dotenv.config()
/** CLI tool version */
const VERSION = '1.0.0'
/** GitHub issue data structure */
interface IssueData {
/** GitHub issue URL */
issueUrl: string
/** Issue number */
issueNumber: string
/** Issue title */
issueTitle: string
/** Issue summary/description */
issueSummary: string
/** Issue author username */
issueAuthor: string
/** Issue labels */
labels: string[]
}
/** Feishu card text element */
interface FeishuTextElement {
tag: 'div'
text: {
tag: 'lark_md'
content: string
}
}
/** Feishu card horizontal rule element */
interface FeishuHrElement {
tag: 'hr'
}
/** Feishu card action button */
interface FeishuActionElement {
tag: 'action'
actions: Array<{
tag: 'button'
text: {
tag: 'plain_text'
content: string
}
type: 'primary' | 'default'
url: string
}>
}
/** Feishu card element union type */
type FeishuCardElement = FeishuTextElement | FeishuHrElement | FeishuActionElement
/** Zod schema for Feishu header color template */
const FeishuHeaderTemplateSchema = z.enum([
'blue',
'wathet',
'turquoise',
'green',
'yellow',
'orange',
'red',
'carmine',
'violet',
'purple',
'indigo',
'grey',
'default'
])
/** Feishu card header color template (inferred from schema) */
type FeishuHeaderTemplate = z.infer<typeof FeishuHeaderTemplateSchema>
/** Feishu interactive card structure */
interface FeishuCard {
elements: FeishuCardElement[]
header: {
template: FeishuHeaderTemplate
title: {
tag: 'plain_text'
content: string
}
}
}
/** Feishu webhook request payload */
interface FeishuPayload {
timestamp: string
sign: string
msg_type: 'interactive'
card: FeishuCard
}
/** Issue subcommand options */
interface IssueOptions {
url: string
number: string
title: string
summary: string
author?: string
labels?: string
}
/** Send subcommand options */
interface SendOptions {
title: string
description: string
color?: string
}
/**
* Generate Feishu webhook signature using HMAC-SHA256
* @param secret - Feishu webhook secret
* @param timestamp - Unix timestamp in seconds
* @returns Base64 encoded signature
*/
function generateSignature(secret: string, timestamp: number): string {
const stringToSign = `${timestamp}\n${secret}`
const hmac = crypto.createHmac('sha256', stringToSign)
return hmac.digest('base64')
}
/**
* Send message to Feishu webhook
* @param webhookUrl - Feishu webhook URL
* @param secret - Feishu webhook secret
* @param content - Feishu card message content
* @returns Resolves when message is sent successfully
* @throws When Feishu API returns non-2xx status code or network error occurs
*/
function sendToFeishu(webhookUrl: string, secret: string, content: FeishuCard): Promise<void> {
return new Promise((resolve, reject) => {
const timestamp = Math.floor(Date.now() / 1000)
const sign = generateSignature(secret, timestamp)
const payload: FeishuPayload = {
timestamp: timestamp.toString(),
sign,
msg_type: 'interactive',
card: content
}
const payloadStr = JSON.stringify(payload)
const url = new URL(webhookUrl)
const options: https.RequestOptions = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payloadStr)
}
}
const req = https.request(options, (res) => {
let data = ''
res.on('data', (chunk: Buffer) => {
data += chunk.toString()
})
res.on('end', () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
console.log('Successfully sent to Feishu:', data)
resolve()
} else {
reject(new Error(`Feishu API error: ${res.statusCode} - ${data}`))
}
})
})
req.on('error', (error: Error) => {
reject(error)
})
req.write(payloadStr)
req.end()
})
}
/**
* Create Feishu card message from issue data
* @param issueData - GitHub issue data
* @returns Feishu card content
*/
function createIssueCard(issueData: IssueData): FeishuCard {
const { issueUrl, issueNumber, issueTitle, issueSummary, issueAuthor, labels } = issueData
const elements: FeishuCardElement[] = [
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**Author:** ${issueAuthor}`
}
}
]
if (labels.length > 0) {
elements.push({
tag: 'div',
text: {
tag: 'lark_md',
content: `**Labels:** ${labels.join(', ')}`
}
})
}
elements.push(
{ tag: 'hr' },
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**Summary:**\n${issueSummary}`
}
},
{ tag: 'hr' },
{
tag: 'action',
actions: [
{
tag: 'button',
text: {
tag: 'plain_text',
content: 'View Issue'
},
type: 'primary',
url: issueUrl
}
]
}
)
return {
elements,
header: {
template: 'blue',
title: {
tag: 'plain_text',
content: `#${issueNumber} - ${issueTitle}`
}
}
}
}
/**
* Create a simple Feishu card message
* @param title - Card title
* @param description - Card description content
* @param color - Header color template (default: 'turquoise')
* @returns Feishu card content
*/
function createSimpleCard(title: string, description: string, color: FeishuHeaderTemplate = 'turquoise'): FeishuCard {
return {
elements: [
{
tag: 'div',
text: {
tag: 'lark_md',
content: description
}
}
],
header: {
template: color,
title: {
tag: 'plain_text',
content: title
}
}
}
}
/**
* Get Feishu credentials from environment variables
*/
function getCredentials(): { webhookUrl: string; secret: string } {
const webhookUrl = process.env.FEISHU_WEBHOOK_URL
const secret = process.env.FEISHU_WEBHOOK_SECRET
if (!webhookUrl) {
console.error('Error: FEISHU_WEBHOOK_URL environment variable is required')
process.exit(1)
}
if (!secret) {
console.error('Error: FEISHU_WEBHOOK_SECRET environment variable is required')
process.exit(1)
}
return { webhookUrl, secret }
}
/**
* Handle send subcommand
*/
async function handleSendCommand(options: SendOptions): Promise<void> {
const { webhookUrl, secret } = getCredentials()
const { title, description, color = 'turquoise' } = options
// Validate color parameter
const colorValidation = FeishuHeaderTemplateSchema.safeParse(color)
if (!colorValidation.success) {
console.error(`Error: Invalid color "${color}". Valid colors: ${FeishuHeaderTemplateSchema.options.join(', ')}`)
process.exit(1)
}
const card = createSimpleCard(title, description, colorValidation.data)
console.log('Sending notification to Feishu...')
console.log(`Title: ${title}`)
await sendToFeishu(webhookUrl, secret, card)
console.log('Notification sent successfully!')
}
/**
* Handle issue subcommand
*/
async function handleIssueCommand(options: IssueOptions): Promise<void> {
const { webhookUrl, secret } = getCredentials()
const { url, number, title, summary, author = 'Unknown', labels: labelsStr = '' } = options
if (!url || !number || !title || !summary) {
console.error('Error: --url, --number, --title, and --summary are required')
process.exit(1)
}
const labels = labelsStr
? labelsStr
.split(',')
.map((l) => l.trim())
.filter(Boolean)
: []
const issueData: IssueData = {
issueUrl: url,
issueNumber: number,
issueTitle: title,
issueSummary: summary,
issueAuthor: author,
labels
}
const card = createIssueCard(issueData)
console.log('Sending notification to Feishu...')
console.log(`Issue #${number}: ${title}`)
await sendToFeishu(webhookUrl, secret, card)
console.log('Notification sent successfully!')
}
// Configure CLI
const program = new Command()
program.name('feishu-notify').description('Send notifications to Feishu webhook').version(VERSION)
// Send subcommand (generic)
program
.command('send')
.description('Send a simple notification to Feishu')
.requiredOption('-t, --title <title>', 'Card title')
.requiredOption('-d, --description <description>', 'Card description (supports markdown)')
.option(
'-c, --color <color>',
`Header color template (default: turquoise). Options: ${FeishuHeaderTemplateSchema.options.join(', ')}`,
'turquoise'
)
.action(async (options: SendOptions) => {
try {
await handleSendCommand(options)
} catch (error) {
console.error('Error:', error instanceof Error ? error.message : error)
process.exit(1)
}
})
// Issue subcommand
program
.command('issue')
.description('Send GitHub issue notification to Feishu')
.requiredOption('-u, --url <url>', 'GitHub issue URL')
.requiredOption('-n, --number <number>', 'Issue number')
.requiredOption('-t, --title <title>', 'Issue title')
.requiredOption('-m, --summary <summary>', 'Issue summary')
.option('-a, --author <author>', 'Issue author', 'Unknown')
.option('-l, --labels <labels>', 'Issue labels, comma-separated')
.action(async (options: IssueOptions) => {
try {
await handleIssueCommand(options)
} catch (error) {
console.error('Error:', error instanceof Error ? error.message : error)
process.exit(1)
}
})
program.parse()

View File

@ -1,64 +0,0 @@
const fs = require('fs')
const path = require('path')
const os = require('os')
const zlib = require('zlib')
const tar = require('tar')
const { pipeline } = require('stream/promises')
async function downloadNpmPackage(packageName, url) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'npm-download-'))
const targetDir = path.join('./node_modules/', packageName)
const filename = path.join(tempDir, packageName.replace('/', '-') + '.tgz')
const extractDir = path.join(tempDir, 'extract')
// Skip if directory already exists
if (fs.existsSync(targetDir)) {
console.log(`${targetDir} already exists, skipping download...`)
return
}
try {
console.log(`Downloading ${packageName}...`, url)
// Download file using fetch API
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const fileStream = fs.createWriteStream(filename)
await pipeline(response.body, fileStream)
console.log(`Extracting ${filename}...`)
// Create extraction directory
fs.mkdirSync(extractDir, { recursive: true })
// Extract tar.gz file using Node.js streams
await pipeline(fs.createReadStream(filename), zlib.createGunzip(), tar.extract({ cwd: extractDir }))
// Remove the downloaded file
fs.rmSync(filename, { force: true })
// Create target directory
fs.mkdirSync(targetDir, { recursive: true })
// Move extracted package contents to target directory
const packageDir = path.join(extractDir, 'package')
if (fs.existsSync(packageDir)) {
fs.cpSync(packageDir, targetDir, { recursive: true })
}
} catch (error) {
console.error(`Error processing ${packageName}: ${error.message}`)
throw error
} finally {
// Clean up temp directory
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true })
}
}
}
module.exports = {
downloadNpmPackage
}

View File

@ -0,0 +1,240 @@
import { buildFunctionCallToolName, buildMcpToolName, generateMcpToolFunctionName, toCamelCase } from '@shared/mcp'
import { describe, expect, it } from 'vitest'
describe('toCamelCase', () => {
it('should convert hyphenated strings', () => {
expect(toCamelCase('my-server')).toBe('myServer')
expect(toCamelCase('my-tool-name')).toBe('myToolName')
})
it('should convert underscored strings', () => {
expect(toCamelCase('my_server')).toBe('myServer')
expect(toCamelCase('search_issues')).toBe('searchIssues')
})
it('should handle mixed delimiters', () => {
expect(toCamelCase('my-server_name')).toBe('myServerName')
})
it('should handle leading numbers by prefixing underscore', () => {
expect(toCamelCase('123server')).toBe('_123server')
})
it('should handle special characters', () => {
expect(toCamelCase('test@server!')).toBe('testServer')
expect(toCamelCase('tool#name$')).toBe('toolName')
})
it('should trim whitespace', () => {
expect(toCamelCase(' server ')).toBe('server')
})
it('should handle empty string', () => {
expect(toCamelCase('')).toBe('')
})
it('should handle uppercase snake case', () => {
expect(toCamelCase('MY_SERVER')).toBe('myServer')
expect(toCamelCase('SEARCH_ISSUES')).toBe('searchIssues')
})
it('should handle mixed case', () => {
expect(toCamelCase('MyServer')).toBe('myserver')
expect(toCamelCase('myTOOL')).toBe('mytool')
})
})
describe('buildMcpToolName', () => {
it('should build basic name with defaults', () => {
expect(buildMcpToolName('github', 'search_issues')).toBe('github_searchIssues')
})
it('should handle undefined server name', () => {
expect(buildMcpToolName(undefined, 'search_issues')).toBe('searchIssues')
})
it('should apply custom prefix and delimiter', () => {
expect(buildMcpToolName('github', 'search', { prefix: 'mcp__', delimiter: '__' })).toBe('mcp__github__search')
})
it('should respect maxLength', () => {
const result = buildMcpToolName('veryLongServerName', 'veryLongToolName', { maxLength: 20 })
expect(result.length).toBeLessThanOrEqual(20)
})
it('should handle collision with existingNames', () => {
const existingNames = new Set(['github_search'])
const result = buildMcpToolName('github', 'search', { existingNames })
expect(result).toBe('github_search1')
expect(existingNames.has('github_search1')).toBe(true)
})
it('should respect maxLength when adding collision suffix', () => {
const existingNames = new Set(['a'.repeat(20)])
const result = buildMcpToolName('a'.repeat(20), '', { maxLength: 20, existingNames })
expect(result.length).toBeLessThanOrEqual(20)
expect(existingNames.has(result)).toBe(true)
})
it('should handle multiple collisions with maxLength', () => {
const existingNames = new Set(['abcd', 'abc1', 'abc2'])
const result = buildMcpToolName('abcd', '', { maxLength: 4, existingNames })
expect(result).toBe('abc3')
expect(result.length).toBeLessThanOrEqual(4)
})
})
describe('generateMcpToolFunctionName', () => {
it('should return format serverName_toolName in camelCase', () => {
expect(generateMcpToolFunctionName('github', 'search_issues')).toBe('github_searchIssues')
})
it('should handle hyphenated names', () => {
expect(generateMcpToolFunctionName('my-server', 'my-tool')).toBe('myServer_myTool')
})
it('should handle undefined server name', () => {
expect(generateMcpToolFunctionName(undefined, 'search_issues')).toBe('searchIssues')
})
it('should handle collision detection', () => {
const existingNames = new Set<string>()
const first = generateMcpToolFunctionName('github', 'search', existingNames)
const second = generateMcpToolFunctionName('github', 'search', existingNames)
expect(first).toBe('github_search')
expect(second).toBe('github_search1')
})
})
describe('buildFunctionCallToolName', () => {
describe('basic format', () => {
it('should return format mcp__{server}__{tool} in camelCase', () => {
const result = buildFunctionCallToolName('github', 'search_issues')
expect(result).toBe('mcp__github__searchIssues')
})
it('should handle simple server and tool names', () => {
expect(buildFunctionCallToolName('fetch', 'get_page')).toBe('mcp__fetch__getPage')
expect(buildFunctionCallToolName('database', 'query')).toBe('mcp__database__query')
})
})
describe('valid JavaScript identifier', () => {
it('should always start with mcp__ prefix (valid JS identifier start)', () => {
const result = buildFunctionCallToolName('123server', '456tool')
expect(result).toMatch(/^mcp__/)
})
it('should handle hyphenated names with camelCase', () => {
const result = buildFunctionCallToolName('my-server', 'my-tool')
expect(result).toBe('mcp__myServer__myTool')
})
it('should be a valid JavaScript identifier', () => {
const testCases = [
['github', 'create_issue'],
['my-server', 'fetch-data'],
['test@server', 'tool#name'],
['server.name', 'tool.action']
]
for (const [server, tool] of testCases) {
const result = buildFunctionCallToolName(server, tool)
expect(result).toMatch(/^[a-zA-Z_][a-zA-Z0-9_]*$/)
}
})
})
describe('character sanitization', () => {
it('should convert special characters to camelCase boundaries', () => {
expect(buildFunctionCallToolName('my-server', 'my-tool-name')).toBe('mcp__myServer__myToolName')
expect(buildFunctionCallToolName('test@server!', 'tool#name$')).toBe('mcp__testServer__toolName')
expect(buildFunctionCallToolName('server.name', 'tool.action')).toBe('mcp__serverName__toolAction')
})
it('should handle spaces', () => {
const result = buildFunctionCallToolName('my server', 'my tool')
expect(result).toBe('mcp__myServer__myTool')
})
})
describe('length constraints', () => {
it('should not exceed 63 characters', () => {
const longServerName = 'a'.repeat(50)
const longToolName = 'b'.repeat(50)
const result = buildFunctionCallToolName(longServerName, longToolName)
expect(result.length).toBeLessThanOrEqual(63)
})
it('should not end with underscores after truncation', () => {
const longServerName = 'a'.repeat(30)
const longToolName = 'b'.repeat(30)
const result = buildFunctionCallToolName(longServerName, longToolName)
expect(result).not.toMatch(/_+$/)
expect(result.length).toBeLessThanOrEqual(63)
})
})
describe('edge cases', () => {
it('should handle empty server name', () => {
const result = buildFunctionCallToolName('', 'tool')
expect(result).toBe('mcp__tool')
})
it('should handle empty tool name', () => {
const result = buildFunctionCallToolName('server', '')
expect(result).toBe('mcp__server__')
})
it('should trim whitespace from names', () => {
const result = buildFunctionCallToolName(' server ', ' tool ')
expect(result).toBe('mcp__server__tool')
})
it('should handle mixed case by normalizing to lowercase first', () => {
const result = buildFunctionCallToolName('MyServer', 'MyTool')
expect(result).toBe('mcp__myserver__mytool')
})
it('should handle uppercase snake case', () => {
const result = buildFunctionCallToolName('MY_SERVER', 'SEARCH_ISSUES')
expect(result).toBe('mcp__myServer__searchIssues')
})
})
describe('deterministic output', () => {
it('should produce consistent results for same input', () => {
const result1 = buildFunctionCallToolName('github', 'search_repos')
const result2 = buildFunctionCallToolName('github', 'search_repos')
expect(result1).toBe(result2)
})
it('should produce different results for different inputs', () => {
const result1 = buildFunctionCallToolName('server1', 'tool')
const result2 = buildFunctionCallToolName('server2', 'tool')
expect(result1).not.toBe(result2)
})
})
describe('real-world scenarios', () => {
it('should handle GitHub MCP server', () => {
expect(buildFunctionCallToolName('github', 'create_issue')).toBe('mcp__github__createIssue')
expect(buildFunctionCallToolName('github', 'search_repositories')).toBe('mcp__github__searchRepositories')
})
it('should handle filesystem MCP server', () => {
expect(buildFunctionCallToolName('filesystem', 'read_file')).toBe('mcp__filesystem__readFile')
expect(buildFunctionCallToolName('filesystem', 'write_file')).toBe('mcp__filesystem__writeFile')
})
it('should handle hyphenated server names (common in npm packages)', () => {
expect(buildFunctionCallToolName('cherry-fetch', 'get_page')).toBe('mcp__cherryFetch__getPage')
expect(buildFunctionCallToolName('mcp-server-github', 'search')).toBe('mcp__mcpServerGithub__search')
})
it('should handle scoped npm package style names', () => {
const result = buildFunctionCallToolName('@anthropic/mcp-server', 'chat')
expect(result).toBe('mcp__AnthropicMcpServer__chat')
})
})
})

View File

@ -1,6 +1,10 @@
import { loggerService } from '@logger'
import { MESSAGE_STREAM_TIMEOUT_MS } from '@main/apiServer/config/timeouts'
import { createStreamAbortController, STREAM_TIMEOUT_REASON } from '@main/apiServer/utils/createStreamAbortController'
import {
createStreamAbortController,
STREAM_TIMEOUT_REASON,
type StreamAbortController
} from '@main/apiServer/utils/createStreamAbortController'
import { agentService, sessionMessageService, sessionService } from '@main/services/agents'
import type { Request, Response } from 'express'
@ -26,7 +30,7 @@ const verifyAgentAndSession = async (agentId: string, sessionId: string) => {
}
export const createMessage = async (req: Request, res: Response): Promise<void> => {
let clearAbortTimeout: (() => void) | undefined
let streamController: StreamAbortController | undefined
try {
const { agentId, sessionId } = req.params
@ -45,14 +49,10 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Headers', 'Cache-Control')
const {
abortController,
registerAbortHandler,
clearAbortTimeout: helperClearAbortTimeout
} = createStreamAbortController({
streamController = createStreamAbortController({
timeoutMs: MESSAGE_STREAM_TIMEOUT_MS
})
clearAbortTimeout = helperClearAbortTimeout
const { abortController, registerAbortHandler, dispose } = streamController
const { stream, completion } = await sessionMessageService.createSessionMessage(
session,
messageData,
@ -64,8 +64,8 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
let responseEnded = false
let streamFinished = false
const cleanupAbortTimeout = () => {
clearAbortTimeout?.()
const cleanup = () => {
dispose()
}
const finalizeResponse = () => {
@ -78,7 +78,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
}
responseEnded = true
cleanupAbortTimeout()
cleanup()
try {
// res.write('data: {"type":"finish"}\n\n')
res.write('data: [DONE]\n\n')
@ -108,7 +108,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
* - Mark the response as ended to prevent further writes
*/
registerAbortHandler((abortReason) => {
cleanupAbortTimeout()
cleanup()
if (responseEnded) return
@ -189,7 +189,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
logger.error('Error writing stream error to SSE', { error: writeError })
}
responseEnded = true
cleanupAbortTimeout()
cleanup()
res.end()
}
}
@ -221,14 +221,14 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
logger.error('Error writing completion error to SSE stream', { error: writeError })
}
responseEnded = true
cleanupAbortTimeout()
cleanup()
res.end()
})
// Clear timeout when response ends
res.on('close', cleanupAbortTimeout)
res.on('finish', cleanupAbortTimeout)
res.on('close', cleanup)
res.on('finish', cleanup)
} catch (error: any) {
clearAbortTimeout?.()
streamController?.dispose()
logger.error('Error in streaming message handler', {
error,
agentId: req.params.agentId,

View File

@ -4,6 +4,7 @@ export interface StreamAbortController {
abortController: AbortController
registerAbortHandler: (handler: StreamAbortHandler) => void
clearAbortTimeout: () => void
dispose: () => void
}
export const STREAM_TIMEOUT_REASON = 'stream timeout'
@ -40,6 +41,15 @@ export const createStreamAbortController = (options: CreateStreamAbortController
signal.addEventListener('abort', handleAbort, { once: true })
let disposed = false
const dispose = () => {
if (disposed) return
disposed = true
clearAbortTimeout()
signal.removeEventListener('abort', handleAbort)
}
const registerAbortHandler = (handler: StreamAbortHandler) => {
abortHandler = handler
@ -59,6 +69,7 @@ export const createStreamAbortController = (options: CreateStreamAbortController
return {
abortController,
registerAbortHandler,
clearAbortTimeout
clearAbortTimeout,
dispose
}
}

View File

@ -1,5 +1,6 @@
import { dbService } from '@data/db/DbService'
import { loggerService } from '@logger'
import { isDev } from '@main/constant'
import { DefaultPreferences } from '@shared/data/preference/preferenceSchemas'
import type {
PreferenceDefaultScopeType,
@ -14,6 +15,50 @@ import { preferenceTable } from './db/schemas/preference'
const logger = loggerService.withContext('PreferenceService')
/**
* Preference statistics summary
*/
interface PreferenceStatsSummary {
/** Timestamp when statistics were collected */
collectedAt: number
/** Total number of preference keys */
totalKeys: number
/** Number of keys with main process subscriptions */
mainProcessSubscribedKeys: number
/** Total main process subscription count */
mainProcessTotalSubscriptions: number
/** Number of keys with window subscriptions */
windowSubscribedKeys: number
/** Total window subscription count (one window subscribing to one key counts as one) */
windowTotalSubscriptions: number
/** Number of active windows with subscriptions */
activeWindowCount: number
}
/**
* Statistics for a single preference key
*/
interface PreferenceKeyStats {
/** Preference key */
key: string
/** Main process subscription count */
mainProcessSubscriptions: number
/** Window subscription count */
windowSubscriptions: number
/** List of window IDs subscribed to this key */
subscribedWindowIds: number[]
}
/**
* Complete statistics result
*/
interface PreferenceStats {
/** Summary statistics */
summary: PreferenceStatsSummary
/** Detailed per-key statistics (only when details=true) */
details?: PreferenceKeyStats[]
}
/**
* Custom observer pattern implementation for preference change notifications
* Replaces EventEmitter to avoid listener limits and improve performance
@ -486,6 +531,113 @@ export class PreferenceService {
return this.notifier.getSubscriptionStats()
}
/**
* Get preference statistics
* @param details Whether to include per-key detailed statistics
* @returns Statistics object with summary and optional details
*/
public getStats(details: boolean = false): PreferenceStats {
if (!isDev) {
logger.warn('getStats() is resource-intensive and should be used in development environment only')
}
const summary = this.collectStatsSummary()
if (!details) {
return { summary }
}
return {
summary,
details: this.collectStatsDetails()
}
}
/**
* Collect statistics summary
*/
private collectStatsSummary(): PreferenceStatsSummary {
const mainProcessStats = this.notifier.getSubscriptionStats()
const mainProcessSubscribedKeys = Object.keys(mainProcessStats).length
const mainProcessTotalSubscriptions = this.notifier.getTotalSubscriptionCount()
const { windowSubscribedKeys, windowTotalSubscriptions, activeWindowCount } = this.collectWindowSubscriptionStats()
return {
collectedAt: Date.now(),
totalKeys: Object.keys(this.cache).length,
mainProcessSubscribedKeys,
mainProcessTotalSubscriptions,
windowSubscribedKeys,
windowTotalSubscriptions,
activeWindowCount
}
}
/**
* Collect window subscription statistics
*/
private collectWindowSubscriptionStats(): {
windowSubscribedKeys: number
windowTotalSubscriptions: number
activeWindowCount: number
} {
const keyToWindows = new Map<string, Set<number>>()
let totalSubscriptions = 0
for (const [windowId, keys] of this.subscriptions.entries()) {
for (const key of keys) {
if (!keyToWindows.has(key)) {
keyToWindows.set(key, new Set())
}
keyToWindows.get(key)!.add(windowId)
totalSubscriptions++
}
}
return {
windowSubscribedKeys: keyToWindows.size,
windowTotalSubscriptions: totalSubscriptions,
activeWindowCount: this.subscriptions.size
}
}
/**
* Collect per-key detailed statistics
*/
private collectStatsDetails(): PreferenceKeyStats[] {
const mainProcessStats = this.notifier.getSubscriptionStats()
const keyToWindowIds = new Map<string, number[]>()
for (const [windowId, keys] of this.subscriptions.entries()) {
for (const key of keys) {
if (!keyToWindowIds.has(key)) {
keyToWindowIds.set(key, [])
}
keyToWindowIds.get(key)!.push(windowId)
}
}
const allSubscribedKeys = new Set<string>([...Object.keys(mainProcessStats), ...keyToWindowIds.keys()])
const details: PreferenceKeyStats[] = []
for (const key of allSubscribedKeys) {
details.push({
key,
mainProcessSubscriptions: mainProcessStats[key] || 0,
windowSubscriptions: keyToWindowIds.get(key)?.length || 0,
subscribedWindowIds: keyToWindowIds.get(key) || []
})
}
details.sort(
(a, b) =>
b.mainProcessSubscriptions + b.windowSubscriptions - (a.mainProcessSubscriptions + a.windowSubscriptions)
)
return details
}
/**
* Unified notification method for both main and renderer processes
* Broadcasts preference changes to main process listeners and subscribed renderer windows

View File

@ -6,7 +6,7 @@ import { app } from 'electron'
import path from 'path'
import { pathToFileURL } from 'url'
import { CUSTOM_SQL_STATEMENTS } from './customSql'
import { CUSTOM_SQL_STATEMENTS } from './customSqls'
import Seeding from './seeding'
import type { DbType } from './types'

View File

@ -14,7 +14,7 @@
* 2. Import and spread them into CUSTOM_SQL_STATEMENTS below
*/
import { MESSAGE_FTS_STATEMENTS } from './schemas/messageFts'
import { MESSAGE_FTS_STATEMENTS } from './schemas/message'
/**
* All custom SQL statements to run after migrations

View File

@ -1,6 +1,6 @@
import { sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { createUpdateTimestamps } from './columnHelpers'
import { createUpdateTimestamps } from './_columnHelpers'
export const appStateTable = sqliteTable('app_state', {
key: text().primaryKey(),

View File

@ -1,6 +1,6 @@
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { createUpdateTimestamps, uuidPrimaryKey } from './columnHelpers'
import { createUpdateTimestamps, uuidPrimaryKey } from './_columnHelpers'
/**
* Group table - general-purpose grouping for entities

View File

@ -3,7 +3,7 @@ import type { AssistantMeta, ModelMeta } from '@shared/data/types/meta'
import { sql } from 'drizzle-orm'
import { check, foreignKey, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { createUpdateDeleteTimestamps, uuidPrimaryKeyOrdered } from './columnHelpers'
import { createUpdateDeleteTimestamps, uuidPrimaryKeyOrdered } from './_columnHelpers'
import { topicTable } from './topic'
/**
@ -63,3 +63,92 @@ export const messageTable = sqliteTable(
check('message_status_check', sql`${t.status} IN ('pending', 'success', 'error', 'paused')`)
]
)
/**
* FTS5 SQL statements for message full-text search
*
* This file contains SQL statements that must be manually added to migration files.
* Drizzle does not auto-generate virtual tables or triggers.
*
* Architecture:
* 1. message.searchable_text - regular column populated by trigger
* 2. message_fts - FTS5 virtual table with external content
* 3. Triggers sync both searchable_text and FTS5 index
*
* Usage:
* - Copy MESSAGE_FTS_MIGRATION_SQL to migration file when generating migrations
*/
/**
* Custom SQL statements that Drizzle cannot manage
* These are executed after every migration via DbService.runCustomMigrations()
*
* All statements should use IF NOT EXISTS to be idempotent.
*/
export const MESSAGE_FTS_STATEMENTS: string[] = [
// FTS5 virtual table, Links to message table's searchable_text column
`CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts5(
searchable_text,
content='message',
content_rowid='rowid',
tokenize='trigram'
)`,
// Trigger: populate searchable_text and sync FTS on INSERT
`CREATE TRIGGER IF NOT EXISTS message_ai AFTER INSERT ON message BEGIN
UPDATE message SET searchable_text = (
SELECT group_concat(json_extract(value, '$.content'), ' ')
FROM json_each(json_extract(NEW.data, '$.blocks'))
WHERE json_extract(value, '$.type') = 'main_text'
) WHERE id = NEW.id;
INSERT INTO message_fts(rowid, searchable_text)
SELECT rowid, searchable_text FROM message WHERE id = NEW.id;
END`,
// Trigger: sync FTS on DELETE
`CREATE TRIGGER IF NOT EXISTS message_ad AFTER DELETE ON message BEGIN
INSERT INTO message_fts(message_fts, rowid, searchable_text)
VALUES ('delete', OLD.rowid, OLD.searchable_text);
END`,
// Trigger: update searchable_text and sync FTS on UPDATE OF data
`CREATE TRIGGER IF NOT EXISTS message_au AFTER UPDATE OF data ON message BEGIN
INSERT INTO message_fts(message_fts, rowid, searchable_text)
VALUES ('delete', OLD.rowid, OLD.searchable_text);
UPDATE message SET searchable_text = (
SELECT group_concat(json_extract(value, '$.content'), ' ')
FROM json_each(json_extract(NEW.data, '$.blocks'))
WHERE json_extract(value, '$.type') = 'main_text'
) WHERE id = NEW.id;
INSERT INTO message_fts(rowid, searchable_text)
SELECT rowid, searchable_text FROM message WHERE id = NEW.id;
END`
]
/** Examples */
/**
* SQL expression to extract searchable text from data.blocks
* Concatenates content from all main_text type blocks
*/
// export const SEARCHABLE_TEXT_EXPRESSION = `
// (SELECT group_concat(json_extract(value, '$.content'), ' ')
// FROM json_each(json_extract(NEW.data, '$.blocks'))
// WHERE json_extract(value, '$.type') = 'main_text')
// `
/**
* Rebuild FTS index (run manually if needed)
*/
// export const REBUILD_FTS_SQL = `INSERT INTO message_fts(message_fts) VALUES ('rebuild')`
/**
* Example search query
*/
// export const EXAMPLE_SEARCH_SQL = `
// SELECT m.*
// FROM message m
// JOIN message_fts fts ON m.rowid = fts.rowid
// WHERE message_fts MATCH ?
// ORDER BY rank
// `

View File

@ -1,86 +0,0 @@
/**
* FTS5 SQL statements for message full-text search
*
* This file contains SQL statements that must be manually added to migration files.
* Drizzle does not auto-generate virtual tables or triggers.
*
* Architecture:
* 1. message.searchable_text - regular column populated by trigger
* 2. message_fts - FTS5 virtual table with external content
* 3. Triggers sync both searchable_text and FTS5 index
*
* Usage:
* - Copy MESSAGE_FTS_MIGRATION_SQL to migration file when generating migrations
*/
/**
* SQL expression to extract searchable text from data.blocks
* Concatenates content from all main_text type blocks
*/
export const SEARCHABLE_TEXT_EXPRESSION = `
(SELECT group_concat(json_extract(value, '$.content'), ' ')
FROM json_each(json_extract(NEW.data, '$.blocks'))
WHERE json_extract(value, '$.type') = 'main_text')
`
/**
* Custom SQL statements that Drizzle cannot manage
* These are executed after every migration via DbService.runCustomMigrations()
*
* All statements should use IF NOT EXISTS to be idempotent.
*/
export const MESSAGE_FTS_STATEMENTS: string[] = [
// FTS5 virtual table, Links to message table's searchable_text column
`CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts5(
searchable_text,
content='message',
content_rowid='rowid',
tokenize='trigram'
)`,
// Trigger: populate searchable_text and sync FTS on INSERT
`CREATE TRIGGER IF NOT EXISTS message_ai AFTER INSERT ON message BEGIN
UPDATE message SET searchable_text = (
SELECT group_concat(json_extract(value, '$.content'), ' ')
FROM json_each(json_extract(NEW.data, '$.blocks'))
WHERE json_extract(value, '$.type') = 'main_text'
) WHERE id = NEW.id;
INSERT INTO message_fts(rowid, searchable_text)
SELECT rowid, searchable_text FROM message WHERE id = NEW.id;
END`,
// Trigger: sync FTS on DELETE
`CREATE TRIGGER IF NOT EXISTS message_ad AFTER DELETE ON message BEGIN
INSERT INTO message_fts(message_fts, rowid, searchable_text)
VALUES ('delete', OLD.rowid, OLD.searchable_text);
END`,
// Trigger: update searchable_text and sync FTS on UPDATE OF data
`CREATE TRIGGER IF NOT EXISTS message_au AFTER UPDATE OF data ON message BEGIN
INSERT INTO message_fts(message_fts, rowid, searchable_text)
VALUES ('delete', OLD.rowid, OLD.searchable_text);
UPDATE message SET searchable_text = (
SELECT group_concat(json_extract(value, '$.content'), ' ')
FROM json_each(json_extract(NEW.data, '$.blocks'))
WHERE json_extract(value, '$.type') = 'main_text'
) WHERE id = NEW.id;
INSERT INTO message_fts(rowid, searchable_text)
SELECT rowid, searchable_text FROM message WHERE id = NEW.id;
END`
]
/**
* Rebuild FTS index (run manually if needed)
*/
export const REBUILD_FTS_SQL = `INSERT INTO message_fts(message_fts) VALUES ('rebuild')`
/**
* Example search query
*/
export const EXAMPLE_SEARCH_SQL = `
SELECT m.*
FROM message m
JOIN message_fts fts ON m.rowid = fts.rowid
WHERE message_fts MATCH ?
ORDER BY rank
`

View File

@ -1,6 +1,6 @@
import { primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { createUpdateTimestamps } from './columnHelpers'
import { createUpdateTimestamps } from './_columnHelpers'
export const preferenceTable = sqliteTable(
'preference',

View File

@ -1,18 +0,0 @@
import { sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { createUpdateTimestamps, uuidPrimaryKey } from './columnHelpers'
/**
* Tag table - general-purpose tags for entities
*
* Tags can be applied to topics, sessions, and assistants
* via the entity_tag join table.
*/
export const tagTable = sqliteTable('tag', {
id: uuidPrimaryKey(),
// Unique tag name
name: text().notNull().unique(),
// Display color (hex code)
color: text(),
...createUpdateTimestamps
})

View File

@ -1,7 +1,21 @@
import { index, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { createUpdateTimestamps } from './columnHelpers'
import { tagTable } from './tag'
import { createUpdateTimestamps, uuidPrimaryKey } from './_columnHelpers'
/**
* Tag table - general-purpose tags for entities
*
* Tags can be applied to topics, sessions, and assistants
* via the entity_tag join table.
*/
export const tagTable = sqliteTable('tag', {
id: uuidPrimaryKey(),
// Unique tag name
name: text().notNull().unique(),
// Display color (hex code)
color: text(),
...createUpdateTimestamps
})
/**
* Entity-Tag join table - associates tags with entities

View File

@ -1,7 +1,7 @@
import type { AssistantMeta } from '@shared/data/types/meta'
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { createUpdateDeleteTimestamps, uuidPrimaryKey } from './columnHelpers'
import { createUpdateDeleteTimestamps, uuidPrimaryKey } from './_columnHelpers'
import { groupTable } from './group'
/**

View File

@ -87,6 +87,15 @@ if (isLinux && process.env.XDG_SESSION_TYPE === 'wayland') {
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal')
}
/**
* Set window class and name for X11
* This ensures the system tray and window manager identify the app correctly
*/
if (isLinux) {
app.commandLine.appendSwitch('class', 'cherry-studio')
app.commandLine.appendSwitch('name', 'cherry-studio')
}
// DocumentPolicyIncludeJSCallStacksInCrashReports: Enable features for unresponsive renderer js call stacks
// EarlyEstablishGpuChannel,EstablishGpuChannelAsync: Enable features for early establish gpu channel
// speed up the startup time

View File

@ -9,6 +9,7 @@ import DiDiMcpServer from './didi-mcp'
import DifyKnowledgeServer from './dify-knowledge'
import FetchServer from './fetch'
import FileSystemServer from './filesystem'
import HubServer from './hub'
import MemoryServer from './memory'
import PythonServer from './python'
import ThinkingServer from './sequentialthinking'
@ -52,6 +53,9 @@ export function createInMemoryMCPServer(
case BuiltinMCPServerNames.browser: {
return new BrowserServer().server
}
case BuiltinMCPServerNames.hub: {
return new HubServer().server
}
default:
throw new Error(`Unknown in-memory MCP server: ${name}`)
}

View File

@ -0,0 +1,213 @@
# Hub MCP Server
A built-in MCP server that aggregates all active MCP servers in Cherry Studio and exposes them through `search` and `exec` tools.
## Overview
The Hub server enables LLMs to discover and call tools from all active MCP servers without needing to know the specific server names or tool signatures upfront.
## Auto Mode Integration
The Hub server is the core component of Cherry Studio's **Auto MCP Mode**. When an assistant is set to Auto mode:
1. **Automatic Injection**: The Hub server is automatically injected as the only MCP server for the assistant
2. **System Prompt**: A specialized system prompt (`HUB_MODE_SYSTEM_PROMPT`) is appended to guide the LLM on how to use the `search` and `exec` tools
3. **Dynamic Discovery**: The LLM can discover and use any tools from all active MCP servers without manual configuration
### MCP Modes
Cherry Studio supports three MCP modes per assistant:
| Mode | Description | Tools Available |
|------|-------------|-----------------|
| **Disabled** | No MCP tools | None |
| **Auto** | Hub server only | `search`, `exec` |
| **Manual** | User selects servers | Selected server tools |
### How Auto Mode Works
```
User Message
┌─────────────────────────────────────────┐
│ Assistant (mcpMode: 'auto') │
│ │
│ System Prompt + HUB_MODE_SYSTEM_PROMPT │
│ Tools: [hub.search, hub.exec] │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ LLM decides to use MCP tools │
│ │
│ 1. search({ query: "github,repo" }) │
│ 2. exec({ code: "await searchRepos()" })│
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Hub Server │
│ │
│ Aggregates all active MCP servers │
│ Routes tool calls to appropriate server │
└─────────────────────────────────────────┘
```
### Relevant Code
- **Type Definition**: `src/renderer/src/types/index.ts` - `McpMode` type and `getEffectiveMcpMode()`
- **Hub Server Constant**: `src/renderer/src/store/mcp.ts` - `hubMCPServer`
- **Server Selection**: `src/renderer/src/services/ApiService.ts` - `getMcpServersForAssistant()`
- **System Prompt**: `src/renderer/src/config/prompts.ts` - `HUB_MODE_SYSTEM_PROMPT`
- **Prompt Injection**: `src/renderer/src/aiCore/prepareParams/parameterBuilder.ts`
## Tools
### `search`
Search for available MCP tools by keywords.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `query` | string | Yes | Search keywords, comma-separated for OR matching |
| `limit` | number | No | Maximum results to return (default: 10, max: 50) |
**Example:**
```json
{
"query": "browser,chrome",
"limit": 5
}
```
**Returns:** JavaScript function declarations with JSDoc comments that can be used in the `exec` tool.
```javascript
// Found 2 tool(s):
/**
* Launch a browser instance
*
* @param {{ browser?: "chromium" | "firefox" | "webkit", headless?: boolean }} params
* @returns {Promise<unknown>}
*/
async function launchBrowser(params) {
return await __callTool("browser__launch_browser", params);
}
```
### `exec`
Execute JavaScript code that calls MCP tools.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `code` | string | Yes | JavaScript code to execute |
**Built-in Helpers:**
- `parallel(...promises)` - Run multiple tool calls concurrently (Promise.all)
- `settle(...promises)` - Run multiple tool calls and get all results (Promise.allSettled)
- `console.log/warn/error/info/debug` - Captured in output logs
**Example:**
```javascript
// Call a single tool
const result = await searchRepos({ query: "react" });
return result;
// Call multiple tools in parallel
const [users, repos] = await parallel(
getUsers({ limit: 10 }),
searchRepos({ query: "typescript" })
);
return { users, repos };
```
**Returns:**
```json
{
"result": { "users": [...], "repos": [...] },
"logs": ["[log] Processing..."],
"error": null
}
```
## Usage Flow
1. **Search** for tools using keywords:
```
search({ query: "github,repository" })
```
2. **Review** the returned function signatures and JSDoc
3. **Execute** code using the discovered tools:
```
exec({ code: 'return await searchRepos({ query: "react" })' })
```
## Configuration
The Hub server is a built-in server identified as `@cherry/hub`.
### Using Auto Mode (Recommended)
The easiest way to use the Hub server is through Auto mode:
1. Click the **MCP Tools** button (hammer icon) in the input bar
2. Select **Auto** mode
3. The Hub server is automatically enabled for the assistant
### Manual Configuration
Alternatively, you can enable the Hub server manually:
1. Go to **Settings** → **MCP Servers**
2. Find **Hub** in the built-in servers list
3. Toggle it on
4. In the assistant's MCP settings, select the Hub server
## Caching
- Tool definitions are cached for **10 minutes**
- Cache is automatically refreshed when expired
- Cache is invalidated when MCP servers connect/disconnect
## Limitations
- Code execution has a **60-second timeout**
- Console logs are limited to **1000 entries**
- Search results are limited to **50 tools** maximum
- The Hub server excludes itself from the aggregated server list
## Architecture
```
LLM
HubServer
├── search → ToolRegistry → SearchIndex
└── exec → Runtime → callMcpTool()
MCPService.callTool()
External MCP Servers
```
## Files
| File | Description |
|------|-------------|
| `index.ts` | Main HubServer class |
| `types.ts` | TypeScript type definitions |
| `generator.ts` | Converts MCP tools to JS functions with JSDoc |
| `tool-registry.ts` | In-memory tool cache with TTL |
| `search.ts` | Keyword-based tool search |
| `runtime.ts` | JavaScript code execution engine |
| `mcp-bridge.ts` | Bridge to Cherry Studio's MCPService |

View File

@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import { generateToolFunction, generateToolsCode } from '../generator'
import type { GeneratedTool } from '../types'
describe('generator', () => {
describe('generateToolFunction', () => {
it('generates a simple tool function', () => {
const tool = {
id: 'test-id',
name: 'search_repos',
description: 'Search for GitHub repositories',
serverId: 'github',
serverName: 'github-server',
inputSchema: {
type: 'object' as const,
properties: {
query: { type: 'string', description: 'Search query' },
limit: { type: 'number', description: 'Max results' }
},
required: ['query']
},
type: 'mcp' as const
}
const existingNames = new Set<string>()
const callTool = async () => ({ success: true })
const result = generateToolFunction(tool, existingNames, callTool)
expect(result.functionName).toBe('githubServer_searchRepos')
expect(result.jsCode).toContain('async function githubServer_searchRepos')
expect(result.jsCode).toContain('Search for GitHub repositories')
expect(result.jsCode).toContain('__callTool')
})
it('handles unique function names', () => {
const tool = {
id: 'test-id',
name: 'search',
serverId: 'server1',
serverName: 'server1',
inputSchema: { type: 'object' as const, properties: {} },
type: 'mcp' as const
}
const existingNames = new Set<string>(['server1_search'])
const callTool = async () => ({})
const result = generateToolFunction(tool, existingNames, callTool)
expect(result.functionName).toBe('server1_search1')
})
it('handles enum types in schema', () => {
const tool = {
id: 'test-id',
name: 'launch_browser',
serverId: 'browser',
serverName: 'browser',
inputSchema: {
type: 'object' as const,
properties: {
browser: {
type: 'string',
enum: ['chromium', 'firefox', 'webkit']
}
}
},
type: 'mcp' as const
}
const existingNames = new Set<string>()
const callTool = async () => ({})
const result = generateToolFunction(tool, existingNames, callTool)
expect(result.jsCode).toContain('"chromium" | "firefox" | "webkit"')
})
})
describe('generateToolsCode', () => {
it('generates code for multiple tools', () => {
const tools: GeneratedTool[] = [
{
serverId: 's1',
serverName: 'server1',
toolName: 'tool1',
functionName: 'server1_tool1',
jsCode: 'async function server1_tool1() {}',
fn: async () => ({}),
signature: '{}',
returns: 'unknown'
},
{
serverId: 's2',
serverName: 'server2',
toolName: 'tool2',
functionName: 'server2_tool2',
jsCode: 'async function server2_tool2() {}',
fn: async () => ({}),
signature: '{}',
returns: 'unknown'
}
]
const result = generateToolsCode(tools)
expect(result).toContain('2 tool(s)')
expect(result).toContain('async function server1_tool1')
expect(result).toContain('async function server2_tool2')
})
it('returns message for empty tools', () => {
const result = generateToolsCode([])
expect(result).toBe('// No tools available')
})
})
})

View File

@ -0,0 +1,229 @@
import type { MCPTool } from '@types'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HubServer } from '../index'
const mockTools: MCPTool[] = [
{
id: 'github__search_repos',
name: 'search_repos',
description: 'Search for GitHub repositories',
serverId: 'github',
serverName: 'GitHub',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
limit: { type: 'number', description: 'Max results' }
},
required: ['query']
},
type: 'mcp'
},
{
id: 'github__get_user',
name: 'get_user',
description: 'Get GitHub user profile',
serverId: 'github',
serverName: 'GitHub',
inputSchema: {
type: 'object',
properties: {
username: { type: 'string', description: 'GitHub username' }
},
required: ['username']
},
type: 'mcp'
},
{
id: 'database__query',
name: 'query',
description: 'Execute a database query',
serverId: 'database',
serverName: 'Database',
inputSchema: {
type: 'object',
properties: {
sql: { type: 'string', description: 'SQL query to execute' }
},
required: ['sql']
},
type: 'mcp'
}
]
vi.mock('@main/services/MCPService', () => ({
default: {
listAllActiveServerTools: vi.fn(async () => mockTools),
callToolById: vi.fn(async (toolId: string, args: unknown) => {
if (toolId === 'github__search_repos') {
return {
content: [{ type: 'text', text: JSON.stringify({ repos: ['repo1', 'repo2'], query: args }) }]
}
}
if (toolId === 'github__get_user') {
return {
content: [{ type: 'text', text: JSON.stringify({ username: (args as any).username, id: 123 }) }]
}
}
if (toolId === 'database__query') {
return {
content: [{ type: 'text', text: JSON.stringify({ rows: [{ id: 1 }, { id: 2 }] }) }]
}
}
return { content: [{ type: 'text', text: '{}' }] }
}),
abortTool: vi.fn(async () => true)
}
}))
import mcpService from '@main/services/MCPService'
describe('HubServer Integration', () => {
let hubServer: HubServer
beforeEach(() => {
vi.clearAllMocks()
hubServer = new HubServer()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('full search → exec flow', () => {
it('searches for tools and executes them', async () => {
const searchResult = await (hubServer as any).handleSearch({ query: 'github,repos' })
expect(searchResult.content).toBeDefined()
const searchText = JSON.parse(searchResult.content[0].text)
expect(searchText.total).toBeGreaterThan(0)
expect(searchText.tools).toContain('github_searchRepos')
const execResult = await (hubServer as any).handleExec({
code: 'return await github_searchRepos({ query: "test" })'
})
expect(execResult.content).toBeDefined()
const execOutput = JSON.parse(execResult.content[0].text)
expect(execOutput.result).toEqual({ repos: ['repo1', 'repo2'], query: { query: 'test' } })
})
it('handles multiple tool calls in parallel', async () => {
await (hubServer as any).handleSearch({ query: 'github' })
const execResult = await (hubServer as any).handleExec({
code: `
const results = await parallel(
github_searchRepos({ query: "react" }),
github_getUser({ username: "octocat" })
);
return results
`
})
const execOutput = JSON.parse(execResult.content[0].text)
expect(execOutput.result).toHaveLength(2)
expect(execOutput.result[0]).toEqual({ repos: ['repo1', 'repo2'], query: { query: 'react' } })
expect(execOutput.result[1]).toEqual({ username: 'octocat', id: 123 })
})
it('searches across multiple servers', async () => {
const searchResult = await (hubServer as any).handleSearch({ query: 'query' })
const searchText = JSON.parse(searchResult.content[0].text)
expect(searchText.tools).toContain('database_query')
})
})
describe('tools caching', () => {
it('uses cached tools within TTL', async () => {
await (hubServer as any).handleSearch({ query: 'github' })
const firstCallCount = vi.mocked(mcpService.listAllActiveServerTools).mock.calls.length
await (hubServer as any).handleSearch({ query: 'github' })
const secondCallCount = vi.mocked(mcpService.listAllActiveServerTools).mock.calls.length
expect(secondCallCount).toBe(firstCallCount) // Should use cache
})
it('refreshes tools after cache invalidation', async () => {
await (hubServer as any).handleSearch({ query: 'github' })
const firstCallCount = vi.mocked(mcpService.listAllActiveServerTools).mock.calls.length
hubServer.invalidateCache()
await (hubServer as any).handleSearch({ query: 'github' })
const secondCallCount = vi.mocked(mcpService.listAllActiveServerTools).mock.calls.length
expect(secondCallCount).toBe(firstCallCount + 1)
})
})
describe('error handling', () => {
it('throws error for invalid search query', async () => {
await expect((hubServer as any).handleSearch({})).rejects.toThrow('query parameter is required')
})
it('throws error for invalid exec code', async () => {
await expect((hubServer as any).handleExec({})).rejects.toThrow('code parameter is required')
})
it('handles runtime errors in exec', async () => {
const execResult = await (hubServer as any).handleExec({
code: 'throw new Error("test error")'
})
const execOutput = JSON.parse(execResult.content[0].text)
expect(execOutput.error).toBe('test error')
expect(execOutput.isError).toBe(true)
})
})
describe('exec timeouts', () => {
afterEach(() => {
vi.useRealTimers()
})
it('aborts in-flight tool calls and returns logs on timeout', async () => {
vi.useFakeTimers()
let toolCallStarted: (() => void) | null = null
const toolCallStartedPromise = new Promise<void>((resolve) => {
toolCallStarted = resolve
})
vi.mocked(mcpService.callToolById).mockImplementationOnce(async () => {
toolCallStarted?.()
return await new Promise(() => {})
})
const execPromise = (hubServer as any).handleExec({
code: `
console.log("starting");
return await github_searchRepos({ query: "hang" });
`
})
await toolCallStartedPromise
await vi.advanceTimersByTimeAsync(60000)
await vi.runAllTimersAsync()
const execResult = await execPromise
const execOutput = JSON.parse(execResult.content[0].text)
expect(execOutput.error).toBe('Execution timed out after 60000ms')
expect(execOutput.result).toBeUndefined()
expect(execOutput.isError).toBe(true)
expect(execOutput.logs).toContain('[log] starting')
expect(vi.mocked(mcpService.abortTool)).toHaveBeenCalled()
})
})
describe('server instance', () => {
it('creates a valid MCP server instance', () => {
expect(hubServer.server).toBeDefined()
expect(hubServer.server.setRequestHandler).toBeDefined()
})
})
})

View File

@ -0,0 +1,159 @@
import { describe, expect, it, vi } from 'vitest'
import { Runtime } from '../runtime'
import type { GeneratedTool } from '../types'
vi.mock('../mcp-bridge', () => ({
callMcpTool: vi.fn(async (toolId: string, params: unknown) => {
if (toolId === 'server__failing_tool') {
throw new Error('Tool failed')
}
return { toolId, params, success: true }
})
}))
const createMockTool = (partial: Partial<GeneratedTool>): GeneratedTool => ({
serverId: 'server1',
serverName: 'server1',
toolName: 'tool',
functionName: 'server1_mockTool',
jsCode: 'async function server1_mockTool() {}',
fn: async (params) => ({ result: params }),
signature: '{}',
returns: 'unknown',
...partial
})
describe('Runtime', () => {
describe('execute', () => {
it('executes simple code and returns result', async () => {
const runtime = new Runtime()
const tools: GeneratedTool[] = []
const result = await runtime.execute('return 1 + 1', tools)
expect(result.result).toBe(2)
expect(result.error).toBeUndefined()
})
it('executes async code', async () => {
const runtime = new Runtime()
const tools: GeneratedTool[] = []
const result = await runtime.execute('return await Promise.resolve(42)', tools)
expect(result.result).toBe(42)
})
it('calls tool functions', async () => {
const runtime = new Runtime()
const tools = [
createMockTool({
functionName: 'searchRepos',
fn: async (params) => ({ repos: ['repo1', 'repo2'], query: params })
})
]
const result = await runtime.execute('return await searchRepos({ query: "test" })', tools)
expect(result.result).toEqual({ toolId: 'searchRepos', params: { query: 'test' }, success: true })
})
it('captures console logs', async () => {
const runtime = new Runtime()
const tools: GeneratedTool[] = []
const result = await runtime.execute(
`
console.log("hello");
console.warn("warning");
return "done"
`,
tools
)
expect(result.result).toBe('done')
expect(result.logs).toContain('[log] hello')
expect(result.logs).toContain('[warn] warning')
})
it('handles errors gracefully', async () => {
const runtime = new Runtime()
const tools: GeneratedTool[] = []
const result = await runtime.execute('throw new Error("test error")', tools)
expect(result.result).toBeUndefined()
expect(result.error).toBe('test error')
expect(result.isError).toBe(true)
})
it('supports parallel helper', async () => {
const runtime = new Runtime()
const tools: GeneratedTool[] = []
const result = await runtime.execute(
`
const results = await parallel(
Promise.resolve(1),
Promise.resolve(2),
Promise.resolve(3)
);
return results
`,
tools
)
expect(result.result).toEqual([1, 2, 3])
})
it('supports settle helper', async () => {
const runtime = new Runtime()
const tools: GeneratedTool[] = []
const result = await runtime.execute(
`
const results = await settle(
Promise.resolve(1),
Promise.reject(new Error("fail"))
);
return results.map(r => r.status)
`,
tools
)
expect(result.result).toEqual(['fulfilled', 'rejected'])
})
it('returns last expression when no explicit return', async () => {
const runtime = new Runtime()
const tools: GeneratedTool[] = []
const result = await runtime.execute(
`
const x = 10;
const y = 20;
return x + y
`,
tools
)
expect(result.result).toBe(30)
})
it('stops execution when a tool throws', async () => {
const runtime = new Runtime()
const tools = [
createMockTool({
functionName: 'server__failing_tool'
})
]
const result = await runtime.execute('return await server__failing_tool({})', tools)
expect(result.result).toBeUndefined()
expect(result.error).toBe('Tool failed')
expect(result.isError).toBe(true)
})
})
})

View File

@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest'
import { searchTools } from '../search'
import type { GeneratedTool } from '../types'
const createMockTool = (partial: Partial<GeneratedTool>): GeneratedTool => {
const functionName = partial.functionName || 'server1_tool'
return {
serverId: 'server1',
serverName: 'server1',
toolName: partial.toolName || 'tool',
functionName,
jsCode: `async function ${functionName}() {}`,
fn: async () => ({}),
signature: '{}',
returns: 'unknown',
...partial
}
}
describe('search', () => {
describe('searchTools', () => {
it('returns all tools when query is empty', () => {
const tools = [
createMockTool({ toolName: 'tool1', functionName: 'tool1' }),
createMockTool({ toolName: 'tool2', functionName: 'tool2' })
]
const result = searchTools(tools, { query: '' })
expect(result.total).toBe(2)
expect(result.tools).toContain('tool1')
expect(result.tools).toContain('tool2')
})
it('filters tools by single keyword', () => {
const tools = [
createMockTool({ toolName: 'search_repos', functionName: 'searchRepos' }),
createMockTool({ toolName: 'get_user', functionName: 'getUser' }),
createMockTool({ toolName: 'search_users', functionName: 'searchUsers' })
]
const result = searchTools(tools, { query: 'search' })
expect(result.total).toBe(2)
expect(result.tools).toContain('searchRepos')
expect(result.tools).toContain('searchUsers')
expect(result.tools).not.toContain('getUser')
})
it('supports OR matching with comma-separated keywords', () => {
const tools = [
createMockTool({ toolName: 'browser_open', functionName: 'browserOpen' }),
createMockTool({ toolName: 'chrome_launch', functionName: 'chromeLaunch' }),
createMockTool({ toolName: 'file_read', functionName: 'fileRead' })
]
const result = searchTools(tools, { query: 'browser,chrome' })
expect(result.total).toBe(2)
expect(result.tools).toContain('browserOpen')
expect(result.tools).toContain('chromeLaunch')
expect(result.tools).not.toContain('fileRead')
})
it('matches against description', () => {
const tools = [
createMockTool({
toolName: 'launch',
functionName: 'launch',
description: 'Launch a browser instance'
}),
createMockTool({
toolName: 'close',
functionName: 'close',
description: 'Close a window'
})
]
const result = searchTools(tools, { query: 'browser' })
expect(result.total).toBe(1)
expect(result.tools).toContain('launch')
})
it('respects limit parameter', () => {
const tools = Array.from({ length: 20 }, (_, i) =>
createMockTool({ toolName: `tool${i}`, functionName: `server1_tool${i}` })
)
const result = searchTools(tools, { query: 'tool', limit: 5 })
expect(result.total).toBe(20)
const matches = (result.tools.match(/async function server1_tool\d+/g) || []).length
expect(matches).toBe(5)
})
it('is case insensitive', () => {
const tools = [createMockTool({ toolName: 'SearchRepos', functionName: 'searchRepos' })]
const result = searchTools(tools, { query: 'SEARCH' })
expect(result.total).toBe(1)
})
it('ranks exact matches higher', () => {
const tools = [
createMockTool({ toolName: 'searching', functionName: 'searching' }),
createMockTool({ toolName: 'search', functionName: 'search' }),
createMockTool({ toolName: 'search_more', functionName: 'searchMore' })
]
const result = searchTools(tools, { query: 'search', limit: 1 })
expect(result.tools).toContain('function search(')
})
})
})

View File

@ -0,0 +1,152 @@
import { generateMcpToolFunctionName } from '@shared/mcp'
import type { MCPTool } from '@types'
import type { GeneratedTool } from './types'
type PropertySchema = Record<string, unknown>
type InputSchema = {
type?: string
properties?: Record<string, PropertySchema>
required?: string[]
}
function schemaTypeToTS(prop: Record<string, unknown>): string {
const type = prop.type as string | string[] | undefined
const enumValues = prop.enum as unknown[] | undefined
if (enumValues && Array.isArray(enumValues)) {
return enumValues.map((v) => (typeof v === 'string' ? `"${v}"` : String(v))).join(' | ')
}
if (Array.isArray(type)) {
return type.map((t) => primitiveTypeToTS(t)).join(' | ')
}
if (type === 'array') {
const items = prop.items as Record<string, unknown> | undefined
if (items) {
return `${schemaTypeToTS(items)}[]`
}
return 'unknown[]'
}
if (type === 'object') {
return 'object'
}
return primitiveTypeToTS(type)
}
function primitiveTypeToTS(type: string | undefined): string {
switch (type) {
case 'string':
return 'string'
case 'number':
case 'integer':
return 'number'
case 'boolean':
return 'boolean'
case 'null':
return 'null'
default:
return 'unknown'
}
}
function jsonSchemaToSignature(schema: Record<string, unknown> | undefined): string {
if (!schema || typeof schema !== 'object') {
return '{}'
}
const properties = schema.properties as Record<string, Record<string, unknown>> | undefined
if (!properties) {
return '{}'
}
const required = (schema.required as string[]) || []
const parts: string[] = []
for (const [key, prop] of Object.entries(properties)) {
const isRequired = required.includes(key)
const typeStr = schemaTypeToTS(prop)
parts.push(`${key}${isRequired ? '' : '?'}: ${typeStr}`)
}
return `{ ${parts.join(', ')} }`
}
function generateJSDoc(tool: MCPTool, inputSchema: InputSchema | undefined, returns: string): string {
const lines: string[] = ['/**']
if (tool.description) {
const desc = tool.description.split('\n')[0]
lines.push(` * ${desc}`)
}
const properties = inputSchema?.properties || {}
const required = inputSchema?.required || []
if (Object.keys(properties).length > 0) {
lines.push(` * @param {Object} params`)
for (const [name, prop] of Object.entries(properties)) {
const isReq = required.includes(name)
const type = schemaTypeToTS(prop)
const paramName = isReq ? `params.${name}` : `[params.${name}]`
const desc = (prop.description as string)?.split('\n')[0] || ''
lines.push(` * @param {${type}} ${paramName} ${desc}`)
}
}
lines.push(` * @returns {Promise<${returns}>}`)
lines.push(` */`)
return lines.join('\n')
}
export function generateToolFunction(
tool: MCPTool,
existingNames: Set<string>,
callToolFn: (functionName: string, params: unknown) => Promise<unknown>
): GeneratedTool {
const functionName = generateMcpToolFunctionName(tool.serverName, tool.name, existingNames)
const inputSchema = tool.inputSchema as InputSchema | undefined
const outputSchema = tool.outputSchema as Record<string, unknown> | undefined
const signature = jsonSchemaToSignature(inputSchema)
const returns = outputSchema ? jsonSchemaToSignature(outputSchema) : 'unknown'
const jsDoc = generateJSDoc(tool, inputSchema, returns)
const jsCode = `${jsDoc}
async function ${functionName}(params) {
return await __callTool("${functionName}", params);
}`
const fn = async (params: unknown): Promise<unknown> => {
return await callToolFn(functionName, params)
}
return {
serverId: tool.serverId,
serverName: tool.serverName,
toolName: tool.name,
functionName,
jsCode,
fn,
signature,
returns,
description: tool.description
}
}
export function generateToolsCode(tools: GeneratedTool[]): string {
if (tools.length === 0) {
return '// No tools available'
}
const header = `// ${tools.length} tool(s). ALWAYS use: const r = await ToolName({...}); return r;`
const code = tools.map((t) => t.jsCode).join('\n\n')
return header + '\n\n' + code
}

View File

@ -0,0 +1,184 @@
import { cacheService } from '@data/CacheService'
import { loggerService } from '@logger'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js'
import { generateToolFunction } from './generator'
import { callMcpTool, clearToolMap, listAllTools, syncToolMapFromGeneratedTools } from './mcp-bridge'
import { Runtime } from './runtime'
import { searchTools } from './search'
import type { ExecInput, GeneratedTool, SearchQuery } from './types'
const logger = loggerService.withContext('MCPServer:Hub')
const TOOLS_CACHE_KEY = 'hub:tools'
const TOOLS_CACHE_TTL = 60 * 1000 // 1 minute
/**
* Hub MCP Server - A meta-server that aggregates all active MCP servers.
*
* This server is NOT included in builtinMCPServers because:
* 1. It aggregates tools from all other MCP servers, not a standalone tool provider
* 2. It's designed for LLM "code mode" - enabling AI to discover and call tools programmatically
* 3. It should be auto-enabled when code mode features are used, not manually installed by users
*
* The server exposes two tools:
* - `search`: Find available tools by keywords, returns JS function signatures
* - `exec`: Execute JavaScript code that calls discovered tools
*/
export class HubServer {
public server: Server
private runtime: Runtime
constructor() {
this.runtime = new Runtime()
this.server = new Server(
{
name: 'hub-server',
version: '1.0.0'
},
{
capabilities: {
tools: {}
}
}
)
this.setupRequestHandlers()
}
private setupRequestHandlers(): void {
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'search',
description:
'Search for available MCP tools by keywords. Use this FIRST to discover tools. Returns JavaScript async function declarations with JSDoc showing exact function names, parameters, and return types for use in `exec`.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description:
'Comma-separated search keywords. A tool matches if ANY keyword appears in its name, description, or server name. Example: "chrome,browser,tab" matches tools related to Chrome OR browser OR tabs.'
},
limit: {
type: 'number',
description: 'Maximum number of tools to return (default: 10, max: 50)'
}
},
required: ['query']
}
},
{
name: 'exec',
description:
'Execute JavaScript that calls MCP tools discovered via `search`. IMPORTANT: You MUST explicitly `return` the final value, or the result will be `undefined`.',
inputSchema: {
type: 'object',
properties: {
code: {
type: 'string',
description:
'JavaScript code to execute. The code runs inside an async context, so use `await` directly. Do NOT wrap your code in `(async () => { ... })()` - this causes double-wrapping and returns undefined. All discovered tools are async functions (call as `await ToolName(params)`). Helpers: `parallel(...promises)`, `settle(...promises)`, `console.*`. You MUST `return` the final value. Examples: `const r = await Tool({ id: "1" }); return r` or `return await Tool({ x: 1 })`'
}
},
required: ['code']
}
}
]
}
})
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
if (!args) {
throw new McpError(ErrorCode.InvalidParams, 'No arguments provided')
}
try {
switch (name) {
case 'search':
return await this.handleSearch(args as unknown as SearchQuery)
case 'exec':
return await this.handleExec(args as unknown as ExecInput)
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`)
}
} catch (error) {
if (error instanceof McpError) {
throw error
}
logger.error(`Error executing tool ${name}:`, error as Error)
throw new McpError(
ErrorCode.InternalError,
`Error executing tool ${name}: ${error instanceof Error ? error.message : String(error)}`
)
}
})
}
private async fetchTools(): Promise<GeneratedTool[]> {
const cached = cacheService.get<GeneratedTool[]>(TOOLS_CACHE_KEY)
if (cached) {
logger.debug('Returning cached tools')
syncToolMapFromGeneratedTools(cached)
return cached
}
logger.debug('Fetching fresh tools')
const allTools = await listAllTools()
const existingNames = new Set<string>()
const tools = allTools.map((tool) => generateToolFunction(tool, existingNames, callMcpTool))
cacheService.set(TOOLS_CACHE_KEY, tools, TOOLS_CACHE_TTL)
syncToolMapFromGeneratedTools(tools)
return tools
}
invalidateCache(): void {
cacheService.delete(TOOLS_CACHE_KEY)
clearToolMap()
logger.debug('Tools cache invalidated')
}
private async handleSearch(query: SearchQuery) {
if (!query.query || typeof query.query !== 'string') {
throw new McpError(ErrorCode.InvalidParams, 'query parameter is required and must be a string')
}
const tools = await this.fetchTools()
const result = searchTools(tools, query)
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2)
}
]
}
}
private async handleExec(input: ExecInput) {
if (!input.code || typeof input.code !== 'string') {
throw new McpError(ErrorCode.InvalidParams, 'code parameter is required and must be a string')
}
const tools = await this.fetchTools()
const result = await this.runtime.execute(input.code, tools)
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2)
}
],
isError: result.isError
}
}
}
export default HubServer

View File

@ -0,0 +1,96 @@
/**
* Bridge module for Hub server to access MCPService.
* Re-exports the methods needed by tool-registry and runtime.
*/
import mcpService from '@main/services/MCPService'
import { generateMcpToolFunctionName } from '@shared/mcp'
import type { MCPCallToolResponse, MCPTool, MCPToolResultContent } from '@types'
import type { GeneratedTool } from './types'
export const listAllTools = () => mcpService.listAllActiveServerTools()
const toolFunctionNameToIdMap = new Map<string, { serverId: string; toolName: string }>()
export async function refreshToolMap(): Promise<void> {
const tools = await listAllTools()
syncToolMapFromTools(tools)
}
export function syncToolMapFromTools(tools: MCPTool[]): void {
toolFunctionNameToIdMap.clear()
const existingNames = new Set<string>()
for (const tool of tools) {
const functionName = generateMcpToolFunctionName(tool.serverName, tool.name, existingNames)
toolFunctionNameToIdMap.set(functionName, { serverId: tool.serverId, toolName: tool.name })
}
}
export function syncToolMapFromGeneratedTools(tools: GeneratedTool[]): void {
toolFunctionNameToIdMap.clear()
for (const tool of tools) {
toolFunctionNameToIdMap.set(tool.functionName, { serverId: tool.serverId, toolName: tool.toolName })
}
}
export function clearToolMap(): void {
toolFunctionNameToIdMap.clear()
}
export const callMcpTool = async (functionName: string, params: unknown, callId?: string): Promise<unknown> => {
const toolInfo = toolFunctionNameToIdMap.get(functionName)
if (!toolInfo) {
await refreshToolMap()
const retryToolInfo = toolFunctionNameToIdMap.get(functionName)
if (!retryToolInfo) {
throw new Error(`Tool not found: ${functionName}`)
}
const toolId = `${retryToolInfo.serverId}__${retryToolInfo.toolName}`
const result = await mcpService.callToolById(toolId, params, callId)
throwIfToolError(result)
return extractToolResult(result)
}
const toolId = `${toolInfo.serverId}__${toolInfo.toolName}`
const result = await mcpService.callToolById(toolId, params, callId)
throwIfToolError(result)
return extractToolResult(result)
}
export const abortMcpTool = async (callId: string): Promise<boolean> => {
return mcpService.abortTool(null as unknown as Electron.IpcMainInvokeEvent, callId)
}
function extractToolResult(result: MCPCallToolResponse): unknown {
if (!result.content || result.content.length === 0) {
return null
}
const textContent = result.content.find((c) => c.type === 'text')
if (textContent?.text) {
try {
return JSON.parse(textContent.text)
} catch {
return textContent.text
}
}
return result.content
}
function throwIfToolError(result: MCPCallToolResponse): void {
if (!result.isError) {
return
}
const textContent = extractTextContent(result.content)
throw new Error(textContent ?? 'Tool execution failed')
}
function extractTextContent(content: MCPToolResultContent[] | undefined): string | undefined {
if (!content || content.length === 0) {
return undefined
}
const textBlock = content.find((item) => item.type === 'text' && item.text)
return textBlock?.text
}

View File

@ -0,0 +1,170 @@
import crypto from 'node:crypto'
import { Worker } from 'node:worker_threads'
import { loggerService } from '@logger'
import { abortMcpTool, callMcpTool } from './mcp-bridge'
import type {
ExecOutput,
GeneratedTool,
HubWorkerCallToolMessage,
HubWorkerExecMessage,
HubWorkerMessage,
HubWorkerResultMessage
} from './types'
import { hubWorkerSource } from './worker'
const logger = loggerService.withContext('MCPServer:Hub:Runtime')
const MAX_LOGS = 1000
const EXECUTION_TIMEOUT = 60000
export class Runtime {
async execute(code: string, tools: GeneratedTool[]): Promise<ExecOutput> {
return await new Promise<ExecOutput>((resolve) => {
const logs: string[] = []
const activeCallIds = new Map<string, string>()
let finished = false
let timedOut = false
let timeoutId: NodeJS.Timeout | null = null
const worker = new Worker(hubWorkerSource, { eval: true })
const addLog = (entry: string) => {
if (logs.length >= MAX_LOGS) {
return
}
logs.push(entry)
}
const finalize = async (output: ExecOutput, terminateWorker = true) => {
if (finished) {
return
}
finished = true
if (timeoutId) {
clearTimeout(timeoutId)
}
worker.removeAllListeners()
if (terminateWorker) {
try {
await worker.terminate()
} catch (error) {
logger.warn('Failed to terminate exec worker', error as Error)
}
}
resolve(output)
}
const abortActiveTools = async () => {
const callIds = Array.from(activeCallIds.values())
activeCallIds.clear()
if (callIds.length === 0) {
return
}
await Promise.allSettled(callIds.map((callId) => abortMcpTool(callId)))
}
const handleToolCall = async (message: HubWorkerCallToolMessage) => {
if (finished || timedOut) {
return
}
const callId = crypto.randomUUID()
activeCallIds.set(message.requestId, callId)
try {
const result = await callMcpTool(message.functionName, message.params, callId)
if (finished || timedOut) {
return
}
worker.postMessage({ type: 'toolResult', requestId: message.requestId, result })
} catch (error) {
if (finished || timedOut) {
return
}
const errorMessage = error instanceof Error ? error.message : String(error)
worker.postMessage({ type: 'toolError', requestId: message.requestId, error: errorMessage })
} finally {
activeCallIds.delete(message.requestId)
}
}
const handleResult = (message: HubWorkerResultMessage) => {
const resolvedLogs = message.logs && message.logs.length > 0 ? message.logs : logs
void finalize({
result: message.result,
logs: resolvedLogs.length > 0 ? resolvedLogs : undefined
})
}
const handleError = (errorMessage: string, messageLogs?: string[], terminateWorker = true) => {
const resolvedLogs = messageLogs && messageLogs.length > 0 ? messageLogs : logs
void finalize(
{
result: undefined,
logs: resolvedLogs.length > 0 ? resolvedLogs : undefined,
error: errorMessage,
isError: true
},
terminateWorker
)
}
const handleMessage = (message: HubWorkerMessage) => {
if (!message || typeof message !== 'object') {
return
}
switch (message.type) {
case 'log':
addLog(message.entry)
break
case 'callTool':
void handleToolCall(message)
break
case 'result':
handleResult(message)
break
case 'error':
handleError(message.error, message.logs)
break
default:
break
}
}
timeoutId = setTimeout(() => {
timedOut = true
void (async () => {
await abortActiveTools()
try {
await worker.terminate()
} catch (error) {
logger.warn('Failed to terminate exec worker after timeout', error as Error)
}
handleError(`Execution timed out after ${EXECUTION_TIMEOUT}ms`, undefined, false)
})()
}, EXECUTION_TIMEOUT)
worker.on('message', handleMessage)
worker.on('error', (error) => {
logger.error('Worker execution error', error)
handleError(error instanceof Error ? error.message : String(error))
})
worker.on('exit', (code) => {
if (finished || timedOut) {
return
}
const message = code === 0 ? 'Exec worker exited unexpectedly' : `Exec worker exited with code ${code}`
logger.error(message)
handleError(message, undefined, false)
})
const execMessage: HubWorkerExecMessage = {
type: 'exec',
code,
tools: tools.map((tool) => ({ functionName: tool.functionName }))
}
worker.postMessage(execMessage)
})
}
}

View File

@ -0,0 +1,109 @@
import { generateToolsCode } from './generator'
import type { GeneratedTool, SearchQuery, SearchResult } from './types'
const DEFAULT_LIMIT = 10
const MAX_LIMIT = 50
export function searchTools(tools: GeneratedTool[], query: SearchQuery): SearchResult {
const { query: queryStr, limit = DEFAULT_LIMIT } = query
const effectiveLimit = Math.min(Math.max(1, limit), MAX_LIMIT)
const keywords = queryStr
.toLowerCase()
.split(',')
.map((k) => k.trim())
.filter((k) => k.length > 0)
if (keywords.length === 0) {
const sliced = tools.slice(0, effectiveLimit)
return {
tools: generateToolsCode(sliced),
total: tools.length
}
}
const matchedTools = tools.filter((tool) => {
const searchText = buildSearchText(tool).toLowerCase()
return keywords.some((keyword) => searchText.includes(keyword))
})
const rankedTools = rankTools(matchedTools, keywords)
const sliced = rankedTools.slice(0, effectiveLimit)
return {
tools: generateToolsCode(sliced),
total: matchedTools.length
}
}
function buildSearchText(tool: GeneratedTool): string {
const combinedName = tool.serverName ? `${tool.serverName}_${tool.toolName}` : tool.toolName
const parts = [
tool.toolName,
tool.functionName,
tool.serverName,
combinedName,
tool.description || '',
tool.signature
]
return parts.join(' ')
}
function rankTools(tools: GeneratedTool[], keywords: string[]): GeneratedTool[] {
const scored = tools.map((tool) => ({
tool,
score: calculateScore(tool, keywords)
}))
scored.sort((a, b) => b.score - a.score)
return scored.map((s) => s.tool)
}
function calculateScore(tool: GeneratedTool, keywords: string[]): number {
let score = 0
const toolName = tool.toolName.toLowerCase()
const serverName = (tool.serverName || '').toLowerCase()
const functionName = tool.functionName.toLowerCase()
const description = (tool.description || '').toLowerCase()
for (const keyword of keywords) {
// Match tool name
if (toolName === keyword) {
score += 10
} else if (toolName.startsWith(keyword)) {
score += 5
} else if (toolName.includes(keyword)) {
score += 3
}
// Match server name
if (serverName === keyword) {
score += 8
} else if (serverName.startsWith(keyword)) {
score += 4
} else if (serverName.includes(keyword)) {
score += 2
}
// Match function name (serverName_toolName format)
if (functionName === keyword) {
score += 10
} else if (functionName.startsWith(keyword)) {
score += 5
} else if (functionName.includes(keyword)) {
score += 3
}
if (description.includes(keyword)) {
const count = (description.match(new RegExp(escapeRegex(keyword), 'g')) || []).length
score += Math.min(count, 3)
}
}
return score
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

View File

@ -0,0 +1,113 @@
import type { MCPServer, MCPTool } from '@types'
export interface GeneratedTool {
serverId: string
serverName: string
toolName: string
functionName: string
jsCode: string
fn: (params: unknown) => Promise<unknown>
signature: string
returns: string
description?: string
}
export interface SearchQuery {
query: string
limit?: number
}
export interface SearchResult {
tools: string
total: number
}
export interface ExecInput {
code: string
}
export type ExecOutput = {
result: unknown
logs?: string[]
error?: string
isError?: boolean
}
export interface ToolRegistryOptions {
ttl?: number
}
export interface MCPToolWithServer extends MCPTool {
server: MCPServer
}
export interface ExecutionContext {
__callTool: (functionName: string, params: unknown) => Promise<unknown>
parallel: <T>(...promises: Promise<T>[]) => Promise<T[]>
settle: <T>(...promises: Promise<T>[]) => Promise<PromiseSettledResult<T>[]>
console: ConsoleMethods
[functionName: string]: unknown
}
export interface ConsoleMethods {
log: (...args: unknown[]) => void
warn: (...args: unknown[]) => void
error: (...args: unknown[]) => void
info: (...args: unknown[]) => void
debug: (...args: unknown[]) => void
}
export type HubWorkerTool = {
functionName: string
}
export type HubWorkerExecMessage = {
type: 'exec'
code: string
tools: HubWorkerTool[]
}
export type HubWorkerCallToolMessage = {
type: 'callTool'
requestId: string
functionName: string
params: unknown
}
export type HubWorkerToolResultMessage = {
type: 'toolResult'
requestId: string
result: unknown
}
export type HubWorkerToolErrorMessage = {
type: 'toolError'
requestId: string
error: string
}
export type HubWorkerResultMessage = {
type: 'result'
result: unknown
logs?: string[]
}
export type HubWorkerErrorMessage = {
type: 'error'
error: string
logs?: string[]
}
export type HubWorkerLogMessage = {
type: 'log'
entry: string
}
export type HubWorkerMessage =
| HubWorkerExecMessage
| HubWorkerCallToolMessage
| HubWorkerToolResultMessage
| HubWorkerToolErrorMessage
| HubWorkerResultMessage
| HubWorkerErrorMessage
| HubWorkerLogMessage

View File

@ -0,0 +1,133 @@
export const hubWorkerSource = `
const crypto = require('node:crypto')
const { parentPort } = require('node:worker_threads')
const MAX_LOGS = 1000
const logs = []
const pendingCalls = new Map()
let isExecuting = false
const stringify = (value) => {
if (value === undefined) return 'undefined'
if (value === null) return 'null'
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
if (value instanceof Error) return value.message
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
const pushLog = (level, args) => {
if (logs.length >= MAX_LOGS) {
return
}
const message = args.map((arg) => stringify(arg)).join(' ')
const entry = \`[\${level}] \${message}\`
logs.push(entry)
parentPort?.postMessage({ type: 'log', entry })
}
const capturedConsole = {
log: (...args) => pushLog('log', args),
warn: (...args) => pushLog('warn', args),
error: (...args) => pushLog('error', args),
info: (...args) => pushLog('info', args),
debug: (...args) => pushLog('debug', args)
}
const callTool = (functionName, params) =>
new Promise((resolve, reject) => {
const requestId = crypto.randomUUID()
pendingCalls.set(requestId, { resolve, reject })
parentPort?.postMessage({ type: 'callTool', requestId, functionName, params })
})
const buildContext = (tools) => {
const context = {
__callTool: callTool,
parallel: (...promises) => Promise.all(promises),
settle: (...promises) => Promise.allSettled(promises),
console: capturedConsole
}
for (const tool of tools) {
context[tool.functionName] = (params) => callTool(tool.functionName, params)
}
return context
}
const runCode = async (code, context) => {
const contextKeys = Object.keys(context)
const contextValues = contextKeys.map((key) => context[key])
const wrappedCode = \`
return (async () => {
\${code}
})()
\`
const fn = new Function(...contextKeys, wrappedCode)
return await fn(...contextValues)
}
const handleExec = async (code, tools) => {
if (isExecuting) {
return
}
isExecuting = true
try {
const context = buildContext(tools)
const result = await runCode(code, context)
parentPort?.postMessage({ type: 'result', result, logs: logs.length > 0 ? logs : undefined })
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
parentPort?.postMessage({ type: 'error', error: errorMessage, logs: logs.length > 0 ? logs : undefined })
} finally {
pendingCalls.clear()
}
}
const handleToolResult = (message) => {
const pending = pendingCalls.get(message.requestId)
if (!pending) {
return
}
pendingCalls.delete(message.requestId)
pending.resolve(message.result)
}
const handleToolError = (message) => {
const pending = pendingCalls.get(message.requestId)
if (!pending) {
return
}
pendingCalls.delete(message.requestId)
pending.reject(new Error(message.error))
}
parentPort?.on('message', (message) => {
if (!message || typeof message !== 'object') {
return
}
switch (message.type) {
case 'exec':
handleExec(message.code, message.tools ?? [])
break
case 'toolResult':
handleToolResult(message)
break
case 'toolError':
handleToolError(message)
break
default:
break
}
})
`

View File

@ -8,6 +8,27 @@ import { v4 as uuidv4 } from 'uuid'
const logger = loggerService.withContext('DxtService')
/**
* Ensure a target path is within the base directory to prevent path traversal attacks.
* This is the correct approach: validate the final resolved path rather than sanitizing input.
*
* @param basePath - The base directory that the target must be within
* @param targetPath - The target path to validate
* @returns The resolved target path if valid
* @throws Error if the target path escapes the base directory
*/
export function ensurePathWithin(basePath: string, targetPath: string): string {
const resolvedBase = path.resolve(basePath)
const resolvedTarget = path.resolve(path.normalize(targetPath))
// Must be direct child of base directory, no subdirectories allowed
if (path.dirname(resolvedTarget) !== resolvedBase) {
throw new Error('Path traversal detected: target path must be direct child of base directory')
}
return resolvedTarget
}
// Type definitions
export interface DxtManifest {
dxt_version: string
@ -68,6 +89,76 @@ export interface DxtUploadResult {
error?: string
}
/**
* Validate and sanitize a command to prevent path traversal attacks.
* Commands should be either:
* 1. Simple command names (e.g., "node", "python", "npx") - looked up in PATH
* 2. Absolute paths (e.g., "/usr/bin/node", "C:\\Program Files\\node\\node.exe")
* 3. Relative paths starting with ./ or .\ (relative to extractDir)
*
* Rejects commands containing path traversal sequences (..)
*
* @param command - The command to validate
* @returns The validated command
* @throws Error if command contains path traversal or is invalid
*/
export function validateCommand(command: string): string {
if (!command || typeof command !== 'string') {
throw new Error('Invalid command: command must be a non-empty string')
}
const trimmed = command.trim()
if (!trimmed) {
throw new Error('Invalid command: command cannot be empty')
}
// Check for path traversal sequences
// This catches: .., ../, ..\, /../, \..\, etc.
if (/(?:^|[/\\])\.\.(?:[/\\]|$)/.test(trimmed) || trimmed === '..') {
throw new Error(`Invalid command: path traversal detected in "${command}"`)
}
// Check for null bytes
if (trimmed.includes('\0')) {
throw new Error('Invalid command: null byte detected')
}
return trimmed
}
/**
* Validate command arguments to prevent injection attacks.
* Rejects arguments containing path traversal sequences.
*
* @param args - The arguments array to validate
* @returns The validated arguments array
* @throws Error if any argument contains path traversal
*/
export function validateArgs(args: string[]): string[] {
if (!Array.isArray(args)) {
throw new Error('Invalid args: must be an array')
}
return args.map((arg, index) => {
if (typeof arg !== 'string') {
throw new Error(`Invalid args: argument at index ${index} must be a string`)
}
// Check for null bytes
if (arg.includes('\0')) {
throw new Error(`Invalid args: null byte detected in argument at index ${index}`)
}
// Check for path traversal in arguments that look like paths
// Only validate if the arg contains path separators (indicating it's meant to be a path)
if ((arg.includes('/') || arg.includes('\\')) && /(?:^|[/\\])\.\.(?:[/\\]|$)/.test(arg)) {
throw new Error(`Invalid args: path traversal detected in argument at index ${index}`)
}
return arg
})
}
export function performVariableSubstitution(
value: string,
extractDir: string,
@ -134,12 +225,16 @@ export function applyPlatformOverrides(mcpConfig: any, extractDir: string, userC
// Apply variable substitution to all string values
if (resolvedConfig.command) {
resolvedConfig.command = performVariableSubstitution(resolvedConfig.command, extractDir, userConfig)
// Validate command after substitution to prevent path traversal attacks
resolvedConfig.command = validateCommand(resolvedConfig.command)
}
if (resolvedConfig.args) {
resolvedConfig.args = resolvedConfig.args.map((arg: string) =>
performVariableSubstitution(arg, extractDir, userConfig)
)
// Validate args after substitution to prevent path traversal attacks
resolvedConfig.args = validateArgs(resolvedConfig.args)
}
if (resolvedConfig.env) {
@ -271,10 +366,8 @@ class DxtService {
}
// Use server name as the final extract directory for automatic version management
// Sanitize the name to prevent creating subdirectories
const sanitizedName = manifest.name.replace(/\//g, '-')
const serverDirName = `server-${sanitizedName}`
const finalExtractDir = path.join(this.mcpDir, serverDirName)
const serverDirName = `server-${manifest.name}`
const finalExtractDir = ensurePathWithin(this.mcpDir, path.join(this.mcpDir, serverDirName))
// Clean up any existing version of this server
if (fs.existsSync(finalExtractDir)) {
@ -354,27 +447,15 @@ class DxtService {
public cleanupDxtServer(serverName: string): boolean {
try {
// Handle server names that might contain slashes (e.g., "anthropic/sequential-thinking")
// by replacing slashes with the same separator used during installation
const sanitizedName = serverName.replace(/\//g, '-')
const serverDirName = `server-${sanitizedName}`
const serverDir = path.join(this.mcpDir, serverDirName)
const serverDirName = `server-${serverName}`
const serverDir = ensurePathWithin(this.mcpDir, path.join(this.mcpDir, serverDirName))
// First try the sanitized path
if (fs.existsSync(serverDir)) {
logger.debug(`Removing DXT server directory: ${serverDir}`)
fs.rmSync(serverDir, { recursive: true, force: true })
return true
}
// Fallback: try with original name in case it was stored differently
const originalServerDir = path.join(this.mcpDir, `server-${serverName}`)
if (fs.existsSync(originalServerDir)) {
logger.debug(`Removing DXT server directory: ${originalServerDir}`)
fs.rmSync(originalServerDir, { recursive: true, force: true })
return true
}
logger.warn(`Server directory not found: ${serverDir}`)
return false
} catch (error) {

View File

@ -10,6 +10,7 @@ import {
scanDir
} from '@main/utils/file'
import { documentExts, imageExts, KB, MB } from '@shared/config/constant'
import { parseDataUrl } from '@shared/utils'
import type { FileMetadata, NotesTreeNode } from '@types'
import { FileTypes } from '@types'
import chardet from 'chardet'
@ -672,8 +673,8 @@ class FileStorage {
throw new Error('Base64 data is required')
}
// 移除 base64 头部信息(如果存在)
const base64String = base64Data.replace(/^data:.*;base64,/, '')
const parseResult = parseDataUrl(base64Data)
const base64String = parseResult?.data ?? base64Data
const buffer = Buffer.from(base64String, 'base64')
const uuid = uuidv4()
const ext = '.png'
@ -1464,8 +1465,8 @@ class FileStorage {
})
if (filePath) {
const base64Data = data.replace(/^data:image\/png;base64,/, '')
fs.writeFileSync(filePath, base64Data, 'base64')
const parseResult = parseDataUrl(data)
fs.writeFileSync(filePath, parseResult?.data ?? data, 'base64')
}
} catch (error) {
logger.error('[IPC - Error] An error occurred saving the image:', error as Error)

View File

@ -4,9 +4,9 @@ import path from 'node:path'
import { cacheService } from '@data/CacheService'
import { loggerService } from '@logger'
import { getMCPServersFromRedux } from '@main/apiServer/utils/mcp'
import { createInMemoryMCPServer } from '@main/mcpServers/factory'
import { makeSureDirExists, removeEnvProxy } from '@main/utils'
import { buildFunctionCallToolName } from '@main/utils/mcp'
import { findCommandInShellEnv, getBinaryName, getBinaryPath, isBinaryExists } from '@main/utils/process'
import getLoginShellEnvironment from '@main/utils/shell-env'
import { TraceMethod, withSpanFunc } from '@mcp-trace/trace-core'
@ -36,6 +36,7 @@ import { HOME_CHERRY_DIR } from '@shared/config/constant'
import type { MCPProgressEvent } from '@shared/config/types'
import type { MCPServerLogEntry } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import { buildFunctionCallToolName } from '@shared/mcp'
import { defaultAppHeaders } from '@shared/utils'
import {
BuiltinMCPServerNames,
@ -165,6 +166,67 @@ class McpService {
this.getServerLogs = this.getServerLogs.bind(this)
}
/**
* List all tools from all active MCP servers (excluding hub).
* Used by Hub server's tool registry.
*/
public async listAllActiveServerTools(): Promise<MCPTool[]> {
const servers = await getMCPServersFromRedux()
const activeServers = servers.filter((server) => server.isActive)
const results = await Promise.allSettled(
activeServers.map(async (server) => {
const tools = await this.listToolsImpl(server)
const disabledTools = new Set(server.disabledTools ?? [])
return disabledTools.size > 0 ? tools.filter((tool) => !disabledTools.has(tool.name)) : tools
})
)
const allTools: MCPTool[] = []
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
allTools.push(...result.value)
} else {
logger.error(
`[listAllActiveServerTools] Failed to list tools from ${activeServers[index].name}:`,
result.reason as Error
)
}
})
return allTools
}
/**
* Call a tool by its full ID (serverId__toolName format).
* Used by Hub server's runtime.
*/
public async callToolById(toolId: string, params: unknown, callId?: string): Promise<MCPCallToolResponse> {
const parts = toolId.split('__')
if (parts.length < 2) {
throw new Error(`Invalid tool ID format: ${toolId}`)
}
const serverId = parts[0]
const toolName = parts.slice(1).join('__')
const servers = await getMCPServersFromRedux()
const server = servers.find((s) => s.id === serverId)
if (!server) {
throw new Error(`Server not found: ${serverId}`)
}
logger.debug(`[callToolById] Calling tool ${toolName} on server ${server.name}`)
return this.callTool(null as unknown as Electron.IpcMainInvokeEvent, {
server,
name: toolName,
args: params,
callId
})
}
private getServerKey(server: MCPServer): string {
return JSON.stringify({
baseUrl: server.baseUrl,

View File

@ -1088,18 +1088,33 @@ export class SelectionService {
this.lastCtrlkeyDownTime = -1
}
//check if the key is ctrl key
// Check if the key is ctrl key
// Windows: VK_LCONTROL(162), VK_RCONTROL(163)
// macOS: kVK_Control(59), kVK_RightControl(62)
private isCtrlkey(vkCode: number) {
if (isMac) {
return vkCode === 59 || vkCode === 62
}
return vkCode === 162 || vkCode === 163
}
//check if the key is shift key
// Check if the key is shift key
// Windows: VK_LSHIFT(160), VK_RSHIFT(161)
// macOS: kVK_Shift(56), kVK_RightShift(60)
private isShiftkey(vkCode: number) {
if (isMac) {
return vkCode === 56 || vkCode === 60
}
return vkCode === 160 || vkCode === 161
}
//check if the key is alt key
// Check if the key is alt/option key
// Windows: VK_LMENU(164), VK_RMENU(165)
// macOS: kVK_Option(58), kVK_RightOption(61)
private isAltkey(vkCode: number) {
if (isMac) {
return vkCode === 58 || vkCode === 61
}
return vkCode === 164 || vkCode === 165
}

View File

@ -0,0 +1,202 @@
import path from 'path'
import { describe, expect, it } from 'vitest'
import { ensurePathWithin, validateArgs, validateCommand } from '../DxtService'
describe('ensurePathWithin', () => {
const baseDir = '/home/user/mcp'
describe('valid paths', () => {
it('should accept direct child paths', () => {
expect(ensurePathWithin(baseDir, '/home/user/mcp/server-test')).toBe('/home/user/mcp/server-test')
expect(ensurePathWithin(baseDir, '/home/user/mcp/my-server')).toBe('/home/user/mcp/my-server')
})
it('should accept paths with unicode characters', () => {
expect(ensurePathWithin(baseDir, '/home/user/mcp/服务器')).toBe('/home/user/mcp/服务器')
expect(ensurePathWithin(baseDir, '/home/user/mcp/サーバー')).toBe('/home/user/mcp/サーバー')
})
})
describe('path traversal prevention', () => {
it('should reject paths that escape base directory', () => {
expect(() => ensurePathWithin(baseDir, '/home/user/mcp/../../../etc')).toThrow('Path traversal detected')
expect(() => ensurePathWithin(baseDir, '/etc/passwd')).toThrow('Path traversal detected')
expect(() => ensurePathWithin(baseDir, '/home/user')).toThrow('Path traversal detected')
})
it('should reject subdirectories', () => {
expect(() => ensurePathWithin(baseDir, '/home/user/mcp/sub/dir')).toThrow('Path traversal detected')
expect(() => ensurePathWithin(baseDir, '/home/user/mcp/a/b/c')).toThrow('Path traversal detected')
})
it('should reject Windows-style path traversal', () => {
const winBase = 'C:\\Users\\user\\mcp'
expect(() => ensurePathWithin(winBase, 'C:\\Users\\user\\mcp\\..\\..\\Windows\\System32')).toThrow(
'Path traversal detected'
)
})
it('should reject null byte attacks', () => {
const maliciousPath = path.join(baseDir, 'server\x00/../../../etc/passwd')
expect(() => ensurePathWithin(baseDir, maliciousPath)).toThrow('Path traversal detected')
})
it('should handle encoded traversal attempts', () => {
expect(() => ensurePathWithin(baseDir, '/home/user/mcp/../escape')).toThrow('Path traversal detected')
})
})
describe('edge cases', () => {
it('should reject base directory itself', () => {
expect(() => ensurePathWithin(baseDir, '/home/user/mcp')).toThrow('Path traversal detected')
})
it('should handle relative path construction', () => {
const target = path.join(baseDir, 'server-name')
expect(ensurePathWithin(baseDir, target)).toBe('/home/user/mcp/server-name')
})
})
})
describe('validateCommand', () => {
describe('valid commands', () => {
it('should accept simple command names', () => {
expect(validateCommand('node')).toBe('node')
expect(validateCommand('python')).toBe('python')
expect(validateCommand('npx')).toBe('npx')
expect(validateCommand('uvx')).toBe('uvx')
})
it('should accept absolute paths', () => {
expect(validateCommand('/usr/bin/node')).toBe('/usr/bin/node')
expect(validateCommand('/usr/local/bin/python3')).toBe('/usr/local/bin/python3')
expect(validateCommand('C:\\Program Files\\nodejs\\node.exe')).toBe('C:\\Program Files\\nodejs\\node.exe')
})
it('should accept relative paths starting with ./', () => {
expect(validateCommand('./node_modules/.bin/tsc')).toBe('./node_modules/.bin/tsc')
expect(validateCommand('.\\scripts\\run.bat')).toBe('.\\scripts\\run.bat')
})
it('should trim whitespace', () => {
expect(validateCommand(' node ')).toBe('node')
expect(validateCommand('\tpython\n')).toBe('python')
})
})
describe('path traversal prevention', () => {
it('should reject commands with path traversal (Unix style)', () => {
expect(() => validateCommand('../../../bin/sh')).toThrow('path traversal detected')
expect(() => validateCommand('../../etc/passwd')).toThrow('path traversal detected')
expect(() => validateCommand('/usr/../../../bin/sh')).toThrow('path traversal detected')
})
it('should reject commands with path traversal (Windows style)', () => {
expect(() => validateCommand('..\\..\\..\\Windows\\System32\\cmd.exe')).toThrow('path traversal detected')
expect(() => validateCommand('..\\..\\Windows\\System32\\calc.exe')).toThrow('path traversal detected')
expect(() => validateCommand('C:\\..\\..\\Windows\\System32\\cmd.exe')).toThrow('path traversal detected')
})
it('should reject just ".."', () => {
expect(() => validateCommand('..')).toThrow('path traversal detected')
})
it('should reject mixed style path traversal', () => {
expect(() => validateCommand('../..\\mixed/..\\attack')).toThrow('path traversal detected')
})
})
describe('null byte injection', () => {
it('should reject commands with null bytes', () => {
expect(() => validateCommand('node\x00.exe')).toThrow('null byte detected')
expect(() => validateCommand('python\0')).toThrow('null byte detected')
})
})
describe('edge cases', () => {
it('should reject empty strings', () => {
expect(() => validateCommand('')).toThrow('command must be a non-empty string')
expect(() => validateCommand(' ')).toThrow('command cannot be empty')
})
it('should reject non-string input', () => {
// @ts-expect-error - testing runtime behavior
expect(() => validateCommand(null)).toThrow('command must be a non-empty string')
// @ts-expect-error - testing runtime behavior
expect(() => validateCommand(undefined)).toThrow('command must be a non-empty string')
// @ts-expect-error - testing runtime behavior
expect(() => validateCommand(123)).toThrow('command must be a non-empty string')
})
})
describe('real-world attack scenarios', () => {
it('should prevent Windows system32 command injection', () => {
expect(() => validateCommand('../../../../Windows/System32/cmd.exe')).toThrow('path traversal detected')
expect(() => validateCommand('..\\..\\..\\..\\Windows\\System32\\powershell.exe')).toThrow(
'path traversal detected'
)
})
it('should prevent Unix bin injection', () => {
expect(() => validateCommand('../../../../bin/bash')).toThrow('path traversal detected')
expect(() => validateCommand('../../../usr/bin/curl')).toThrow('path traversal detected')
})
})
})
describe('validateArgs', () => {
describe('valid arguments', () => {
it('should accept normal arguments', () => {
expect(validateArgs(['--version'])).toEqual(['--version'])
expect(validateArgs(['-y', '@anthropic/mcp-server'])).toEqual(['-y', '@anthropic/mcp-server'])
expect(validateArgs(['install', 'package-name'])).toEqual(['install', 'package-name'])
})
it('should accept arguments with safe paths', () => {
expect(validateArgs(['./src/index.ts'])).toEqual(['./src/index.ts'])
expect(validateArgs(['/absolute/path/file.js'])).toEqual(['/absolute/path/file.js'])
})
it('should accept empty array', () => {
expect(validateArgs([])).toEqual([])
})
})
describe('path traversal prevention', () => {
it('should reject arguments with path traversal', () => {
expect(() => validateArgs(['../../../etc/passwd'])).toThrow('path traversal detected')
expect(() => validateArgs(['--config', '../../secrets.json'])).toThrow('path traversal detected')
expect(() => validateArgs(['..\\..\\Windows\\System32\\config'])).toThrow('path traversal detected')
})
it('should only check path-like arguments', () => {
// Arguments without path separators should pass even with dots
expect(validateArgs(['..version'])).toEqual(['..version'])
expect(validateArgs(['test..name'])).toEqual(['test..name'])
})
})
describe('null byte injection', () => {
it('should reject arguments with null bytes', () => {
expect(() => validateArgs(['file\x00.txt'])).toThrow('null byte detected')
expect(() => validateArgs(['--config', 'path\0name'])).toThrow('null byte detected')
})
})
describe('edge cases', () => {
it('should reject non-array input', () => {
// @ts-expect-error - testing runtime behavior
expect(() => validateArgs('not an array')).toThrow('must be an array')
// @ts-expect-error - testing runtime behavior
expect(() => validateArgs(null)).toThrow('must be an array')
})
it('should reject non-string elements', () => {
// @ts-expect-error - testing runtime behavior
expect(() => validateArgs([123])).toThrow('must be a string')
// @ts-expect-error - testing runtime behavior
expect(() => validateArgs(['valid', null])).toThrow('must be a string')
})
})
})

View File

@ -0,0 +1,75 @@
import type { MCPServer, MCPTool } from '@types'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@main/apiServer/utils/mcp', () => ({
getMCPServersFromRedux: vi.fn()
}))
vi.mock('@main/services/WindowService', () => ({
windowService: {
getMainWindow: vi.fn(() => null)
}
}))
import { getMCPServersFromRedux } from '@main/apiServer/utils/mcp'
import mcpService from '@main/services/MCPService'
const baseInputSchema: { type: 'object'; properties: Record<string, unknown>; required: string[] } = {
type: 'object',
properties: {},
required: []
}
const createTool = (overrides: Partial<MCPTool>): MCPTool => ({
id: `${overrides.serverId}__${overrides.name}`,
name: overrides.name ?? 'tool',
description: overrides.description,
serverId: overrides.serverId ?? 'server',
serverName: overrides.serverName ?? 'server',
inputSchema: baseInputSchema,
type: 'mcp',
...overrides
})
describe('MCPService.listAllActiveServerTools', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('filters disabled tools per server', async () => {
const servers: MCPServer[] = [
{
id: 'alpha',
name: 'Alpha',
isActive: true,
disabledTools: ['disabled_tool']
},
{
id: 'beta',
name: 'Beta',
isActive: true
}
]
vi.mocked(getMCPServersFromRedux).mockResolvedValue(servers)
const listToolsSpy = vi.spyOn(mcpService as any, 'listToolsImpl').mockImplementation(async (server: any) => {
if (server.id === 'alpha') {
return [
createTool({ name: 'enabled_tool', serverId: server.id, serverName: server.name }),
createTool({ name: 'disabled_tool', serverId: server.id, serverName: server.name })
]
}
return [createTool({ name: 'beta_tool', serverId: server.id, serverName: server.name })]
})
const tools = await mcpService.listAllActiveServerTools()
expect(listToolsSpy).toHaveBeenCalledTimes(2)
expect(tools.map((tool) => tool.name)).toEqual(['enabled_tool', 'beta_tool'])
})
})

View File

@ -1,7 +1,7 @@
import { loggerService } from '@logger'
import { mcpApiService } from '@main/apiServer/services/mcp'
import { type ModelValidationError, validateModelId } from '@main/apiServer/utils'
import { buildFunctionCallToolName } from '@main/utils/mcp'
import { buildFunctionCallToolName } from '@shared/mcp'
import type { AgentType, MCPTool, SlashCommand, Tool } from '@types'
import { objectKeys } from '@types'
import fs from 'fs'

View File

@ -1,225 +0,0 @@
import { describe, expect, it } from 'vitest'
import { buildFunctionCallToolName } from '../mcp'
describe('buildFunctionCallToolName', () => {
describe('basic format', () => {
it('should return format mcp__{server}__{tool}', () => {
const result = buildFunctionCallToolName('github', 'search_issues')
expect(result).toBe('mcp__github__search_issues')
})
it('should handle simple server and tool names', () => {
expect(buildFunctionCallToolName('fetch', 'get_page')).toBe('mcp__fetch__get_page')
expect(buildFunctionCallToolName('database', 'query')).toBe('mcp__database__query')
expect(buildFunctionCallToolName('cherry_studio', 'search')).toBe('mcp__cherry_studio__search')
})
})
describe('valid JavaScript identifier', () => {
it('should always start with mcp__ prefix (valid JS identifier start)', () => {
const result = buildFunctionCallToolName('123server', '456tool')
expect(result).toMatch(/^mcp__/)
expect(result).toBe('mcp__123server__456tool')
})
it('should only contain alphanumeric chars and underscores', () => {
const result = buildFunctionCallToolName('my-server', 'my-tool')
expect(result).toBe('mcp__my_server__my_tool')
expect(result).toMatch(/^[a-zA-Z][a-zA-Z0-9_]*$/)
})
it('should be a valid JavaScript identifier', () => {
const testCases = [
['github', 'create_issue'],
['my-server', 'fetch-data'],
['test@server', 'tool#name'],
['server.name', 'tool.action'],
['123abc', 'def456']
]
for (const [server, tool] of testCases) {
const result = buildFunctionCallToolName(server, tool)
// Valid JS identifiers match this pattern
expect(result).toMatch(/^[a-zA-Z_][a-zA-Z0-9_]*$/)
}
})
})
describe('character sanitization', () => {
it('should replace dashes with underscores', () => {
const result = buildFunctionCallToolName('my-server', 'my-tool-name')
expect(result).toBe('mcp__my_server__my_tool_name')
})
it('should replace special characters with underscores', () => {
const result = buildFunctionCallToolName('test@server!', 'tool#name$')
expect(result).toBe('mcp__test_server__tool_name')
})
it('should replace dots with underscores', () => {
const result = buildFunctionCallToolName('server.name', 'tool.action')
expect(result).toBe('mcp__server_name__tool_action')
})
it('should replace spaces with underscores', () => {
const result = buildFunctionCallToolName('my server', 'my tool')
expect(result).toBe('mcp__my_server__my_tool')
})
it('should collapse consecutive underscores', () => {
const result = buildFunctionCallToolName('my--server', 'my___tool')
expect(result).toBe('mcp__my_server__my_tool')
expect(result).not.toMatch(/_{3,}/)
})
it('should trim leading and trailing underscores from parts', () => {
const result = buildFunctionCallToolName('_server_', '_tool_')
expect(result).toBe('mcp__server__tool')
})
it('should handle names with only special characters', () => {
const result = buildFunctionCallToolName('---', '###')
expect(result).toBe('mcp____')
})
})
describe('length constraints', () => {
it('should not exceed 63 characters', () => {
const longServerName = 'a'.repeat(50)
const longToolName = 'b'.repeat(50)
const result = buildFunctionCallToolName(longServerName, longToolName)
expect(result.length).toBeLessThanOrEqual(63)
})
it('should truncate server name to max 20 chars', () => {
const longServerName = 'abcdefghijklmnopqrstuvwxyz' // 26 chars
const result = buildFunctionCallToolName(longServerName, 'tool')
expect(result).toBe('mcp__abcdefghijklmnopqrst__tool')
expect(result).toContain('abcdefghijklmnopqrst') // First 20 chars
expect(result).not.toContain('uvwxyz') // Truncated
})
it('should truncate tool name to max 35 chars', () => {
const longToolName = 'a'.repeat(40)
const result = buildFunctionCallToolName('server', longToolName)
const expectedTool = 'a'.repeat(35)
expect(result).toBe(`mcp__server__${expectedTool}`)
})
it('should not end with underscores after truncation', () => {
// Create a name that would end with underscores after truncation
const longServerName = 'a'.repeat(20)
const longToolName = 'b'.repeat(35) + '___extra'
const result = buildFunctionCallToolName(longServerName, longToolName)
expect(result).not.toMatch(/_+$/)
expect(result.length).toBeLessThanOrEqual(63)
})
it('should handle max length edge case exactly', () => {
// mcp__ (5) + server (20) + __ (2) + tool (35) = 62 chars
const server = 'a'.repeat(20)
const tool = 'b'.repeat(35)
const result = buildFunctionCallToolName(server, tool)
expect(result.length).toBe(62)
expect(result).toBe(`mcp__${'a'.repeat(20)}__${'b'.repeat(35)}`)
})
})
describe('edge cases', () => {
it('should handle empty server name', () => {
const result = buildFunctionCallToolName('', 'tool')
expect(result).toBe('mcp____tool')
})
it('should handle empty tool name', () => {
const result = buildFunctionCallToolName('server', '')
expect(result).toBe('mcp__server__')
})
it('should handle both empty names', () => {
const result = buildFunctionCallToolName('', '')
expect(result).toBe('mcp____')
})
it('should handle whitespace-only names', () => {
const result = buildFunctionCallToolName(' ', ' ')
expect(result).toBe('mcp____')
})
it('should trim whitespace from names', () => {
const result = buildFunctionCallToolName(' server ', ' tool ')
expect(result).toBe('mcp__server__tool')
})
it('should handle unicode characters', () => {
const result = buildFunctionCallToolName('服务器', '工具')
// Unicode chars are replaced with underscores, then collapsed
expect(result).toMatch(/^mcp__/)
})
it('should handle mixed case', () => {
const result = buildFunctionCallToolName('MyServer', 'MyTool')
expect(result).toBe('mcp__MyServer__MyTool')
})
})
describe('deterministic output', () => {
it('should produce consistent results for same input', () => {
const serverName = 'github'
const toolName = 'search_repos'
const result1 = buildFunctionCallToolName(serverName, toolName)
const result2 = buildFunctionCallToolName(serverName, toolName)
const result3 = buildFunctionCallToolName(serverName, toolName)
expect(result1).toBe(result2)
expect(result2).toBe(result3)
})
it('should produce different results for different inputs', () => {
const result1 = buildFunctionCallToolName('server1', 'tool')
const result2 = buildFunctionCallToolName('server2', 'tool')
const result3 = buildFunctionCallToolName('server', 'tool1')
const result4 = buildFunctionCallToolName('server', 'tool2')
expect(result1).not.toBe(result2)
expect(result3).not.toBe(result4)
})
})
describe('real-world scenarios', () => {
it('should handle GitHub MCP server', () => {
expect(buildFunctionCallToolName('github', 'create_issue')).toBe('mcp__github__create_issue')
expect(buildFunctionCallToolName('github', 'search_repositories')).toBe('mcp__github__search_repositories')
expect(buildFunctionCallToolName('github', 'get_pull_request')).toBe('mcp__github__get_pull_request')
})
it('should handle filesystem MCP server', () => {
expect(buildFunctionCallToolName('filesystem', 'read_file')).toBe('mcp__filesystem__read_file')
expect(buildFunctionCallToolName('filesystem', 'write_file')).toBe('mcp__filesystem__write_file')
expect(buildFunctionCallToolName('filesystem', 'list_directory')).toBe('mcp__filesystem__list_directory')
})
it('should handle hyphenated server names (common in npm packages)', () => {
expect(buildFunctionCallToolName('cherry-fetch', 'get_page')).toBe('mcp__cherry_fetch__get_page')
expect(buildFunctionCallToolName('mcp-server-github', 'search')).toBe('mcp__mcp_server_github__search')
})
it('should handle scoped npm package style names', () => {
const result = buildFunctionCallToolName('@anthropic/mcp-server', 'chat')
expect(result).toBe('mcp__anthropic_mcp_server__chat')
})
it('should handle tools with long descriptive names', () => {
const result = buildFunctionCallToolName('github', 'search_repositories_by_language_and_stars')
expect(result.length).toBeLessThanOrEqual(63)
expect(result).toMatch(/^mcp__github__search_repositories_by_lan/)
})
})
})

View File

@ -13,18 +13,13 @@ export async function getIpCountry(): Promise<string> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
const ipinfo = await net.fetch('https://ipinfo.io/json', {
signal: controller.signal,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9'
}
const ipinfo = await net.fetch(`https://api.ipinfo.io/lite/me?token=2a42580355dae4`, {
signal: controller.signal
})
clearTimeout(timeoutId)
const data = await ipinfo.json()
const country = data.country || 'CN'
const country = data.country_code || 'CN'
logger.info(`Detected user IP address country: ${country}`)
return country
} catch (error) {

View File

@ -1,29 +0,0 @@
/**
* Builds a valid JavaScript function name for MCP tool calls.
* Format: mcp__{server_name}__{tool_name}
*
* @param serverName - The MCP server name
* @param toolName - The tool name from the server
* @returns A valid JS identifier in format mcp__{server}__{tool}, max 63 chars
*/
export function buildFunctionCallToolName(serverName: string, toolName: string): string {
// Sanitize to valid JS identifier chars (alphanumeric + underscore only)
const sanitize = (str: string): string =>
str
.trim()
.replace(/[^a-zA-Z0-9]/g, '_') // Replace all non-alphanumeric with underscore
.replace(/_{2,}/g, '_') // Collapse multiple underscores
.replace(/^_+|_+$/g, '') // Trim leading/trailing underscores
const server = sanitize(serverName).slice(0, 20) // Keep server name short
const tool = sanitize(toolName).slice(0, 35) // More room for tool name
let name = `mcp__${server}__${tool}`
// Ensure max 63 chars and clean trailing underscores
if (name.length > 63) {
name = name.slice(0, 63).replace(/_+$/, '')
}
return name
}

View File

@ -7,13 +7,13 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react'
import { AppShell } from './components/layout/AppShell'
import TopViewContainer from './components/TopView'
import AntdProvider from './context/AntdProvider'
import { CodeStyleProvider } from './context/CodeStyleProvider'
import { NotificationProvider } from './context/NotificationProvider'
import StyleSheetManager from './context/StyleSheetManager'
import { ThemeProvider } from './context/ThemeProvider'
import Router from './Router'
const logger = loggerService.withContext('App.tsx')
@ -42,7 +42,7 @@ function App(): React.ReactElement {
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<Router />
<AppShell />
</TopViewContainer>
</PersistGate>
</CodeStyleProvider>

View File

@ -1,67 +0,0 @@
import '@renderer/databases'
import type { FC } from 'react'
import { useMemo } from 'react'
import { HashRouter, Route, Routes } from 'react-router-dom'
import Sidebar from './components/app/Sidebar'
import { ErrorBoundary } from './components/ErrorBoundary'
import TabsContainer from './components/Tab/TabContainer'
import NavigationHandler from './handler/NavigationHandler'
import { useNavbarPosition } from './hooks/useNavbar'
import CodeToolsPage from './pages/code/CodeToolsPage'
import FilesPage from './pages/files/FilesPage'
import HomePage from './pages/home/HomePage'
import KnowledgePage from './pages/knowledge/KnowledgePage'
import LaunchpadPage from './pages/launchpad/LaunchpadPage'
import MinAppPage from './pages/minapps/MinAppPage'
import MinAppsPage from './pages/minapps/MinAppsPage'
import NotesPage from './pages/notes/NotesPage'
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
import SettingsPage from './pages/settings/SettingsPage'
import AssistantPresetsPage from './pages/store/assistants/presets/AssistantPresetsPage'
import TranslatePage from './pages/translate/TranslatePage'
const Router: FC = () => {
const { navbarPosition } = useNavbarPosition()
const routes = useMemo(() => {
return (
<ErrorBoundary>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/store" element={<AssistantPresetsPage />} />
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/notes" element={<NotesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps/:appId" element={<MinAppPage />} />
<Route path="/apps" element={<MinAppsPage />} />
<Route path="/code" element={<CodeToolsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
<Route path="/launchpad" element={<LaunchpadPage />} />
</Routes>
</ErrorBoundary>
)
}, [])
if (navbarPosition === 'left') {
return (
<HashRouter>
<Sidebar />
{routes}
<NavigationHandler />
</HashRouter>
)
}
return (
<HashRouter>
<NavigationHandler />
<TabsContainer>{routes}</TabsContainer>
</HashRouter>
)
}
export default Router

View File

@ -1,7 +1,7 @@
import type { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins'
import { loggerService } from '@logger'
import { isGemini3Model, isSupportedThinkingTokenQwenModel } from '@renderer/config/models'
import type { MCPTool } from '@renderer/types'
import { isAnthropicModel, isGemini3Model, isSupportedThinkingTokenQwenModel } from '@renderer/config/models'
import type { McpMode, MCPTool } from '@renderer/types'
import { type Assistant, type Message, type Model, type Provider, SystemProviderIds } from '@renderer/types'
import type { Chunk } from '@renderer/types/chunk'
import { isOllamaProvider, isSupportEnableThinkingProvider } from '@renderer/utils/provider'
@ -10,6 +10,7 @@ import { extractReasoningMiddleware, simulateStreamingMiddleware } from 'ai'
import { getAiSdkProviderId } from '../provider/factory'
import { isOpenRouterGeminiGenerateImageModel } from '../utils/image'
import { anthropicCacheMiddleware } from './anthropicCacheMiddleware'
import { noThinkMiddleware } from './noThinkMiddleware'
import { openrouterGenerateImageMiddleware } from './openrouterGenerateImageMiddleware'
import { openrouterReasoningMiddleware } from './openrouterReasoningMiddleware'
@ -38,6 +39,7 @@ export interface AiSdkMiddlewareConfig {
enableWebSearch: boolean
enableGenerateImage: boolean
enableUrlContext: boolean
mcpMode?: McpMode
mcpTools?: MCPTool[]
uiMessages?: Message[]
// 内置搜索配置
@ -178,17 +180,21 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config:
// 根据不同provider添加特定中间件
switch (config.provider.type) {
case 'anthropic':
// Anthropic特定中间件
if (isAnthropicModel(config.model) && config.provider.anthropicCacheControl?.tokenThreshold) {
builder.add({
name: 'anthropic-cache',
middleware: anthropicCacheMiddleware(config.provider)
})
}
break
case 'openai':
case 'azure-openai': {
if (config.enableReasoning) {
const tagName = getReasoningTagName(config.model?.id.toLowerCase())
builder.add({
name: 'thinking-tag-extraction',
middleware: extractReasoningMiddleware({ tagName })
})
}
// 就算这里不传参数也有可能调用推理
const tagName = getReasoningTagName(config.model?.id.toLowerCase())
builder.add({
name: 'thinking-tag-extraction',
middleware: extractReasoningMiddleware({ tagName })
})
break
}
case 'gemini':

View File

@ -0,0 +1,79 @@
/**
* Anthropic Prompt Caching Middleware
* @see https://ai-sdk.dev/providers/ai-sdk-providers/anthropic#cache-control
*/
import { estimateTextTokens } from '@renderer/services/TokenService'
import type { Provider } from '@renderer/types'
import type { LanguageModelMiddleware } from 'ai'
const cacheProviderOptions = {
anthropic: { cacheControl: { type: 'ephemeral' } }
}
function estimateContentTokens(content: unknown): number {
if (typeof content === 'string') return estimateTextTokens(content)
if (Array.isArray(content)) {
return content.reduce((acc, part) => {
if (typeof part === 'object' && part !== null && 'text' in part) {
return acc + estimateTextTokens(part.text as string)
}
return acc
}, 0)
}
return 0
}
function addCacheToContentParts(content: unknown): unknown {
if (typeof content === 'string') {
return [{ type: 'text', text: content, providerOptions: cacheProviderOptions }]
}
if (Array.isArray(content) && content.length > 0) {
const result = [...content]
const last = result[result.length - 1]
if (typeof last === 'object' && last !== null) {
result[result.length - 1] = { ...last, providerOptions: cacheProviderOptions }
}
return result
}
return content
}
export function anthropicCacheMiddleware(provider: Provider): LanguageModelMiddleware {
return {
middlewareVersion: 'v2',
transformParams: async ({ params }) => {
const settings = provider.anthropicCacheControl
if (!settings?.tokenThreshold || !Array.isArray(params.prompt) || params.prompt.length === 0) {
return params
}
const { tokenThreshold, cacheSystemMessage, cacheLastNMessages } = settings
const messages = [...params.prompt]
let cachedCount = 0
// Cache system message (providerOptions on message object)
if (cacheSystemMessage) {
for (let i = 0; i < messages.length; i++) {
const msg = messages[i] as any
if (msg.role === 'system' && estimateContentTokens(msg.content) >= tokenThreshold) {
messages[i] = { ...msg, providerOptions: cacheProviderOptions }
break
}
}
}
// Cache last N non-system messages (providerOptions on content parts)
if (cacheLastNMessages > 0) {
for (let i = messages.length - 1; i >= 0 && cachedCount < cacheLastNMessages; i--) {
const msg = messages[i] as any
if (msg.role !== 'system' && estimateContentTokens(msg.content) >= tokenThreshold) {
messages[i] = { ...msg, content: addCacheToContentParts(msg.content) }
cachedCount++
}
}
}
return { ...params, prompt: messages }
}
}
}

View File

@ -47,6 +47,7 @@ export async function buildPlugins(
plugins.push(
createPromptToolUsePlugin({
enabled: true,
mcpMode: middlewareConfig.mcpMode,
createSystemMessage: (systemPrompt, params, context) => {
const modelId = typeof context.model === 'string' ? context.model : context.model.modelId
if (modelId.includes('o1-mini') || modelId.includes('o1-preview')) {

View File

@ -8,13 +8,13 @@ import { loggerService } from '@logger'
import { isImageEnhancementModel, isVisionModel } from '@renderer/config/models'
import type { Message, Model } from '@renderer/types'
import type { FileMessageBlock, ImageMessageBlock, ThinkingMessageBlock } from '@renderer/types/newMessage'
import { parseDataUrlMediaType } from '@renderer/utils/image'
import {
findFileBlocks,
findImageBlocks,
findThinkingBlocks,
getMainTextContent
} from '@renderer/utils/messageUtils/find'
import { parseDataUrl } from '@shared/utils'
import type {
AssistantModelMessage,
FilePart,
@ -69,18 +69,16 @@ async function convertImageBlockToImagePart(imageBlocks: ImageMessageBlock[]): P
}
} else if (imageBlock.url) {
const url = imageBlock.url
const isDataUrl = url.startsWith('data:')
if (isDataUrl) {
const { mediaType } = parseDataUrlMediaType(url)
const commaIndex = url.indexOf(',')
if (commaIndex === -1) {
logger.error('Malformed data URL detected (missing comma separator), image will be excluded:', {
urlPrefix: url.slice(0, 50) + '...'
})
continue
}
const base64Data = url.slice(commaIndex + 1)
parts.push({ type: 'image', image: base64Data, ...(mediaType ? { mediaType } : {}) })
const parseResult = parseDataUrl(url)
if (parseResult?.isBase64) {
const { mediaType, data } = parseResult
parts.push({ type: 'image', image: data, ...(mediaType ? { mediaType } : {}) })
} else if (url.startsWith('data:')) {
// Malformed data URL or non-base64 data URL
logger.error('Malformed or non-base64 data URL detected, image will be excluded:', {
urlPrefix: url.slice(0, 50) + '...'
})
continue
} else {
// For remote URLs we keep payload minimal to match existing expectations.
parts.push({ type: 'image', image: url })

View File

@ -26,11 +26,13 @@ import {
isSupportedThinkingTokenModel,
isWebSearchModel
} from '@renderer/config/models'
import { getHubModeSystemPrompt } from '@renderer/config/prompts-code-mode'
import { fetchAllActiveServerTools } from '@renderer/services/ApiService'
import { getDefaultModel } from '@renderer/services/AssistantService'
import store from '@renderer/store'
import type { CherryWebSearchConfig } from '@renderer/store/websearch'
import type { Model } from '@renderer/types'
import { type Assistant, type MCPTool, type Provider, SystemProviderIds } from '@renderer/types'
import { type Assistant, getEffectiveMcpMode, type MCPTool, type Provider, SystemProviderIds } from '@renderer/types'
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
import { replacePromptVariables } from '@renderer/utils/prompt'
@ -243,8 +245,18 @@ export async function buildStreamTextParams(
params.tools = tools
}
if (assistant.prompt) {
params.system = await replacePromptVariables(assistant.prompt, model.name)
let systemPrompt = assistant.prompt ? await replacePromptVariables(assistant.prompt, model.name) : ''
if (getEffectiveMcpMode(assistant) === 'auto') {
const allActiveTools = await fetchAllActiveServerTools()
const autoModePrompt = getHubModeSystemPrompt(allActiveTools)
if (autoModePrompt) {
systemPrompt = systemPrompt ? `${systemPrompt}\n\n${autoModePrompt}` : autoModePrompt
}
}
if (systemPrompt) {
params.system = systemPrompt
}
logger.debug('params', params)

View File

@ -1,5 +1,5 @@
import { formatPrivateKey, hasProviderConfig, ProviderConfigFactory } from '@cherrystudio/ai-core/provider'
import { isOpenAIChatCompletionOnlyModel } from '@renderer/config/models'
import { isOpenAIChatCompletionOnlyModel, isOpenAIReasoningModel } from '@renderer/config/models'
import {
getAwsBedrockAccessKeyId,
getAwsBedrockApiKey,
@ -29,6 +29,7 @@ import {
isNewApiProvider,
isOllamaProvider,
isPerplexityProvider,
isSupportDeveloperRoleProvider,
isSupportStreamOptionsProvider,
isVertexProvider
} from '@renderer/utils/provider'
@ -264,6 +265,14 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A
}
}
// Apply developer-to-system role conversion for providers that don't support developer role
// bug: https://github.com/vercel/ai/issues/10982
// fixPR: https://github.com/vercel/ai/pull/11127
// TODO: but the PR don't backport to v5, the code will be removed when upgrading to v6
if (!isSupportDeveloperRoleProvider(actualProvider) || !isOpenAIReasoningModel(model)) {
extraOptions.fetch = createDeveloperToSystemFetch(extraOptions.fetch)
}
if (hasProviderConfig(aiSdkProviderId) && aiSdkProviderId !== 'openai-compatible') {
const options = ProviderConfigFactory.fromProvider(aiSdkProviderId, baseConfig, extraOptions)
return {
@ -302,6 +311,44 @@ export function isModernSdkSupported(provider: Provider): boolean {
return hasProviderConfig(aiSdkProviderId)
}
/**
* Creates a custom fetch wrapper that converts 'developer' role to 'system' role in request body.
* This is needed for providers that don't support the 'developer' role (e.g., Azure DeepSeek R1).
*
* @param originalFetch - Optional original fetch function to wrap
* @returns A fetch function that transforms the request body
*/
function createDeveloperToSystemFetch(originalFetch?: typeof fetch): typeof fetch {
const baseFetch = originalFetch ?? fetch
return async (input: RequestInfo | URL, init?: RequestInit) => {
let options = init
if (options?.body && typeof options.body === 'string') {
try {
const body = JSON.parse(options.body)
if (body.messages && Array.isArray(body.messages)) {
let hasChanges = false
body.messages = body.messages.map((msg: { role: string }) => {
if (msg.role === 'developer') {
hasChanges = true
return { ...msg, role: 'system' }
}
return msg
})
if (hasChanges) {
options = {
...options,
body: JSON.stringify(body)
}
}
}
} catch {
// If parsing fails, just use original body
}
}
return baseFetch(input, options)
}
}
/**
* provider的配置,
*/
@ -360,5 +407,6 @@ export async function prepareSpecialProviderConfig(
}
}
}
return config
}

View File

@ -3,7 +3,7 @@ import { type AnthropicProviderOptions } from '@ai-sdk/anthropic'
import type { GoogleGenerativeAIProviderOptions } from '@ai-sdk/google'
import type { OpenAIResponsesProviderOptions } from '@ai-sdk/openai'
import type { XaiProviderOptions } from '@ai-sdk/xai'
import { baseProviderIdSchema, customProviderIdSchema } from '@cherrystudio/ai-core/provider'
import { baseProviderIdSchema, customProviderIdSchema, hasProviderConfig } from '@cherrystudio/ai-core/provider'
import { loggerService } from '@logger'
import {
getModelSupportedVerbosity,
@ -616,9 +616,14 @@ function buildGenericProviderOptions(
}
if (enableReasoning) {
if (isInterleavedThinkingModel(model)) {
providerOptions = {
...providerOptions,
sendReasoning: true
// sendReasoning is a patch specific to @ai-sdk/openai-compatible
// Only apply when provider will actually use openai-compatible SDK
// (i.e., no dedicated SDK registered OR explicitly openai-compatible)
if (!hasProviderConfig(providerId) || providerId === 'openai-compatible') {
providerOptions = {
...providerOptions,
sendReasoning: true
}
}
}
}

View File

@ -118,6 +118,11 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
return { thinking: { type: 'disabled' } }
}
// Deepseek, default behavior is non-thinking
if (isDeepSeekHybridInferenceModel(model)) {
return {}
}
// GPT 5.1, GPT 5.2, or newer
if (isSupportNoneReasoningEffortModel(model)) {
return {

View File

@ -30,13 +30,13 @@ export const FreeTrialModelTag: FC<Props> = ({ model, showLabel = true }) => {
}
const onSelectProvider = () => {
NavigationService.navigate!(`/settings/provider?id=${providerId}`)
NavigationService.navigate!({ to: `/settings/provider`, search: { id: providerId } })
}
const onNavigateProvider = (e: MouseEvent) => {
e.stopPropagation()
SelectModelPopup.hide()
NavigationService.navigate!(`/settings/provider?id=${providerId}`)
NavigationService.navigate?.({ to: '/settings/provider', search: { id: providerId } })
}
if (!showLabel) {

View File

@ -11,6 +11,7 @@ import {
import { loggerService } from '@logger'
import { download } from '@renderer/utils/download'
import { convertImageToPng } from '@renderer/utils/image'
import { parseDataUrl } from '@shared/utils'
import type { ImageProps as AntImageProps } from 'antd'
import { Dropdown, Image as AntImage, Space } from 'antd'
import { Base64 } from 'js-base64'
@ -37,12 +38,13 @@ const ImageViewer: React.FC<ImageViewerProps> = ({ src, style, ...props }) => {
let blob: Blob
if (src.startsWith('data:')) {
// 处理 base64 格式的图片
const match = src.match(/^data:(image\/\w+);base64,(.+)$/)
if (!match) throw new Error('Invalid base64 image format')
const mimeType = match[1]
const byteArray = Base64.toUint8Array(match[2])
blob = new Blob([byteArray as unknown as BlobPart], { type: mimeType })
// 处理 base64 格式的图片 - 使用 parseDataUrl 避免正则匹配大字符串导致OOM
const parseResult = parseDataUrl(src)
if (!parseResult || !parseResult.mediaType || !parseResult.isBase64) {
throw new Error('Invalid base64 image format')
}
const byteArray = Base64.toUint8Array(parseResult.data)
blob = new Blob([byteArray as unknown as BlobPart], { type: parseResult.mediaType })
} else if (src.startsWith('file://')) {
// 处理本地文件路径
const bytes = await window.api.fs.read(src)

View File

@ -6,11 +6,11 @@ import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { useNavbarPosition } from '@renderer/hooks/useNavbar'
import type { MinAppType } from '@renderer/types'
import { useNavigate } from '@tanstack/react-router'
import type { MenuProps } from 'antd'
import { Dropdown } from 'antd'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import styled from 'styled-components'
interface Props {
@ -47,7 +47,7 @@ const MinApp: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
const handleClick = () => {
if (isTopNavbar) {
// 顶部导航栏:导航到小程序页面
navigate(`/apps/${app.id}`)
navigate({ to: '/app/minapp/$appId', params: { appId: app.id } })
} else {
// 侧边导航栏:保持原有弹窗行为
openMinappKeepAlive(app)

View File

@ -3,9 +3,9 @@ import WebviewContainer from '@renderer/components/MinApp/WebviewContainer'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { useNavbarPosition } from '@renderer/hooks/useNavbar'
import { getWebviewLoaded, setWebviewLoaded } from '@renderer/utils/webviewStateManager'
import { useLocation } from '@tanstack/react-router'
import type { WebviewTag } from 'electron'
import React, { useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import styled from 'styled-components'
/**
@ -31,10 +31,10 @@ const MinAppTabsPool: React.FC = () => {
// 使用集中工具进行更稳健的路由判断
const isAppDetail = (() => {
const pathname = location.pathname
if (pathname === '/apps') return false
if (!pathname.startsWith('/apps/')) return false
const parts = pathname.split('/').filter(Boolean) // ['apps', '<id>', ...]
return parts.length >= 2
if (pathname === '/app/minapp') return false
if (!pathname.startsWith('/app/minapp/')) return false
const parts = pathname.split('/').filter(Boolean) // ['app', 'minapp', '<id>', ...]
return parts.length >= 3
})()
const shouldShow = isTopNavbar && isAppDetail

View File

@ -193,7 +193,7 @@ const PopupContainer: React.FC<Props> = ({ model, filter: baseFilter, showTagFil
e.stopPropagation()
setOpen(false)
resolve(undefined)
window.navigate(`/settings/provider?id=${p.id}`)
window.navigate({ to: '/settings/provider', search: { id: p.id } })
}}
/>
</Tooltip>

View File

@ -580,7 +580,7 @@ const RichEditor = ({
<GripVertical />
</Tooltip>
</DragHandle>
<EditorContent style={{ height: '100%' }} editor={editor} />
<EditorContent style={{ minHeight: '100%' }} editor={editor} />
</StyledEditorContent>
</Scrollbar>
{enableContentSearch && (

View File

@ -17,6 +17,7 @@ import { addTab, removeTab, setActiveTab, setTabs } from '@renderer/store/tabs'
import type { MinAppType } from '@renderer/types'
import { classNames } from '@renderer/utils'
import { ThemeMode } from '@shared/data/preference/preferenceTypes'
import { useLocation, useNavigate } from '@tanstack/react-router'
import type { LRUCache } from 'lru-cache'
import {
FileSearch,
@ -36,7 +37,6 @@ import {
} from 'lucide-react'
import { useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
import MinAppIcon from '../Icons/MinAppIcon'
@ -200,17 +200,17 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
const handleAddTab = () => {
hideMinappPopup()
navigate('/launchpad')
navigate({ to: '/launchpad' })
}
const handleSettingsClick = () => {
hideMinappPopup()
navigate(lastSettingsPath)
navigate({ to: lastSettingsPath })
}
const handleTabClick = (tab: Tab) => {
hideMinappPopup()
navigate(tab.path)
navigate({ to: tab.path })
}
const visibleTabs = useMemo(() => tabs.filter((tab) => !specialTabs.includes(tab.id)), [tabs])

View File

@ -12,6 +12,7 @@ import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
import { useSettings } from '@renderer/hooks/useSettings'
import { getSidebarIconLabel, getThemeModeLabel } from '@renderer/i18n/label'
import { isEmoji } from '@renderer/utils'
import { getDefaultRouteTitle } from '@renderer/utils/routeTitle'
import { ThemeMode } from '@shared/data/preference/preferenceTypes'
import {
Code,
@ -30,9 +31,9 @@ import {
} from 'lucide-react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
import { useTabs } from '../../hooks/useTabs'
import UserPopup from '../Popups/UserPopup'
import { SidebarOpenedMinappTabs, SidebarPinnedApps } from './PinnedMinapps'
@ -40,9 +41,11 @@ const Sidebar: FC = () => {
const { hideMinappPopup } = useMinappPopup()
const { pinned, minappShow } = useMinapps()
const [visibleSidebarIcons] = usePreference('ui.sidebar.icons.visible')
const { tabs, activeTabId, updateTab } = useTabs()
const { pathname } = useLocation()
const navigate = useNavigate()
// 获取当前 Tab 的 URL 作为 pathname
const activeTab = tabs.find((t) => t.id === activeTabId)
const pathname = activeTab?.url || '/'
const { theme, settedTheme, toggleTheme } = useTheme()
const avatar = useAvatar()
@ -54,9 +57,12 @@ const Sidebar: FC = () => {
const showPinnedApps = pinned.length > 0 && visibleSidebarIcons.includes('minapp')
// 在当前 Tab 内跳转
const to = async (path: string) => {
await modelGenerating()
navigate(path)
if (activeTabId) {
updateTab(activeTabId, { url: path, title: getDefaultRouteTitle(path) })
}
}
const isFullscreen = useFullscreen()
@ -118,14 +124,16 @@ const Sidebar: FC = () => {
const MainMenus: FC = () => {
const { hideMinappPopup } = useMinappPopup()
const { minappShow } = useMinapps()
const { tabs, activeTabId, updateTab } = useTabs()
// 获取当前 Tab 的 URL 作为 pathname
const activeTab = tabs.find((t) => t.id === activeTabId)
const pathname = activeTab?.url || '/'
const { pathname } = useLocation()
const [visibleSidebarIcons] = usePreference('ui.sidebar.icons.visible')
const { defaultPaintingProvider } = useSettings()
const navigate = useNavigate()
const { theme } = useTheme()
const isRoute = (path: string): string => (pathname === path && !minappShow ? 'active' : '')
const isRoutes = (path: string): string => (pathname.startsWith(path) && !minappShow ? 'active' : '')
const iconMap = {
@ -141,28 +149,35 @@ const MainMenus: FC = () => {
}
const pathMap = {
assistants: '/',
store: '/store',
paintings: `/paintings/${defaultPaintingProvider}`,
translate: '/translate',
minapp: '/apps',
knowledge: '/knowledge',
files: '/files',
code_tools: '/code',
notes: '/notes'
assistants: '/app/chat',
store: '/app/assistant',
paintings: `/app/paintings/${defaultPaintingProvider}`,
translate: '/app/translate',
minapp: '/app/minapp',
knowledge: '/app/knowledge',
files: '/app/files',
code_tools: '/app/code',
notes: '/app/notes'
}
// 在当前 Tab 内跳转
const to = async (path: string) => {
await modelGenerating()
if (activeTabId) {
updateTab(activeTabId, { url: path, title: getDefaultRouteTitle(path) })
}
}
return visibleSidebarIcons.map((icon) => {
const path = pathMap[icon]
const isActive = path === '/' ? isRoute(path) : isRoutes(path)
const isActive = isRoutes(path)
return (
<Tooltip key={icon} placement="right" content={getSidebarIconLabel(icon)} delay={800}>
<StyledLink
onClick={async () => {
hideMinappPopup()
await modelGenerating()
navigate(path)
await to(path)
}}>
<Icon theme={theme} className={isActive}>
{iconMap[icon]}

View File

@ -0,0 +1,111 @@
import '@renderer/databases'
import { Tabs, TabsList, TabsTrigger } from '@cherrystudio/ui'
import { cn } from '@renderer/utils'
import { getDefaultRouteTitle } from '@renderer/utils/routeTitle'
import { Plus, X } from 'lucide-react'
import { Activity } from 'react'
import { v4 as uuid } from 'uuid'
import { useTabs } from '../../hooks/useTabs'
import Sidebar from '../app/Sidebar'
import { TabRouter } from './TabRouter'
// Mock Webview component (TODO: Replace with actual MinApp/Webview)
const WebviewContainer = ({ url, isActive }: { url: string; isActive: boolean }) => (
<Activity mode={isActive ? 'visible' : 'hidden'}>
<div className="flex h-full w-full flex-col items-center justify-center bg-background">
<div className="mb-2 font-bold text-lg">Webview App</div>
<code className="rounded bg-muted p-2">{url}</code>
</div>
</Activity>
)
export const AppShell = () => {
const { tabs, activeTabId, setActiveTab, closeTab, updateTab, addTab } = useTabs()
// Sync internal navigation back to tab state with default title (url may include search/hash)
const handleUrlChange = (tabId: string, url: string) => {
updateTab(tabId, { url, title: getDefaultRouteTitle(url) })
}
// 新增 Tab默认打开首页
const handleAddTab = () => {
addTab({
id: uuid(),
type: 'route',
url: '/',
title: getDefaultRouteTitle('/')
})
}
return (
<div className="flex h-screen w-screen flex-row overflow-hidden bg-background text-foreground">
{/* Zone 1: Sidebar */}
<Sidebar />
<div className="flex h-full w-full flex-1 flex-col overflow-hidden">
{/* Zone 2: Tab Bar */}
<Tabs value={activeTabId} onValueChange={setActiveTab} variant="line" className="w-full">
<header className="flex h-10 w-full items-center border-b bg-muted/5">
<TabsList className="flex h-full min-w-0 flex-1 justify-start gap-0 overflow-hidden">
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className={cn(
'group relative flex h-full min-w-0 max-w-[200px] flex-1 items-center justify-between gap-2 rounded-none border-r px-3 text-sm',
tab.id === activeTabId ? 'bg-background' : 'bg-transparent'
)}>
{/* TODO: pin功能,形式还未确定 */}
<span className={cn('truncate text-xs', tab.isDormant && 'opacity-60')}>{tab.title}</span>
{tabs.length > 1 && (
<div
role="button"
onClick={(e) => {
e.stopPropagation()
closeTab(tab.id)
}}
className="ml-1 cursor-pointer rounded-sm p-0.5 opacity-0 hover:bg-muted-foreground/20 hover:opacity-100 group-hover:opacity-50">
<X className="size-3" />
</div>
)}
</TabsTrigger>
))}
{/* 新增 Tab 按钮 - 跟随最后一个 Tab */}
<button
type="button"
onClick={handleAddTab}
className="flex h-full shrink-0 items-center justify-center px-3 hover:bg-muted/50"
title="New Tab">
<Plus className="size-4" />
</button>
</TabsList>
</header>
</Tabs>
{/* Zone 3: Content Area - Multi MemoryRouter Architecture */}
<main className="relative flex-1 overflow-hidden bg-background">
{/* Route Tabs: Only render non-dormant tabs */}
{tabs
.filter((t) => t.type === 'route' && !t.isDormant)
.map((tab) => (
<TabRouter
key={tab.id}
tab={tab}
isActive={tab.id === activeTabId}
onUrlChange={(url) => handleUrlChange(tab.id, url)}
/>
))}
{/* Webview Tabs: Only render non-dormant tabs */}
{tabs
.filter((t) => t.type === 'webview' && !t.isDormant)
.map((tab) => (
<WebviewContainer key={tab.id} url={tab.url} isActive={tab.id === activeTabId} />
))}
</main>
</div>
</div>
)
}

View File

@ -0,0 +1,52 @@
import type { Tab } from '@shared/data/cache/cacheValueTypes'
import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router'
import { Activity } from 'react'
import { useEffect, useMemo } from 'react'
import { routeTree } from '../../routeTree.gen'
interface TabRouterProps {
tab: Tab
isActive: boolean
onUrlChange: (url: string) => void
}
/**
* TabRouter - Independent MemoryRouter for each Tab
*
* Each tab maintains its own router instance with isolated history,
* enabling true KeepAlive behavior via React 19's Activity component.
*/
export const TabRouter = ({ tab, isActive, onUrlChange }: TabRouterProps) => {
// Create independent router instance per tab (only once)
const router = useMemo(() => {
const history = createMemoryHistory({ initialEntries: [tab.url] })
return createRouter({ routeTree, history })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab.id])
// Sync internal navigation back to tab state
useEffect(() => {
return router.subscribe('onResolved', ({ toLocation }) => {
const nextHref = toLocation.href
if (nextHref !== tab.url) {
onUrlChange(nextHref)
}
})
}, [router, tab.url, onUrlChange])
// Navigate when tab.url changes externally (e.g., from Sidebar)
useEffect(() => {
const currentHref = router.state.location.href
if (tab.url !== currentHref) {
router.navigate({ to: tab.url })
}
}, [router, tab.url])
return (
<Activity mode={isActive ? 'visible' : 'hidden'}>
<div className="h-full w-full">
<RouterProvider router={router} />
</div>
</Activity>
)
}

View File

@ -745,7 +745,7 @@ describe('getThinkModelType - Comprehensive Coverage', () => {
})
it('should return doubao_after_251015 for Doubao-Seed-1.8 models', () => {
expect(getThinkModelType(createModel({ id: 'doubao-seed-1-8-251215' }))).toBe('doubao_after_251015')
expect(getThinkModelType(createModel({ id: 'doubao-seed-1-8-251228' }))).toBe('doubao_after_251015')
expect(getThinkModelType(createModel({ id: 'doubao-seed-1.8' }))).toBe('doubao_after_251015')
})
@ -879,7 +879,7 @@ describe('getThinkModelType - Comprehensive Coverage', () => {
// auto > after_251015 > no_auto
expect(getThinkModelType(createModel({ id: 'doubao-seed-1.6' }))).toBe('doubao')
expect(getThinkModelType(createModel({ id: 'doubao-seed-1-6-251015' }))).toBe('doubao_after_251015')
expect(getThinkModelType(createModel({ id: 'doubao-seed-1-8-251215' }))).toBe('doubao_after_251015')
expect(getThinkModelType(createModel({ id: 'doubao-seed-1-8-251228' }))).toBe('doubao_after_251015')
expect(getThinkModelType(createModel({ id: 'doubao-1.5-thinking-vision-pro' }))).toBe('doubao_no_auto')
})
@ -1368,7 +1368,9 @@ describe('findTokenLimit', () => {
{ modelId: 'qwen-plus-ultra', expected: { min: 0, max: 81_920 } },
{ modelId: 'qwen-turbo-pro', expected: { min: 0, max: 38_912 } },
{ modelId: 'qwen-flash-lite', expected: { min: 0, max: 81_920 } },
{ modelId: 'qwen3-7b', expected: { min: 1_024, max: 38_912 } }
{ modelId: 'qwen3-7b', expected: { min: 1_024, max: 38_912 } },
{ modelId: 'Baichuan-M2', expected: { min: 0, max: 30_000 } },
{ modelId: 'baichuan-m2', expected: { min: 0, max: 30_000 } }
]
it.each(cases)('returns correct limits for $modelId', ({ modelId, expected }) => {

View File

@ -713,6 +713,30 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
provider: 'baichuan',
name: 'Baichuan3 Turbo 128k',
group: 'Baichuan3'
},
{
id: 'Baichuan4-Turbo',
provider: 'baichuan',
name: 'Baichuan4 Turbo',
group: 'Baichuan4'
},
{
id: 'Baichuan4-Air',
provider: 'baichuan',
name: 'Baichuan4 Air',
group: 'Baichuan4'
},
{
id: 'Baichuan-M2',
provider: 'baichuan',
name: 'Baichuan M2',
group: 'Baichuan-M2'
},
{
id: 'Baichuan-M2-Plus',
provider: 'baichuan',
name: 'Baichuan M2 Plus',
group: 'Baichuan-M2'
}
],
modelscope: [
@ -771,7 +795,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
],
doubao: [
{
id: 'doubao-seed-1-8-251215',
id: 'doubao-seed-1-8-251228',
provider: 'doubao',
name: 'Doubao-Seed-1.8',
group: 'Doubao-Seed-1.8'

View File

@ -640,6 +640,16 @@ export const isMiniMaxReasoningModel = (model?: Model): boolean => {
return (['minimax-m1', 'minimax-m2', 'minimax-m2.1'] as const).some((id) => modelId.includes(id))
}
export const isBaichuanReasoningModel = (model?: Model): boolean => {
if (!model) {
return false
}
const modelId = getLowerBaseModelName(model.id, '/')
// 只有 Baichuan-M2 是推理模型注意M2-Plus 不是推理模型)
return modelId.includes('baichuan-m2') && !modelId.includes('plus')
}
export function isReasoningModel(model?: Model): boolean {
if (!model || isEmbeddingModel(model) || isRerankModel(model) || isTextToImageModel(model)) {
return false
@ -675,6 +685,7 @@ export function isReasoningModel(model?: Model): boolean {
isLingReasoningModel(model) ||
isMiniMaxReasoningModel(model) ||
isMiMoReasoningModel(model) ||
isBaichuanReasoningModel(model) ||
modelId.includes('magistral') ||
modelId.includes('pangu-pro-moe') ||
modelId.includes('seed-oss') ||
@ -718,7 +729,10 @@ const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> = {
'(?:anthropic\\.)?claude-opus-4(?:[.-]0)?(?:[@-](?:\\d{4,}|[a-z][\\w-]*))?(?:-v\\d+:\\d+)?$': {
min: 1024,
max: 32_000
}
},
// Baichuan models
'baichuan-m2$': { min: 0, max: 30_000 }
}
export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => {

View File

@ -0,0 +1,174 @@
import { generateMcpToolFunctionName } from '@shared/mcp'
export interface ToolInfo {
name: string
serverName?: string
description?: string
}
/**
* Hub Mode System Prompt - For native MCP tool calling
* Used when model supports native function calling via MCP protocol
*/
const HUB_MODE_SYSTEM_PROMPT_BASE = `
## Hub MCP Tools Code Execution Mode
You can discover and call MCP tools through the hub server using **ONLY two meta-tools**: **search** and **exec**.
### IMPORTANT: You can ONLY call these two tools directly
| Tool | Purpose |
|------|---------|
| \`search\` | Discover available tools and their signatures |
| \`exec\` | Execute JavaScript code that calls the discovered tools |
**All other tools (listed in "Discoverable Tools" below) can ONLY be called from INSIDE \`exec\` code.**
You CANNOT call them directly as tool calls. They are async functions available within the \`exec\` runtime.
### Critical Rules (Read First)
1. **ONLY \`search\` and \`exec\` are callable as tools.** All other tools must be used inside \`exec\` code.
2. You MUST explicitly \`return\` the final value from your \`exec\` code. If you do not return a value, the result will be \`undefined\`.
3. All MCP tools inside \`exec\` are async functions. Always call them as \`await ToolName(params)\`.
4. Use the exact function names and parameter shapes returned by \`search\`.
5. You CANNOT call \`search\` or \`exec\` from inside \`exec\` code—use them only as direct tool calls.
6. \`console.log\` output is NOT the result. Logs are separate; the final answer must come from \`return\`.
### Workflow
1. Call \`search\` with relevant keywords to discover tools.
2. Read the returned JavaScript function declarations and JSDoc to understand names and parameters.
3. Call \`exec\` with JavaScript code that uses the discovered tools and ends with an explicit \`return\`.
4. Use the \`exec\` result as your answer.
### What \`search\` Does
- Input: keyword string (comma-separated for OR-matching), plus optional \`limit\`.
- Output: JavaScript async function declarations with JSDoc showing exact function names, parameters, and return types.
### What \`exec\` Does
- Runs JavaScript code in an isolated async context (wrapped as \`(async () => { your code })())\`.
- All discovered tools are exposed as async functions: \`await ToolName(params)\`.
- Available helpers:
- \`parallel(...promises)\`\`Promise.all(promises)\`
- \`settle(...promises)\`\`Promise.allSettled(promises)\`
- \`console.log/info/warn/error/debug\`
- Returns JSON with: \`result\` (your returned value), \`logs\` (optional), \`error\` (optional), \`isError\` (optional).
### Example: Single Tool Call
\`\`\`javascript
// Step 1: search({ query: "browser,fetch" })
// Step 2: exec with:
const page = await CherryBrowser_fetch({ url: "https://example.com" })
return page
\`\`\`
### Example: Multiple Tools with Parallel
\`\`\`javascript
const [forecast, time] = await parallel(
Weather_getForecast({ city: "Paris" }),
Time_getLocalTime({ city: "Paris" })
)
return { city: "Paris", forecast, time }
\`\`\`
### Example: Handle Partial Failures with Settle
\`\`\`javascript
const results = await settle(
Weather_getForecast({ city: "Paris" }),
Weather_getForecast({ city: "Tokyo" })
)
const successful = results.filter(r => r.status === "fulfilled").map(r => r.value)
return { results, successful }
\`\`\`
### Example: Error Handling
\`\`\`javascript
try {
const user = await User_lookup({ email: "user@example.com" })
return { found: true, user }
} catch (error) {
return { found: false, error: String(error) }
}
\`\`\`
### Common Mistakes to Avoid
**Forgetting to return** (result will be \`undefined\`):
\`\`\`javascript
const data = await SomeTool({ id: "123" })
// Missing return!
\`\`\`
**Always return**:
\`\`\`javascript
const data = await SomeTool({ id: "123" })
return data
\`\`\`
**Only logging, not returning**:
\`\`\`javascript
const data = await SomeTool({ id: "123" })
console.log(data) // Logs are NOT the result!
\`\`\`
**Missing await**:
\`\`\`javascript
const data = SomeTool({ id: "123" }) // Returns Promise, not value!
return data
\`\`\`
**Awaiting before parallel**:
\`\`\`javascript
await parallel(await ToolA(), await ToolB()) // Wrong: runs sequentially
\`\`\`
**Pass promises directly to parallel**:
\`\`\`javascript
await parallel(ToolA(), ToolB()) // Correct: runs in parallel
\`\`\`
### Best Practices
- Always call \`search\` first to discover tools and confirm signatures.
- Always use an explicit \`return\` at the end of \`exec\` code.
- Use \`parallel\` for independent operations that can run at the same time.
- Use \`settle\` when some calls may fail but you still want partial results.
- Prefer a single \`exec\` call for multi-step flows.
- Treat \`console.*\` as debugging only, never as the primary result.
`
function buildToolsSection(tools: ToolInfo[]): string {
const existingNames = new Set<string>()
return tools
.map((t) => {
const functionName = generateMcpToolFunctionName(t.serverName, t.name, existingNames)
const desc = t.description || ''
const normalizedDesc = desc.replace(/\s+/g, ' ').trim()
const truncatedDesc = normalizedDesc.length > 50 ? `${normalizedDesc.slice(0, 50)}...` : normalizedDesc
return `- ${functionName}: ${truncatedDesc}`
})
.join('\n')
}
export function getHubModeSystemPrompt(tools: ToolInfo[] = []): string {
if (tools.length === 0) {
return ''
}
const toolsSection = buildToolsSection(tools)
return `${HUB_MODE_SYSTEM_PROMPT_BASE}
## Discoverable Tools (ONLY usable inside \`exec\` code, NOT as direct tool calls)
The following tools are available inside \`exec\`. Use \`search\` to get their full signatures.
Do NOT call these directlywrap them in \`exec\` code.
${toolsSection}
`
}

View File

@ -1025,7 +1025,7 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
official: 'https://www.baichuan-ai.com/',
apiKey: 'https://platform.baichuan-ai.com/console/apikey',
docs: 'https://platform.baichuan-ai.com/docs',
models: 'https://platform.baichuan-ai.com/price'
models: 'https://platform.baichuan-ai.com/prices'
}
},
modelscope: {

View File

@ -27,7 +27,14 @@ import type {
UseCacheKey
} from '@shared/data/cache/cacheSchemas'
import { DefaultRendererPersistCache } from '@shared/data/cache/cacheSchemas'
import type { CacheEntry, CacheSubscriber, CacheSyncMessage } from '@shared/data/cache/cacheTypes'
import type {
CacheEntry,
CacheEntryDetail,
CacheStats,
CacheSubscriber,
CacheSyncMessage,
CacheTierSummary
} from '@shared/data/cache/cacheTypes'
const STORAGE_PERSIST_KEY = 'cs_cache_persist'
@ -56,8 +63,8 @@ export class CacheService {
private sharedCache = new Map<string, CacheEntry>() // Cross-window cache (local copy)
private persistCache = new Map<RendererPersistCacheKey, any>() // Persistent cache
// Hook reference tracking
private activeHooks = new Set<string>()
// Hook reference tracking (reference-counted)
private activeHookCounts = new Map<string, number>()
// Subscription management
private subscribers = new Map<string, Set<CacheSubscriber>>()
@ -346,7 +353,7 @@ export class CacheService {
*/
private deleteInternal(key: string): boolean {
// Check if key is being used by hooks
if (this.activeHooks.has(key)) {
if (this.activeHookCounts.get(key)) {
logger.error(`Cannot delete key "${key}" as it's being used by useCache hook`)
return false
}
@ -574,7 +581,7 @@ export class CacheService {
*/
private deleteSharedInternal(key: string): boolean {
// Check if key is being used by hooks
if (this.activeHooks.has(key)) {
if (this.activeHookCounts.get(key)) {
logger.error(`Cannot delete key "${key}" as it's being used by useSharedCache hook`)
return false
}
@ -666,7 +673,8 @@ export class CacheService {
* @param key - Cache key being used by the hook
*/
registerHook(key: string): void {
this.activeHooks.add(key)
const currentCount = this.activeHookCounts.get(key) ?? 0
this.activeHookCounts.set(key, currentCount + 1)
}
/**
@ -674,7 +682,195 @@ export class CacheService {
* @param key - Cache key no longer being used by the hook
*/
unregisterHook(key: string): void {
this.activeHooks.delete(key)
const currentCount = this.activeHookCounts.get(key)
if (!currentCount) {
return
}
if (currentCount === 1) {
this.activeHookCounts.delete(key)
return
}
this.activeHookCounts.set(key, currentCount - 1)
}
// ============ Statistics ============
/**
* Get comprehensive statistics about all cache tiers
*
* @param includeDetails - Whether to include per-entry details (default: false)
* @returns Cache statistics with summary and optional details
*
* @example
* ```typescript
* // Get summary only (fast)
* const stats = cacheService.getStats()
* console.log(`Memory cache: ${stats.summary.memory.validCount} valid entries`)
*
* // Get full details (for debugging)
* const fullStats = cacheService.getStats(true)
* fullStats.details.memory.forEach(entry => {
* if (entry.isExpired) console.log(`Expired: ${entry.key}`)
* })
* ```
*/
public getStats(includeDetails: boolean = false): CacheStats {
const now = Date.now()
// Process memory and shared cache tiers
const memory = this.processCacheTier(this.memoryCache, now, includeDetails)
const shared = this.processCacheTier(this.sharedCache, now, includeDetails)
const persist = this.processPersistTier(includeDetails)
// Calculate totals
const totalBytes = memory.summary.estimatedBytes + shared.summary.estimatedBytes + persist.summary.estimatedBytes
const total = {
totalCount: memory.summary.totalCount + shared.summary.totalCount + persist.summary.totalCount,
validCount: memory.summary.validCount + shared.summary.validCount + persist.summary.validCount,
expiredCount: memory.summary.expiredCount + shared.summary.expiredCount,
withTTLCount: memory.summary.withTTLCount + shared.summary.withTTLCount,
hookReferences: memory.summary.hookReferences + shared.summary.hookReferences + persist.summary.hookReferences,
estimatedBytes: totalBytes,
estimatedSize: this.formatBytes(totalBytes)
}
return {
collectedAt: now,
summary: {
memory: memory.summary,
shared: shared.summary,
persist: persist.summary,
total
},
details: {
memory: memory.details,
shared: shared.details,
persist: persist.details
}
}
}
/**
* Process a cache tier (memory or shared) and collect statistics
*/
private processCacheTier(
cache: Map<string, CacheEntry>,
now: number,
includeDetails: boolean
): { summary: CacheTierSummary; details: CacheEntryDetail[] } {
let validCount = 0
let expiredCount = 0
let withTTLCount = 0
let hookReferences = 0
let estimatedBytes = 0
const details: CacheEntryDetail[] = []
for (const [key, entry] of cache.entries()) {
const hasTTL = entry.expireAt !== undefined
const isExpired = hasTTL && now > entry.expireAt!
const hookCount = this.activeHookCounts.get(key) ?? 0
// Estimate memory: key size + value size + metadata overhead
estimatedBytes += this.estimateSize(key) + this.estimateSize(entry.value)
if (entry.expireAt) estimatedBytes += 8 // number size
if (hasTTL) withTTLCount++
if (isExpired) {
expiredCount++
} else {
validCount++
}
hookReferences += hookCount
if (includeDetails) {
details.push({
key,
hasValue: entry.value !== undefined,
hasTTL,
isExpired,
expireAt: entry.expireAt,
remainingTTL: hasTTL && !isExpired ? entry.expireAt! - now : undefined,
hookCount
})
}
}
return {
summary: {
totalCount: cache.size,
validCount,
expiredCount,
withTTLCount,
hookReferences,
estimatedBytes
},
details
}
}
/**
* Process persist cache tier and collect statistics
* Persist cache has no TTL support, all entries are always valid
*/
private processPersistTier(includeDetails: boolean): {
summary: CacheTierSummary
details: CacheEntryDetail[]
} {
let hookReferences = 0
let estimatedBytes = 0
for (const [key, value] of this.persistCache.entries()) {
hookReferences += this.activeHookCounts.get(key) ?? 0
estimatedBytes += this.estimateSize(key) + this.estimateSize(value)
}
const details: CacheEntryDetail[] = includeDetails
? Array.from(this.persistCache.keys()).map((key) => ({
key,
hasValue: true,
hasTTL: false,
isExpired: false,
hookCount: this.activeHookCounts.get(key) ?? 0
}))
: []
return {
summary: {
totalCount: this.persistCache.size,
validCount: this.persistCache.size, // All persist entries are always valid
expiredCount: 0,
withTTLCount: 0,
hookReferences,
estimatedBytes
},
details
}
}
/**
* Estimate memory size of a value in bytes using JSON serialization
* Note: This is a rough estimate, actual memory usage may differ
*/
private estimateSize(value: any): number {
try {
return new Blob([JSON.stringify(value)]).size
} catch {
return 0
}
}
/**
* Format bytes to human-readable size
*/
private formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`
}
// ============ Shared Cache Ready State Management ============
@ -998,7 +1194,7 @@ export class CacheService {
this.persistCache.clear()
// Clear tracking
this.activeHooks.clear()
this.activeHookCounts.clear()
this.subscribers.clear()
logger.debug('CacheService cleanup completed')

View File

@ -313,7 +313,7 @@ export function useMutation<TPath extends ConcreteApiPaths, TMethod extends 'POS
// Refresh specified keys on success
if (optionsRef.current?.refresh?.length) {
await Promise.all(optionsRef.current.refresh.map((key) => globalMutate(key)))
await globalMutate(createMultiKeyMatcher(optionsRef.current.refresh))
}
},
onError: (error) => optionsRef.current?.onError?.(error),
@ -329,7 +329,7 @@ export function useMutation<TPath extends ConcreteApiPaths, TMethod extends 'POS
// Apply optimistic update if optimisticData is provided
if (hasOptimisticData) {
await globalMutate(path, opts!.optimisticData, false)
await globalMutate([path], opts!.optimisticData, false)
}
try {
@ -337,14 +337,14 @@ export function useMutation<TPath extends ConcreteApiPaths, TMethod extends 'POS
// Revalidate after optimistic update completes
if (hasOptimisticData) {
await globalMutate(path)
await globalMutate([path])
}
return result
} catch (err) {
// Rollback optimistic update on error
if (hasOptimisticData) {
await globalMutate(path)
await globalMutate([path])
}
throw err
}
@ -383,9 +383,9 @@ export function useInvalidateCache() {
if (keys === true || keys === undefined) {
await mutate(() => true)
} else if (typeof keys === 'string') {
await mutate(keys)
await mutate(createKeyMatcher(keys))
} else if (Array.isArray(keys)) {
await Promise.all(keys.map((key) => mutate(key)))
await mutate(createMultiKeyMatcher(keys))
}
}
@ -703,12 +703,12 @@ function createApiFetcher<TPath extends ConcreteApiPaths, TMethod extends 'GET'
* @internal
* @param path - API endpoint path
* @param query - Optional query parameters
* @returns Tuple of [path] or [path, query] for SWR cache key
* @returns Tuple of [path, query?] for SWR cache key
*/
function buildSWRKey<TPath extends ConcreteApiPaths, TQuery extends QueryParamsForPath<TPath>>(
path: TPath,
query?: TQuery
): [TPath] | [TPath, TQuery] {
): [TPath, TQuery?] {
if (query && Object.keys(query).length > 0) {
return [path, query]
}
@ -729,3 +729,26 @@ function getFetcher<TPath extends ConcreteApiPaths>([path, query]: [TPath, Query
const apiFetcher = createApiFetcher<TPath, 'GET'>('GET')
return apiFetcher(path, { query })
}
/**
* Create a filter function that matches SWR cache keys by path.
* Matches both [path] and [path, query] formats.
*
* @internal
* @param pathToMatch - The API path to match against cache keys
* @returns Filter function for use with SWR's mutate
*/
function createKeyMatcher(pathToMatch: string): (key: unknown) => boolean {
return (key) => Array.isArray(key) && key[0] === pathToMatch
}
/**
* Create a filter function that matches multiple paths.
*
* @internal
* @param paths - Array of API paths to match against cache keys
* @returns Filter function for use with SWR's mutate
*/
function createMultiKeyMatcher(paths: string[]): (key: unknown) => boolean {
return (key) => Array.isArray(key) && paths.includes(key[0] as string)
}

View File

@ -2,8 +2,8 @@
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import type { ToastUtilities } from '@cherrystudio/ui'
import type { UseNavigateResult } from '@tanstack/react-router'
import type { HookAPI } from 'antd/es/modal/useModal'
import type { NavigateFunction } from 'react-router-dom'
interface ImportMetaEnv {
VITE_RENDERER_INTEGRATED_MODEL: string
@ -18,7 +18,7 @@ declare global {
root: HTMLElement
modal: HookAPI
store: any
navigate: NavigateFunction
navigate: UseNavigateResult<string>
toast: ToastUtilities
agentTools: {
respondToPermission: (payload: {

View File

@ -1,8 +1,8 @@
import { useAppSelector } from '@renderer/store'
import { IpcChannel } from '@shared/IpcChannel'
import { useLocation, useNavigate } from '@tanstack/react-router'
import { useEffect } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useLocation, useNavigate } from 'react-router-dom'
const NavigationHandler: React.FC = () => {
const location = useLocation()
@ -17,7 +17,7 @@ const NavigationHandler: React.FC = () => {
if (location.pathname.startsWith('/settings')) {
return
}
navigate('/settings/provider')
navigate({ to: '/settings/provider' })
},
{
splitKey: '!',
@ -30,7 +30,7 @@ const NavigationHandler: React.FC = () => {
// Listen for navigate to About page event from macOS menu
useEffect(() => {
const handleNavigateToAbout = () => {
navigate('/settings/about')
navigate({ to: '/settings/about' })
}
const removeListener = window.electron.ipcRenderer.on(IpcChannel.Windows_NavigateToAbout, handleNavigateToAbout)

View File

@ -64,7 +64,7 @@ export function useAppInit() {
useEffect(() => {
window.api.getDataPathFromArgs().then((dataPath) => {
if (dataPath) {
window.navigate('/settings/data', { replace: true })
window.navigate({ to: '/settings/data', replace: true })
}
})
}, [])

Some files were not shown because too many files have changed in this diff Show More