Compare commits

...

40 Commits

Author SHA1 Message Date
MyPrototypeWhat
60d6fbe8f4 feat: refactor web search to provider-specific tools with advanced parameters
- Add ExaSearchTool and TavilySearchTool with provider-specific parameters
- Extend type system for Exa (neural search, date filters) and Tavily (AI answers, search depth)
- Update all providers to support ProviderSpecificParams interface
- Add searchResultAdapters for unified citation conversion
- Remove rawContent from LLM output and storage to reduce token usage
- Support favicon, highlights, answer, images metadata
- Update UI components to handle new tool names
- Preserve existing RAG compression and token cutoff capabilities

Breaking changes: None (backward compatible with existing providers)
2025-10-13 17:53:40 +08:00
MyPrototypeWhat
ff378ca567 feat: enhance web search functionality with abort signal support
- Updated WebSearchTool to accept an abort signal in the execute method.
- Modified various WebSearchProvider classes to include httpOptions for search methods, allowing for abort signal handling.
- Improved WebSearchService to prioritize external abort signals for better request management.
- Enhanced MessageTool to reflect tool status with appropriate UI feedback.
2025-10-09 17:44:42 +08:00
Tristan Zhang
654f19eaa9 fix: change the url for qwen (#10584) 2025-10-09 13:37:07 +08:00
Tristan Zhang
ce642f17d9 fix: layout for antrophic api tips (#10579)
* fix: layout for antrophic api tips

* lint
2025-10-09 13:20:40 +08:00
fullex
d7bcd5a20e Merge pull request #10096 from CherryHQ/feat/agents-new
feat: agents implemention
2025-10-09 10:07:18 +08:00
suyao
27903e7d9d fix 2025-10-09 09:42:04 +08:00
suyao
a8c0d0a684 fix 2025-10-09 09:10:04 +08:00
suyao
5e33c89fe7 Merge branch 'main' into feat/agents-new 2025-10-09 09:06:06 +08:00
Tristan Zhang
42849e4586 feat: support export image for notes (#10559)
* feat: support export image for notes

* feat: extract functions
2025-10-08 23:32:32 +08:00
kangfenmao
6a8544fb0e chore: bump version to 1.6.3 2025-10-08 22:08:08 +08:00
kangfenmao
37f7042f0f refactor: update styling and layout in Message component and NotesSidebar
- Adjusted class names in Message component for better layout management.
- Modified margin in DropHintNode of NotesSidebar for improved spacing.
- Enhanced BackupService to remove 'notes_tree' from indexedDB during data restoration.
2025-10-08 21:42:50 +08:00
亢奋猫
65d066cbef fix: migration for missing providers … (#10438)
chore: bump version to 1.6.3 and add migration for missing providers #10425

fix: #10425

- Updated the version from 158 to 159 in the persisted reducer configuration.
- Implemented a migration function to ensure missing system providers are added to the state during the migration to version 159, enhancing state consistency.
2025-10-08 19:28:08 +08:00
George·Dong
504531d4d5 feat(notes): add spell-check control (#10507)
* feat(notes): add spell-check control

* feat(notes): add spell-check toggle to preview mode toolbar

* feat(settings): move spellcheck to global and use hook
2025-10-08 17:48:26 +08:00
Tristan Zhang
d4b3428160 feat: Support automatic line wrapping for tables in notes (#10503)
* feat: add table auto-wrap feature for notes

* chore: lint

* feat: remove settings for auto wrap
2025-10-08 01:57:00 +08:00
Daniel Hofheinz
cd881ceb34 fix(ui): remove redundant scrollbar in side-by-side view & fix message menubar overflow (#10543)
* fix(ui): remove redundant scrollbar in side-by-side view

Changed GridContainer from styled(Scrollbar) to styled.div to
eliminate redundant horizontal scrollbar in multi-model horizontal
layout mode. The Scrollbar component is designed for vertical
scrolling and conflicts with horizontal layouts.

Fixes #10520

* fix(ui): restore vertical scrollbar for grid mode while preserving horizontal fix

Optimal solution: Use Scrollbar component as base to preserve auto-hide
behavior for vertical modes (grid, vertical, fold) while overriding its
overflow-y behavior for horizontal mode only.

This approach:
- Preserves the June 2025 UX optimization (auto-hide scrollbars)
- Fixes horizontal scrollbar issue from #10520
- Restores vertical scrolling for grid mode
- Maintains auto-hide behavior for all vertical scrolling modes
- Minimal change with no code duplication

The Scrollbar component provides scrollbar thumb auto-hide after 1.5s,
which enhances UX for vertical scrolling. By using CSS overrides only
for horizontal mode, we get the best of both worlds.

* chore: fix import sorting in MessageGroup.tsx

Unrelated to PR scope - fixing to unblock CI.
Auto-fixed via eslint --fix (moved Scrollbar import to correct position).
Also updated yarn.lock to resolve dependency sync.

* fix(ui): add explicit overflow declarations for all grid modes

Previous fix relied on CSS inheritance from Scrollbar base component,
but display: grid interferes with overflow property inheritance.

This iteration adds explicit overflow-y: auto and overflow-x: hidden
to grid, fold, vertical, and multi-select modes to ensure vertical
scrolling works reliably across all layouts.

- horizontal mode: overflow-y visible, overflow-x auto (unchanged)
- grid/fold/vertical modes: explicit overflow-y auto, overflow-x hidden
- multi-select mode: explicit overflow-y auto, overflow-x hidden

Fixes vertical scrollbar missing in grid mode reported by @EurFelux

* fix(Messages): adjust overflow behavior in message groups

Fix scrollbar issues by hiding vertical overflow in horizontal layout and simplifying overflow handling in grid layout

* feat(HorizontalScrollContainer): add classNames prop for container and content styling

allow custom styling of container and content via classNames prop

---------

Co-authored-by: icarus <eurfelux@gmail.com>
2025-10-08 01:55:21 +08:00
Vaayne
68b37e66e9 ⬆️ chore: upgrade electron from 37.4.0 to 37.6.0 2025-10-07 14:36:03 +08:00
Vaayne
d6e7ed81ee Merge remote-tracking branch 'origin/main' into feat/agents-new
# Conflicts:
#	src/renderer/src/pages/home/Tabs/TopicsTab.tsx
#	yarn.lock
2025-10-07 14:33:41 +08:00
Phantom
a9843b4128 feat: expand clickable area of topic in-place renaming (#10548)
* chore: update electron dependency from 37.4.0 to 37.6.0

* feat(TopicsTab): add double click to edit topic name

Move double click handler from TopicName component to parent div to improve UX

* fix(TopicsTab): prevent topic edit on double click when already editing
2025-10-07 14:24:29 +08:00
Vaayne
d4c6131fa3 Merge remote-tracking branch 'origin/main' into feat/agents-new
# Conflicts:
#	package.json
#	src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts
2025-10-07 12:30:18 +08:00
Murphy
d2d5064eed fix: forked topic and rename modal retaining old name after rename (#10528)
fix: sync active topic metadata after rename
2025-10-07 00:02:48 +08:00
rebecca554owen
8bec7640fa fix(metrics): restore first token latency reporting (#10538) 2025-10-06 22:19:09 +08:00
沿途风浪
fcf53f06ef fix(models vision) (#10530) 2025-10-05 20:39:32 +08:00
one
2048f210e7 feat(CodeEditor): add a prop to enable the readOnly extension (#10516)
* feat(CodeEditor): add a prop to enable the readOnly extension

* feat: enable keymap for TextFilePreview
2025-10-04 23:24:50 +08:00
PP Kun
78eacccf6e chore(build): Upgrade electron (#10525)
- 将 electron 从 37.4.0 升级到 37.6.0
- 解决旧版本导致macOS 26卡顿问题
2025-10-04 12:42:07 +08:00
Tristan Zhang
a436ab1d78 fix(TextFilePreview): make editor read-only but can be copied (#10499)
* fix(TextFilePreview): make editor read-only but can be copied

* feat: add table auto-wrap feature for notes

* Revert "feat: add table auto-wrap feature for notes"

This reverts commit 7785f480b1.
2025-10-03 19:23:49 +08:00
Phantom
2aedbf5702 fix(reasoning): support deepseek v3.2, claude 4.5, glm 4.6 (#10475)
* fix(reasoning): update deepseek model id regex pattern to match more variants

The previous regex pattern was too restrictive and didn't account for all possible deepseek model id formats. This change expands the pattern to support more variants while maintaining the same functionality.

* fix(reasoning): update deepseek model id regex pattern to match more variants

* fix(reasoning): improve regex pattern for deepseek model matching

Update the regex pattern to be more precise in matching deepseek model versions.
Add detailed comments explaining the pattern and note future improvements.

* feat(models): add GLM-4.6 model to supported list

Update model configuration to include new GLM-4.6 model and add it to the supported models for thinking token functionality

* feat(models): add claude sonnet 4.5 model to anthropic provider
2025-10-03 14:36:18 +08:00
Tristan Zhang
b7e7174f3d feat: add middle-click tab closing (#10498) 2025-10-02 20:56:53 +08:00
Tristan Zhang
e7e5c0456f feat: allowing notes to be renamed using LLM (#10487)
* feat: implement auto-renaming feature for notes

* feat: motion effects for auto renaming in notes

* feat: add i18n for zh-tw for auto renaming in notes

* chore: lint
2025-10-02 20:45:46 +08:00
purefkh
53e38ed1aa feat(models): update Gemini regex (#10463)
* feat(models): update Gemini regex

* fix: lint

* fix format
2025-10-02 17:45:42 +08:00
Tristan Zhang
f91e7da0a1 feat: add notes export (#10488)
* feat: add notes export

* chore: fix lint error

* feat: unified export interface for notes

* fix: hide export reasoning when exporting notes

* chore: fix lint error

* chore: remove debug log
2025-10-02 08:09:11 +01:00
dependabot[bot]
74db4c4646 ci(deps): bump actions/github-script from 7 to 8 (#10480)
Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 8.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 21:16:02 +08:00
dependabot[bot]
1e4902b267 ci(deps): bump actions/checkout from 4 to 5 (#10479)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 21:15:38 +08:00
dependabot[bot]
932b1d529a ci(deps): bump actions/setup-node from 4 to 5 (#10478)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 5.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 21:15:14 +08:00
Vaayne
53046460ec fix(ClaudeCodeService): update environment variables to use modelInfo provider details 2025-09-30 23:53:10 +08:00
LeaderOnePro
38ac42af8c feat: add GitHub Copilot CLI integration to coding tools (#10403)
* feat: add GitHub Copilot CLI integration to coding tools

- Add githubCopilotCli to codeTools enum
- Support @github/copilot package installation
- Add 'copilot' executable command mapping
- Update Redux store to include GitHub Copilot CLI state
- Add GitHub Copilot CLI option to UI with proper provider mapping
- Implement environment variable handling for GitHub authentication
- Fix model selection logic to disable model choice for GitHub Copilot CLI
- Update launch validation to not require model selection for GitHub Copilot CLI
- Fix prepareLaunchEnvironment and executeLaunch to handle no-model scenario

This enables users to launch GitHub Copilot CLI directly from Cherry Studio's
code tools interface without needing to select a model, as GitHub Copilot CLI
uses GitHub's built-in models and authentication.

Signed-off-by: LeaderOnePro <leaderonepro@outlook.com>

* style: apply code formatting for GitHub Copilot CLI integration

Auto-fix code style inconsistencies using project's Biome formatter.
Resolves semicolon, comma, and quote style issues to match project standards.

Signed-off-by: LeaderOnePro <leaderonepro@outlook.com>

* feat: conditionally render model selector for GitHub Copilot CLI

- Hide model selector component when GitHub Copilot CLI is selected
- Maintain validation logic to allow GitHub Copilot CLI without model selection
- Improve UX by removing empty model dropdown for GitHub Copilot CLI

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

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

---------

Signed-off-by: LeaderOnePro <leaderonepro@outlook.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-30 23:43:19 +08:00
Vaayne
538291c03f ♻️ refactor: consolidate Claude Code system message handling and streaming logic
- Unify buildClaudeCodeSystemMessage implementation in shared package
- Refactor MessagesService to provide comprehensive message processing API
- Extract streaming logic, error handling, and header preparation into service methods
- Remove duplicate anthropic config from renderer, use shared implementation
- Update ClaudeCodeService to use append mode for custom instructions
- Improve type safety and request validation in message processing
2025-09-30 23:33:41 +08:00
icarus
142ad9e41e refactor(Assistants): move add assistant button inside container div
Improve layout structure by moving the button inside the same container as other elements for better visual grouping
2025-09-30 21:14:48 +08:00
Vaayne
7250ce3514 fix(CodeToolsService): update package name for Claude Code SDK 2025-09-30 18:55:17 +08:00
Vaayne
02cf012671 fix(ProcessTransport): replace spawn with fork for Node.js process handling 2025-09-30 18:50:02 +08:00
MyPrototypeWhat
d11a2cd95c chore: update dependencies and versioning across packages (#10471)
- Bumped versions for several @ai-sdk packages in package.json and yarn.lock to their latest releases, including @ai-sdk/amazon-bedrock, @ai-sdk/google-vertex, @ai-sdk/mistral, and @ai-sdk/perplexity.
- Updated ai package version from 5.0.44 to 5.0.59.
- Updated aiCore package version from 1.0.0-alpha.18 to 1.0.1 and adjusted dependencies accordingly.
- Ensured compatibility with the latest zod version in multiple packages.
2025-09-30 18:39:48 +08:00
84 changed files with 2372 additions and 1110 deletions

View File

@@ -26,7 +26,7 @@ jobs:
ref: ${{ github.event.pull_request.head.ref }}
- name: 📦 Setting Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20

View File

@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 1

View File

@@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 1

View File

@@ -37,7 +37,7 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 1

View File

@@ -12,7 +12,7 @@ jobs:
if: github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository
steps:
- name: Delete merged branch
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
github.rest.git.deleteRef({

View File

@@ -56,7 +56,7 @@ jobs:
ref: main
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20

View File

@@ -24,7 +24,7 @@ jobs:
uses: actions/checkout@v5
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20

View File

@@ -47,7 +47,7 @@ jobs:
npm version "$VERSION" --no-git-tag-version --allow-same-version
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20

View File

@@ -4,12 +4,12 @@ index 461e9a2ba246778261108a682762ffcf26f7224e..44bd667d9f591969d36a105ba5eb8b47
+++ b/sdk.mjs
@@ -6215,7 +6215,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
@@ -6473,14 +6473,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?`;
@@ -19,7 +19,7 @@ index 461e9a2ba246778261108a682762ffcf26f7224e..44bd667d9f591969d36a105ba5eb8b47
- const spawnCommand = isNative ? pathToClaudeCodeExecutable : executable;
- const spawnArgs = isNative ? args : [...executableArgs, pathToClaudeCodeExecutable, ...args];
- this.logForDebugging(isNative ? `Spawning Claude Code native binary: ${pathToClaudeCodeExecutable} ${args.join(" ")}` : `Spawning Claude Code process: ${executable} ${[...executableArgs, pathToClaudeCodeExecutable, ...args].join(" ")}`);
+ this.logDebug(`Forking Claude Code Node.js process: ${pathToClaudeCodeExecutable} ${args.join(" ")}`);
+ this.logForDebugging(`Forking Claude Code Node.js process: ${pathToClaudeCodeExecutable} ${args.join(" ")}`);
const stderrMode = env.DEBUG || stderr ? "pipe" : "ignore";
- this.child = spawn(spawnCommand, spawnArgs, {
+ this.child = fork(pathToClaudeCodeExecutable, args, {

View File

@@ -125,7 +125,21 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
Optimized note-taking feature, now able to quickly rename by modifying the title
Fixed issue where CherryAI free model could not be used
Fixed issue where VertexAI proxy address could not be called normally
Fixed issue where built-in tools from service providers could not be called normally
What's New in v1.6.3
Features:
- Notes: Add spell-check control, automatic table line wrapping, export functionality, and LLM-based renaming
- UI: Expand topic rename clickable area, add middle-click tab closing, remove redundant scrollbars, fix message menubar overflow
- Editor: Add read-only extension support, make TextFilePreview read-only but copyable
- Models: Update support for DeepSeek v3.2, Claude 4.5, GLM 4.6, Gemini regex, and vision models
- Code Tools: Add GitHub Copilot CLI integration
Bug Fixes:
- Fix migration for missing providers
- Fix forked topic retaining old name after rename
- Restore first token latency reporting in metrics
- Fix UI scrollbar and overflow issues
Technical Updates:
- Upgrade to Electron 37.6.0
- Update dependencies across packages

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.7.0-alpha.4",
"version": "1.7.0-alpha.5",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -99,10 +99,10 @@
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@ai-sdk/amazon-bedrock": "^3.0.21",
"@ai-sdk/google-vertex": "^3.0.27",
"@ai-sdk/mistral": "^2.0.14",
"@ai-sdk/perplexity": "^2.0.9",
"@ai-sdk/amazon-bedrock": "^3.0.29",
"@ai-sdk/google-vertex": "^3.0.33",
"@ai-sdk/mistral": "^2.0.17",
"@ai-sdk/perplexity": "^2.0.11",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@anthropic-ai/sdk": "^0.41.0",
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
@@ -219,7 +219,7 @@
"@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4",
"ai": "^5.0.44",
"ai": "^5.0.59",
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
@@ -244,7 +244,7 @@
"dotenv-cli": "^7.4.2",
"drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.5",
"electron": "37.4.0",
"electron": "37.6.0",
"electron-builder": "26.0.15",
"electron-devtools-installer": "^3.2.0",
"electron-reload": "^2.0.0-alpha.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@cherrystudio/ai-core",
"version": "1.0.0-alpha.18",
"version": "1.0.1",
"description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK",
"main": "dist/index.js",
"module": "dist/index.mjs",
@@ -36,14 +36,14 @@
"ai": "^5.0.26"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.17",
"@ai-sdk/azure": "^2.0.30",
"@ai-sdk/deepseek": "^1.0.17",
"@ai-sdk/openai": "^2.0.30",
"@ai-sdk/openai-compatible": "^1.0.17",
"@ai-sdk/anthropic": "^2.0.22",
"@ai-sdk/azure": "^2.0.42",
"@ai-sdk/deepseek": "^1.0.20",
"@ai-sdk/openai": "^2.0.42",
"@ai-sdk/openai-compatible": "^1.0.19",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.9",
"@ai-sdk/xai": "^2.0.18",
"@ai-sdk/provider-utils": "^3.0.10",
"@ai-sdk/xai": "^2.0.23",
"zod": "^4.1.5"
},
"devDependencies": {

View File

@@ -12,8 +12,19 @@ import Anthropic from '@anthropic-ai/sdk'
import { TextBlockParam } from '@anthropic-ai/sdk/resources'
import { loggerService } from '@logger'
import { Provider } from '@types'
import type { ModelMessage } from 'ai'
const logger = loggerService.withContext('anthropic-sdk')
const defaultClaudeCodeSystemPrompt = `You are Claude Code, Anthropic's official CLI for Claude.`
const defaultClaudeCodeSystem: Array<TextBlockParam> = [
{
type: 'text',
text: defaultClaudeCodeSystemPrompt
}
]
/**
* Creates and configures an Anthropic SDK client based on the provider configuration.
*
@@ -44,7 +55,11 @@ const logger = loggerService.withContext('anthropic-sdk')
* const apiKeyClient = getSdkClient(apiKeyProvider);
* ```
*/
export function getSdkClient(provider: Provider, oauthToken?: string | null): Anthropic {
export function getSdkClient(
provider: Provider,
oauthToken?: string | null,
extraHeaders?: Record<string, string | string[]>
): Anthropic {
if (provider.authType === 'oauth') {
if (!oauthToken) {
throw new Error('OAuth token is not available')
@@ -68,7 +83,8 @@ export function getSdkClient(provider: Provider, oauthToken?: string | null): An
'x-stainless-os': 'MacOS',
'x-stainless-arch': 'arm64',
'x-stainless-runtime': 'node',
'x-stainless-runtime-version': 'v22.18.0'
'x-stainless-runtime-version': 'v22.18.0',
...extraHeaders
}
})
}
@@ -87,7 +103,8 @@ export function getSdkClient(provider: Provider, oauthToken?: string | null): An
defaultHeaders: {
'anthropic-beta': 'output-128k-2025-02-19',
'APP-Code': 'MLTG2087',
...provider.extra_headers
...provider.extra_headers,
...extraHeaders
}
})
}
@@ -118,53 +135,36 @@ export function getSdkClient(provider: Provider, oauthToken?: string | null): An
* @param system - Optional user-provided system message (string or TextBlockParam array)
* @returns Combined system message with Claude Code prompt prepended
*
* @example
* ```typescript
* // No system message
* const result1 = buildClaudeCodeSystemMessage();
* // Returns: "You are Claude Code, Anthropic's official CLI for Claude."
*
* // String system message
* const result2 = buildClaudeCodeSystemMessage("You are a helpful assistant.");
* // Returns: [
* // { type: 'text', text: "You are Claude Code, Anthropic's official CLI for Claude." },
* // { type: 'text', text: "You are a helpful assistant." }
* // ]
*
* // Array system message
* const systemArray = [{ type: 'text', text: 'Custom instructions' }];
* const result3 = buildClaudeCodeSystemMessage(systemArray);
* // Returns: Array with Claude Code message prepended
* ```
*/
export function buildClaudeCodeSystemMessage(system?: string | Array<TextBlockParam>): string | Array<TextBlockParam> {
const defaultClaudeCodeSystem = `You are Claude Code, Anthropic's official CLI for Claude.`
export function buildClaudeCodeSystemMessage(system?: string | Array<TextBlockParam>): Array<TextBlockParam> {
if (!system) {
return defaultClaudeCodeSystem
}
if (typeof system === 'string') {
if (system.trim() === defaultClaudeCodeSystem) {
return system
if (system.trim() === defaultClaudeCodeSystemPrompt || system.trim() === '') {
return defaultClaudeCodeSystem
} else {
return [...defaultClaudeCodeSystem, { type: 'text', text: system }]
}
}
if (Array.isArray(system)) {
const firstSystem = system[0]
if (firstSystem.type === 'text' && firstSystem.text.trim() === defaultClaudeCodeSystemPrompt) {
return system
} else {
return [...defaultClaudeCodeSystem, ...system]
}
return [
{
type: 'text',
text: defaultClaudeCodeSystem
},
{
type: 'text',
text: system
}
]
}
if (system[0].text.trim() != defaultClaudeCodeSystem) {
system.unshift({
type: 'text',
text: defaultClaudeCodeSystem
})
}
return system
return defaultClaudeCodeSystem
}
export function buildClaudeCodeSystemModelMessage(system?: string | Array<TextBlockParam>): Array<ModelMessage> {
const textBlocks = buildClaudeCodeSystemMessage(system)
return textBlocks.map((block) => ({
role: 'system',
content: block.text
}))
}

View File

@@ -217,7 +217,8 @@ export enum codeTools {
claudeCode = 'claude-code',
geminiCli = 'gemini-cli',
openaiCodex = 'openai-codex',
iFlowCli = 'iflow-cli'
iFlowCli = 'iflow-cli',
githubCopilotCli = 'github-copilot-cli'
}
export enum terminalApps {

View File

@@ -1,9 +1,9 @@
import { MessageCreateParams } from '@anthropic-ai/sdk/resources'
import { loggerService } from '@logger'
import { Provider } from '@types'
import express, { Request, Response } from 'express'
import { Provider } from '../../../renderer/src/types/provider'
import { MessagesService, messagesService } from '../services/messages'
import { messagesService } from '../services/messages'
import { getProviderById, validateModelId } from '../utils'
const logger = loggerService.withContext('ApiServerMessagesRoutes')
@@ -11,7 +11,7 @@ const logger = loggerService.withContext('ApiServerMessagesRoutes')
const router = express.Router()
const providerRouter = express.Router({ mergeParams: true })
// Helper functions for shared logic
// Helper function for basic request validation
async function validateRequestBody(req: Request): Promise<{ valid: boolean; error?: any }> {
const request: MessageCreateParams = req.body
@@ -31,157 +31,53 @@ async function validateRequestBody(req: Request): Promise<{ valid: boolean; erro
return { valid: true }
}
async function handleStreamingResponse(
res: Response,
request: MessageCreateParams,
provider: Provider,
messagesService: MessagesService
): Promise<void> {
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache, no-transform')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no')
res.flushHeaders()
const flushableResponse = res as Response & { flush?: () => void }
const flushStream = () => {
if (typeof flushableResponse.flush !== 'function') {
return
}
try {
flushableResponse.flush()
} catch (flushError: unknown) {
logger.warn('Failed to flush streaming response', {
error: flushError
})
}
}
try {
for await (const chunk of messagesService.processStreamingMessage(request, provider)) {
res.write(`event: ${chunk.type}\n`)
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
flushStream()
}
res.write('data: [DONE]\n\n')
flushStream()
} catch (streamError: any) {
logger.error('Stream error', {
error: streamError,
provider: provider.id,
model: request.model,
apiHost: provider.apiHost,
anthropicApiHost: provider.anthropicApiHost
})
res.write(
`data: ${JSON.stringify({
type: 'error',
error: {
type: 'api_error',
message: 'Stream processing error'
}
})}\n\n`
)
} finally {
res.end()
}
}
function handleErrorResponse(res: Response, error: any): Response {
logger.error('Message processing error', { error })
let statusCode = 500
let errorType = 'api_error'
let errorMessage = 'Internal server error'
const anthropicStatus = typeof error?.status === 'number' ? error.status : undefined
const anthropicError = error?.error
if (anthropicStatus) {
statusCode = anthropicStatus
}
if (anthropicError?.type) {
errorType = anthropicError.type
}
if (anthropicError?.message) {
errorMessage = anthropicError.message
} else if (error instanceof Error && error.message) {
errorMessage = error.message
}
if (!anthropicStatus && error instanceof Error) {
if (error.message.includes('API key') || error.message.includes('authentication')) {
statusCode = 401
errorType = 'authentication_error'
} else if (error.message.includes('rate limit') || error.message.includes('quota')) {
statusCode = 429
errorType = 'rate_limit_error'
} else if (error.message.includes('timeout') || error.message.includes('connection')) {
statusCode = 502
errorType = 'api_error'
} else if (error.message.includes('validation') || error.message.includes('invalid')) {
statusCode = 400
errorType = 'invalid_request_error'
}
}
return res.status(statusCode).json({
type: 'error',
error: {
type: errorType,
message: errorMessage,
requestId: error?.request_id
}
})
}
async function processMessageRequest(
req: Request,
res: Response,
provider: Provider,
interface HandleMessageProcessingOptions {
req: Request
res: Response
provider: Provider
request: MessageCreateParams
modelId?: string
): Promise<Response | void> {
}
async function handleMessageProcessing({
req,
res,
provider,
request,
modelId
}: HandleMessageProcessingOptions): Promise<void> {
try {
const request: MessageCreateParams = req.body
// Use provided modelId or keep original model
if (modelId) {
request.model = modelId
}
// Validate request
const validation = messagesService.validateRequest(request)
if (!validation.isValid) {
return res.status(400).json({
res.status(400).json({
type: 'error',
error: {
type: 'invalid_request_error',
message: validation.errors.join('; ')
}
})
}
logger.info('Processing anthropic messages request', {
provider: provider.id,
apiHost: provider.apiHost,
anthropicApiHost: provider.anthropicApiHost,
model: request.model,
stream: request.stream,
thinking: request.thinking
})
// Handle streaming
if (request.stream) {
await handleStreamingResponse(res, request, provider, messagesService)
return
}
// Handle non-streaming
const response = await messagesService.processMessage(request, provider)
return res.json(response)
const extraHeaders = messagesService.prepareHeaders(req.headers)
const { client, anthropicRequest } = await messagesService.processMessage({
provider,
request,
extraHeaders,
modelId
})
if (request.stream) {
await messagesService.handleStreaming(client, anthropicRequest, { response: res }, provider)
return
}
const response = await client.messages.create(anthropicRequest)
res.json(response)
} catch (error: any) {
return handleErrorResponse(res, error)
logger.error('Message processing error', { error })
const { statusCode, errorResponse } = messagesService.transformError(error)
res.status(statusCode).json(errorResponse)
}
}
@@ -338,10 +234,11 @@ router.post('/', async (req: Request, res: Response) => {
const provider = modelValidation.provider!
const modelId = modelValidation.modelId!
// Use shared processing function
return await processMessageRequest(req, res, provider, modelId)
return handleMessageProcessing({ req, res, provider, request, modelId })
} catch (error: any) {
return handleErrorResponse(res, error)
logger.error('Message processing error', { error })
const { statusCode, errorResponse } = messagesService.transformError(error)
return res.status(statusCode).json(errorResponse)
}
})
@@ -493,10 +390,13 @@ providerRouter.post('/', async (req: Request, res: Response) => {
})
}
// Use shared processing function (no modelId override needed)
return await processMessageRequest(req, res, provider)
const request: MessageCreateParams = req.body
return handleMessageProcessing({ req, res, provider, request })
} catch (error: any) {
return handleErrorResponse(res, error)
logger.error('Message processing error', { error })
const { statusCode, errorResponse } = messagesService.transformError(error)
return res.status(statusCode).json(errorResponse)
}
})

View File

@@ -1,33 +1,93 @@
import Anthropic from '@anthropic-ai/sdk'
import { Message, MessageCreateParams, RawMessageStreamEvent } from '@anthropic-ai/sdk/resources'
import { MessageCreateParams, MessageStreamEvent } from '@anthropic-ai/sdk/resources'
import { loggerService } from '@logger'
import anthropicService from '@main/services/AnthropicService'
import { buildClaudeCodeSystemMessage, getSdkClient } from '@shared/anthropic'
import { Provider } from '@types'
import { Response } from 'express'
const logger = loggerService.withContext('MessagesService')
const EXCLUDED_FORWARD_HEADERS: ReadonlySet<string> = new Set([
'host',
'x-api-key',
'authorization',
'sentry-trace',
'baggage',
'content-length',
'connection'
])
export interface ValidationResult {
isValid: boolean
errors: string[]
}
export interface ErrorResponse {
type: 'error'
error: {
type: string
message: string
requestId?: string
}
}
export interface StreamConfig {
response: Response
onChunk?: (chunk: MessageStreamEvent) => void
onError?: (error: any) => void
onComplete?: () => void
}
export interface ProcessMessageOptions {
provider: Provider
request: MessageCreateParams
extraHeaders?: Record<string, string | string[]>
modelId?: string
}
export interface ProcessMessageResult {
client: Anthropic
anthropicRequest: MessageCreateParams
}
export class MessagesService {
// oxlint-disable-next-line no-unused-vars
validateRequest(request: MessageCreateParams): ValidationResult {
// TODO: Implement comprehensive request validation
const errors: string[] = []
if (!request.model) {
if (!request.model || typeof request.model !== 'string') {
errors.push('Model is required')
}
if (!request.max_tokens || request.max_tokens < 1) {
errors.push('max_tokens is required and must be at least 1')
if (typeof request.max_tokens !== 'number' || !Number.isFinite(request.max_tokens) || request.max_tokens < 1) {
errors.push('max_tokens is required and must be a positive number')
}
if (!request.messages || !Array.isArray(request.messages) || request.messages.length === 0) {
errors.push('messages is required and must be a non-empty array')
} else {
request.messages.forEach((message, index) => {
if (!message || typeof message !== 'object') {
errors.push(`messages[${index}] must be an object`)
return
}
if (!('role' in message) || typeof message.role !== 'string' || message.role.trim().length === 0) {
errors.push(`messages[${index}].role is required`)
}
const content: unknown = message.content
if (content === undefined || content === null) {
errors.push(`messages[${index}].content is required`)
return
}
if (typeof content === 'string' && content.trim().length === 0) {
errors.push(`messages[${index}].content cannot be empty`)
} else if (Array.isArray(content) && content.length === 0) {
errors.push(`messages[${index}].content must include at least one item when using an array`)
}
})
}
return {
@@ -36,79 +96,224 @@ export class MessagesService {
}
}
async getClient(provider: Provider): Promise<Anthropic> {
async getClient(provider: Provider, extraHeaders?: Record<string, string | string[]>): Promise<Anthropic> {
// Create Anthropic client for the provider
if (provider.authType === 'oauth') {
const oauthToken = await anthropicService.getValidAccessToken()
return getSdkClient(provider, oauthToken)
return getSdkClient(provider, oauthToken, extraHeaders)
}
return getSdkClient(provider)
return getSdkClient(provider, null, extraHeaders)
}
async processMessage(request: MessageCreateParams, provider: Provider): Promise<Message> {
logger.debug('Preparing Anthropic message request', {
model: request.model,
messageCount: request.messages.length,
stream: request.stream,
maxTokens: request.max_tokens,
provider: provider.id
})
prepareHeaders(headers: Record<string, string | string[] | undefined>): Record<string, string | string[]> {
const extraHeaders: Record<string, string | string[]> = {}
// Create Anthropic client for the provider
const client = await this.getClient(provider)
for (const [key, value] of Object.entries(headers)) {
if (value === undefined) {
continue
}
// Prepare request with the actual model ID
const normalizedKey = key.toLowerCase()
if (EXCLUDED_FORWARD_HEADERS.has(normalizedKey)) {
continue
}
extraHeaders[normalizedKey] = value
}
return extraHeaders
}
createAnthropicRequest(request: MessageCreateParams, provider: Provider, modelId?: string): MessageCreateParams {
const anthropicRequest: MessageCreateParams = {
...request,
stream: false
stream: !!request.stream
}
if (provider.authType === 'oauth') {
anthropicRequest.system = buildClaudeCodeSystemMessage(request.system || '')
// Override model if provided
if (modelId) {
anthropicRequest.model = modelId
}
const response = await client.messages.create(anthropicRequest)
// Add Claude Code system message for OAuth providers
if (provider.type === 'anthropic' && provider.authType === 'oauth') {
anthropicRequest.system = buildClaudeCodeSystemMessage(request.system)
}
logger.info('Anthropic message completed', {
model: request.model,
provider: provider.id
})
return response
return anthropicRequest
}
async *processStreamingMessage(
async handleStreaming(
client: Anthropic,
request: MessageCreateParams,
config: StreamConfig,
provider: Provider
): AsyncIterable<RawMessageStreamEvent> {
logger.debug('Preparing streaming Anthropic message request', {
model: request.model,
messageCount: request.messages.length,
provider: provider.id
): Promise<void> {
const { response, onChunk, onError, onComplete } = config
// Set streaming headers
response.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
response.setHeader('Cache-Control', 'no-cache, no-transform')
response.setHeader('Connection', 'keep-alive')
response.setHeader('X-Accel-Buffering', 'no')
response.flushHeaders()
const flushableResponse = response as Response & { flush?: () => void }
const flushStream = () => {
if (typeof flushableResponse.flush !== 'function') {
return
}
try {
flushableResponse.flush()
} catch (flushError: unknown) {
logger.warn('Failed to flush streaming response', { error: flushError })
}
}
const writeSse = (eventType: string | undefined, payload: unknown) => {
if (response.writableEnded || response.destroyed) {
return
}
if (eventType) {
response.write(`event: ${eventType}\n`)
}
const data = typeof payload === 'string' ? payload : JSON.stringify(payload)
response.write(`data: ${data}\n\n`)
flushStream()
}
try {
const stream = client.messages.stream(request)
for await (const chunk of stream) {
if (response.writableEnded || response.destroyed) {
logger.warn('Streaming response ended before stream completion', {
provider: provider.id,
model: request.model
})
break
}
writeSse(chunk.type, chunk)
if (onChunk) {
onChunk(chunk)
}
}
writeSse(undefined, '[DONE]')
if (onComplete) {
onComplete()
}
} catch (streamError: any) {
logger.error('Stream error', {
error: streamError,
provider: provider.id,
model: request.model,
apiHost: provider.apiHost,
anthropicApiHost: provider.anthropicApiHost
})
writeSse(undefined, {
type: 'error',
error: {
type: 'api_error',
message: 'Stream processing error'
}
})
if (onError) {
onError(streamError)
}
} finally {
if (!response.writableEnded) {
response.end()
}
}
}
transformError(error: any): { statusCode: number; errorResponse: ErrorResponse } {
let statusCode = 500
let errorType = 'api_error'
let errorMessage = 'Internal server error'
const anthropicStatus = typeof error?.status === 'number' ? error.status : undefined
const anthropicError = error?.error
if (anthropicStatus) {
statusCode = anthropicStatus
}
if (anthropicError?.type) {
errorType = anthropicError.type
}
if (anthropicError?.message) {
errorMessage = anthropicError.message
} else if (error instanceof Error && error.message) {
errorMessage = error.message
}
// Infer error type from message if not from Anthropic API
if (!anthropicStatus && error instanceof Error) {
const errorMessageText = error.message ?? ''
if (errorMessageText.includes('API key') || errorMessageText.includes('authentication')) {
statusCode = 401
errorType = 'authentication_error'
} else if (errorMessageText.includes('rate limit') || errorMessageText.includes('quota')) {
statusCode = 429
errorType = 'rate_limit_error'
} else if (errorMessageText.includes('timeout') || errorMessageText.includes('connection')) {
statusCode = 502
errorType = 'api_error'
} else if (errorMessageText.includes('validation') || errorMessageText.includes('invalid')) {
statusCode = 400
errorType = 'invalid_request_error'
}
}
const safeErrorMessage =
typeof errorMessage === 'string' && errorMessage.length > 0 ? errorMessage : 'Internal server error'
return {
statusCode,
errorResponse: {
type: 'error',
error: {
type: errorType,
message: safeErrorMessage,
requestId: error?.request_id
}
}
}
}
async processMessage(options: ProcessMessageOptions): Promise<ProcessMessageResult> {
const { provider, request, extraHeaders, modelId } = options
const client = await this.getClient(provider, extraHeaders)
const anthropicRequest = this.createAnthropicRequest(request, provider, modelId)
const messageCount = Array.isArray(request.messages) ? request.messages.length : 0
logger.info('Processing anthropic messages request', {
provider: provider.id,
apiHost: provider.apiHost,
anthropicApiHost: provider.anthropicApiHost,
model: anthropicRequest.model,
stream: !!anthropicRequest.stream,
// systemPrompt: JSON.stringify(!!request.system),
// messages: JSON.stringify(request.messages),
messageCount,
toolCount: Array.isArray(request.tools) ? request.tools.length : 0
})
// Create Anthropic client for the provider
const client = await this.getClient(provider)
// Prepare streaming request
const streamingRequest: MessageCreateParams = {
...request,
stream: true
// Return client and request for route layer to handle streaming/non-streaming
return {
client,
anthropicRequest
}
if (provider.authType === 'oauth') {
streamingRequest.system = buildClaudeCodeSystemMessage(request.system || '')
}
const stream = client.messages.stream(streamingRequest)
for await (const chunk of stream) {
yield chunk
}
logger.info('Completed streaming Anthropic message', {
model: request.model,
provider: provider.id
})
}
}

View File

@@ -31,7 +31,10 @@ interface VersionInfo {
class CodeToolsService {
private versionCache: Map<string, { version: string; timestamp: number }> = new Map()
private terminalsCache: { terminals: TerminalConfig[]; timestamp: number } | null = null
private terminalsCache: {
terminals: TerminalConfig[]
timestamp: number
} | null = null
private customTerminalPaths: Map<string, string> = new Map() // Store user-configured terminal paths
private readonly CACHE_DURATION = 1000 * 60 * 30 // 30 minutes cache
private readonly TERMINALS_CACHE_DURATION = 1000 * 60 * 5 // 5 minutes cache for terminals
@@ -73,7 +76,7 @@ class CodeToolsService {
public async getPackageName(cliTool: string) {
switch (cliTool) {
case codeTools.claudeCode:
return '@anthropic-ai/claude-agent-sdk'
return '@anthropic-ai/claude-code'
case codeTools.geminiCli:
return '@google/gemini-cli'
case codeTools.openaiCodex:
@@ -82,6 +85,8 @@ class CodeToolsService {
return '@qwen-code/qwen-code'
case codeTools.iFlowCli:
return '@iflow-ai/iflow-cli'
case codeTools.githubCopilotCli:
return '@github/copilot'
default:
throw new Error(`Unsupported CLI tool: ${cliTool}`)
}
@@ -99,6 +104,8 @@ class CodeToolsService {
return 'qwen'
case codeTools.iFlowCli:
return 'iflow'
case codeTools.githubCopilotCli:
return 'copilot'
default:
throw new Error(`Unsupported CLI tool: ${cliTool}`)
}
@@ -144,7 +151,9 @@ class CodeToolsService {
case terminalApps.powershell:
// Check for PowerShell in PATH
try {
await execAsync('powershell -Command "Get-Host"', { timeout: 3000 })
await execAsync('powershell -Command "Get-Host"', {
timeout: 3000
})
return terminal
} catch {
try {
@@ -384,7 +393,9 @@ class CodeToolsService {
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
const { stdout } = await execAsync(`"${executablePath}" --version`, { timeout: 10000 })
const { stdout } = await execAsync(`"${executablePath}" --version`, {
timeout: 10000
})
// Extract version number from output (format may vary by tool)
const versionMatch = stdout.trim().match(/\d+\.\d+\.\d+/)
installedVersion = versionMatch ? versionMatch[0] : stdout.trim().split(' ')[0]
@@ -425,7 +436,10 @@ class CodeToolsService {
logger.info(`${packageName} latest version: ${latestVersion}`)
// Cache the result
this.versionCache.set(cacheKey, { version: latestVersion!, timestamp: now })
this.versionCache.set(cacheKey, {
version: latestVersion!,
timestamp: now
})
logger.debug(`Cached latest version for ${packageName}`)
} catch (error) {
logger.warn(`Failed to get latest version for ${packageName}:`, error as Error)

View File

@@ -1,341 +0,0 @@
# Agent Message Architecture Design Document
## Overview
This document describes the architecture for handling agent messages in Cherry Studio, including how agent-specific messages are generated, transformed to AI SDK format, stored, and sent to the UI. The system is designed to be agent-agnostic, allowing multiple agent types (Claude Code, OpenAI, etc.) to integrate seamlessly.
## Core Design Principles
1. **Agent Agnosticism**: The core message handling system should work with any agent type without modification
2. **Data Preservation**: All raw agent data must be preserved alongside transformed UI-friendly formats
3. **Streaming First**: Support real-time streaming of agent responses to the UI
4. **Type Safety**: Strong TypeScript interfaces ensure consistency across the pipeline
## Architecture Components
### 1. Agent Service Layer
Each agent (e.g., ClaudeCodeService) implements the `AgentServiceInterface`:
```typescript
interface AgentServiceInterface {
invoke(prompt: string, cwd: string, sessionId?: string, options?: any): AgentStream
}
```
#### Responsibilities:
- Spawn and manage agent-specific processes (e.g., Claude Code CLI)
- Parse agent-specific output formats (e.g., SDKMessage for Claude Code)
- Transform agent messages to AI SDK format
- Emit standardized `AgentStreamEvent` objects
### 2. Agent Stream Events
The standardized event interface that all agents emit:
```typescript
interface AgentStreamEvent {
type: 'chunk' | 'error' | 'complete'
chunk?: UIMessageChunk // AI SDK format for UI
rawAgentMessage?: any // Agent-specific raw message
error?: Error
agentResult?: any // Complete agent-specific result
}
```
### 3. Session Message Service
The `SessionMessageService` acts as the orchestration layer:
#### Responsibilities:
- Manages session lifecycle and persistence
- Collects streaming chunks and raw agent messages
- Stores structured data in the database
- Forwards events to the API layer
### 4. Database Storage
Session messages are stored with complete structured data:
```typescript
interface SessionMessageContent {
aiSDKChunks: UIMessageChunk[] // UI-friendly format
rawAgentMessages: any[] // Original agent messages
agentResult?: any // Complete agent result
agentType: string // Agent identifier
}
```
## Data Flow
```mermaid
graph TD
A[User Input] --> B[API Handler]
B --> C[SessionMessageService]
C --> D[Agent Service]
D --> E[Agent Process]
E --> F[Raw Agent Output]
F --> G[Transform to AI SDK]
G --> H[Emit AgentStreamEvent]
H --> I[SessionMessageService]
I --> J[Store in Database]
I --> K[Forward to Client]
K --> L[UI Rendering]
```
## Message Transformation Process
### Step 1: Raw Agent Message Generation
Each agent generates messages in its native format:
**Claude Code Example:**
```typescript
// SDKMessage from Claude Code CLI
{
type: 'assistant',
uuid: 'msg_123',
session_id: 'session_456',
message: {
role: 'assistant',
content: [
{ type: 'text', text: 'Hello, I can help...' },
{ type: 'tool_use', id: 'tool_1', name: 'read_file', input: {...} }
]
}
}
```
### Step 2: Transformation to AI SDK Format
The agent service transforms native messages to AI SDK `UIMessageChunk`:
```typescript
// In ClaudeCodeService
const emitChunks = (sdkMessage: SDKMessage) => {
// Transform to AI SDK format
const chunks = transformSDKMessageToUIChunk(sdkMessage)
for (const chunk of chunks) {
stream.emit('data', {
type: 'chunk',
chunk, // AI SDK format
rawAgentMessage: sdkMessage // Preserve original
})
}
}
```
**Transformed AI SDK Chunk:**
```typescript
{
type: 'text-delta',
id: 'msg_123',
delta: 'Hello, I can help...',
providerMetadata: {
claudeCode: {
originalSDKMessage: {...},
uuid: 'msg_123',
session_id: 'session_456'
}
}
}
```
### Step 3: Session Message Processing
The SessionMessageService collects and processes events:
```typescript
// Collect streaming data
const streamedChunks: UIMessageChunk[] = []
const rawAgentMessages: any[] = []
claudeStream.on('data', async (event: AgentStreamEvent) => {
switch (event.type) {
case 'chunk':
streamedChunks.push(event.chunk)
if (event.rawAgentMessage) {
rawAgentMessages.push(event.rawAgentMessage)
}
// Forward to client
sessionStream.emit('data', { type: 'chunk', chunk: event.chunk })
break
case 'complete':
// Store complete structured data
const content = {
aiSDKChunks: streamedChunks,
rawAgentMessages: rawAgentMessages,
agentResult: event.agentResult,
agentType: event.agentResult?.agentType || 'unknown'
}
// Save to database...
break
}
})
```
### Step 4: Client Streaming
The API handler converts events to Server-Sent Events (SSE):
```typescript
// In API handler
messageStream.on('data', (event: any) => {
switch (event.type) {
case 'chunk':
// Send AI SDK chunk as SSE
res.write(`data: ${JSON.stringify(event.chunk)}\n\n`)
break
case 'complete':
res.write('data: [DONE]\n\n')
res.end()
break
}
})
```
## Adding New Agent Types
To add support for a new agent (e.g., OpenAI):
### 1. Create Agent Service
```typescript
class OpenAIService implements AgentServiceInterface {
invokeStream(prompt: string, cwd: string, sessionId?: string, options?: any): AgentStream {
const stream = new OpenAIStream()
// Call OpenAI API
const openaiResponse = await openai.chat.completions.create({
messages: [{ role: 'user', content: prompt }],
stream: true
})
// Transform OpenAI format to AI SDK
for await (const chunk of openaiResponse) {
const aiSDKChunk = transformOpenAIToAISDK(chunk)
stream.emit('data', {
type: 'chunk',
chunk: aiSDKChunk,
rawAgentMessage: chunk // Preserve OpenAI format
})
}
return stream
}
}
```
### 2. Create Transform Function
```typescript
function transformOpenAIToAISDK(openaiChunk: OpenAIChunk): UIMessageChunk {
return {
type: 'text-delta',
id: openaiChunk.id,
delta: openaiChunk.choices[0].delta.content,
providerMetadata: {
openai: {
original: openaiChunk,
model: openaiChunk.model
}
}
}
}
```
### 3. Register Agent Type
Update the agent type enum and factory:
```typescript
export type AgentType = 'claude-code' | 'openai' | 'anthropic-api'
function createAgentService(type: AgentType): AgentServiceInterface {
switch (type) {
case 'claude-code':
return new ClaudeCodeService()
case 'openai':
return new OpenAIService()
// ...
}
}
```
## Benefits of This Architecture
1. **Extensibility**: Easy to add new agent types without modifying core logic
2. **Data Integrity**: Raw agent data is never lost during transformation
3. **Debugging**: Complete message history available for troubleshooting
4. **Performance**: Streaming support for real-time responses
5. **Type Safety**: Strong interfaces prevent runtime errors
6. **UI Consistency**: All agents provide data in standard AI SDK format
## Key Interfaces Reference
### AgentStreamEvent
```typescript
interface AgentStreamEvent {
type: 'chunk' | 'error' | 'complete'
chunk?: UIMessageChunk
rawAgentMessage?: any
error?: Error
agentResult?: any
}
```
### SessionMessageEntity
```typescript
interface SessionMessageEntity {
id: number
session_id: string
parent_id?: number
role: 'user' | 'assistant' | 'system' | 'tool'
type: string
content: string | SessionMessageContent
metadata?: Record<string, any>
created_at: string
updated_at: string
}
```
### SessionMessageContent
```typescript
interface SessionMessageContent {
aiSDKChunks: UIMessageChunk[]
rawAgentMessages: any[]
agentResult?: any
agentType: string
}
```
## Testing Strategy
### Unit Tests
- Test each transform function independently
- Verify event emission sequences
- Validate data structure preservation
### Integration Tests
- Test complete flow from input to database
- Verify streaming behavior
- Test error handling and recovery
### Agent-Specific Tests
- Validate agent-specific transformations
- Test edge cases for each agent type
- Verify metadata preservation
## Future Enhancements
1. **Message Replay**: Ability to replay sessions from stored raw messages
2. **Format Migration**: Tools to migrate between agent formats
3. **Analytics**: Aggregate metrics from raw agent data
4. **Caching**: Cache transformed chunks for performance
5. **Compression**: Compress raw messages for storage efficiency
## Conclusion
This architecture provides a robust, extensible foundation for handling messages from multiple AI agents while maintaining data integrity and providing a consistent interface for the UI. The separation of concerns between agent-specific logic and core message handling ensures the system can evolve to support new agents and features without breaking existing functionality.

View File

@@ -84,9 +84,13 @@ class ClaudeCodeService implements AgentServiceInterface {
const env = {
...loginShellEnvWithoutProxies,
ANTHROPIC_API_KEY: apiConfig.apiKey,
ANTHROPIC_AUTH_TOKEN: apiConfig.apiKey,
ANTHROPIC_BASE_URL: `http://${apiConfig.host}:${apiConfig.port}/${modelInfo.provider.id}`,
// TODO: fix the proxy api server
// ANTHROPIC_API_KEY: apiConfig.apiKey,
// ANTHROPIC_AUTH_TOKEN: apiConfig.apiKey,
// ANTHROPIC_BASE_URL: `http://${apiConfig.host}:${apiConfig.port}/${modelInfo.provider.id}`,
ANTHROPIC_API_KEY: modelInfo.provider.apiKey,
ANTHROPIC_AUTH_TOKEN: modelInfo.provider.apiKey,
ANTHROPIC_BASE_URL: modelInfo.provider.anthropicApiHost?.trim() || modelInfo.provider.apiHost,
ANTHROPIC_MODEL: modelInfo.modelId,
ANTHROPIC_SMALL_FAST_MODEL: modelInfo.modelId,
ELECTRON_RUN_AS_NODE: '1',
@@ -106,7 +110,13 @@ class ClaudeCodeService implements AgentServiceInterface {
logger.warn('claude stderr', { chunk })
errorChunks.push(chunk)
},
systemPrompt: session.instructions ? session.instructions : { type: 'preset', preset: 'claude_code' },
systemPrompt: session.instructions
? {
type: 'preset',
preset: 'claude_code',
append: session.instructions
}
: { type: 'preset', preset: 'claude_code' },
settingSources: ['project'],
includePartialMessages: true,
permissionMode: session.configuration?.permission_mode,
@@ -136,6 +146,8 @@ class ClaudeCodeService implements AgentServiceInterface {
if (lastAgentSessionId) {
options.resume = lastAgentSessionId
// TODO: use fork session when we support branching sessions
// options.forkSession = true
}
logger.info('Starting Claude Code SDK query', {

View File

@@ -24,6 +24,8 @@ export class AiSdkToChunkAdapter {
private isFirstChunk = true
private enableWebSearch: boolean = false
private onSessionUpdate?: (sessionId: string) => void
private responseStartTimestamp: number | null = null
private firstTokenTimestamp: number | null = null
constructor(
private onChunk: (chunk: Chunk) => void,
@@ -38,6 +40,17 @@ export class AiSdkToChunkAdapter {
this.onSessionUpdate = onSessionUpdate
}
private markFirstTokenIfNeeded() {
if (this.firstTokenTimestamp === null && this.responseStartTimestamp !== null) {
this.firstTokenTimestamp = Date.now()
}
}
private resetTimingState() {
this.responseStartTimestamp = null
this.firstTokenTimestamp = null
}
/**
* 处理 AI SDK 流结果
* @param aiSdkResult AI SDK 的流结果对象
@@ -65,6 +78,8 @@ export class AiSdkToChunkAdapter {
webSearchResults: [],
reasoningId: ''
}
this.resetTimingState()
this.responseStartTimestamp = Date.now()
// Reset link converter state at the start of stream
this.isFirstChunk = true
@@ -77,6 +92,7 @@ export class AiSdkToChunkAdapter {
if (this.enableWebSearch) {
const remainingText = flushLinkConverterBuffer()
if (remainingText) {
this.markFirstTokenIfNeeded()
this.onChunk({
type: ChunkType.TEXT_DELTA,
text: remainingText
@@ -91,6 +107,7 @@ export class AiSdkToChunkAdapter {
}
} finally {
reader.releaseLock()
this.resetTimingState()
}
}
@@ -152,6 +169,7 @@ export class AiSdkToChunkAdapter {
// Only emit chunk if there's text to send
if (finalText) {
this.markFirstTokenIfNeeded()
this.onChunk({
type: ChunkType.TEXT_DELTA,
text: this.accumulate ? final.text : finalText
@@ -176,6 +194,9 @@ export class AiSdkToChunkAdapter {
break
case 'reasoning-delta':
final.reasoningContent += chunk.text || ''
if (chunk.text) {
this.markFirstTokenIfNeeded()
}
this.onChunk({
type: ChunkType.THINKING_DELTA,
text: final.reasoningContent || ''
@@ -275,44 +296,37 @@ export class AiSdkToChunkAdapter {
break
}
case 'finish':
case 'finish': {
const usage = {
completion_tokens: chunk.totalUsage?.outputTokens || 0,
prompt_tokens: chunk.totalUsage?.inputTokens || 0,
total_tokens: chunk.totalUsage?.totalTokens || 0
}
const metrics = this.buildMetrics(chunk.totalUsage)
const baseResponse = {
text: final.text || '',
reasoning_content: final.reasoningContent || ''
}
this.onChunk({
type: ChunkType.BLOCK_COMPLETE,
response: {
text: final.text || '',
reasoning_content: final.reasoningContent || '',
usage: {
completion_tokens: chunk.totalUsage.outputTokens || 0,
prompt_tokens: chunk.totalUsage.inputTokens || 0,
total_tokens: chunk.totalUsage.totalTokens || 0
},
metrics: chunk.totalUsage
? {
completion_tokens: chunk.totalUsage.outputTokens || 0,
time_completion_millsec: 0
}
: undefined
...baseResponse,
usage: { ...usage },
metrics: metrics ? { ...metrics } : undefined
}
})
this.onChunk({
type: ChunkType.LLM_RESPONSE_COMPLETE,
response: {
text: final.text || '',
reasoning_content: final.reasoningContent || '',
usage: {
completion_tokens: chunk.totalUsage.outputTokens || 0,
prompt_tokens: chunk.totalUsage.inputTokens || 0,
total_tokens: chunk.totalUsage.totalTokens || 0
},
metrics: chunk.totalUsage
? {
completion_tokens: chunk.totalUsage.outputTokens || 0,
time_completion_millsec: 0
}
: undefined
...baseResponse,
usage: { ...usage },
metrics: metrics ? { ...metrics } : undefined
}
})
this.resetTimingState()
break
}
// === 源和文件相关事件 ===
case 'source':
@@ -348,6 +362,34 @@ export class AiSdkToChunkAdapter {
default:
}
}
private buildMetrics(totalUsage?: {
inputTokens?: number | null
outputTokens?: number | null
totalTokens?: number | null
}) {
if (!totalUsage) {
return undefined
}
const completionTokens = totalUsage.outputTokens ?? 0
const now = Date.now()
const start = this.responseStartTimestamp ?? now
const firstToken = this.firstTokenTimestamp
const timeFirstToken = Math.max(firstToken != null ? firstToken - start : 0, 0)
const baseForCompletion = firstToken ?? start
let timeCompletion = Math.max(now - baseForCompletion, 0)
if (timeCompletion === 0 && completionTokens > 0) {
timeCompletion = 1
}
return {
completion_tokens: completionTokens,
time_first_token_millsec: timeFirstToken,
time_completion_millsec: timeCompletion
}
}
}
export default AiSdkToChunkAdapter

View File

@@ -14,6 +14,7 @@ import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types'
import type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes'
import { buildClaudeCodeSystemModelMessage } from '@shared/anthropic'
import { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
import AiSdkToChunkAdapter from './chunk/AiSdkToChunkAdapter'
@@ -21,7 +22,6 @@ import LegacyAiProvider from './legacy/index'
import { CompletionsParams, CompletionsResult } from './legacy/middleware/schemas'
import { AiSdkMiddlewareConfig, buildAiSdkMiddlewares } from './middleware/AiSdkMiddlewareBuilder'
import { buildPlugins } from './plugins/PluginBuilder'
import { buildClaudeCodeSystemMessage } from './provider/config/anthropic'
import { createAiSdkProvider } from './provider/factory'
import {
getActualProvider,
@@ -122,13 +122,9 @@ export default class ModernAiProvider {
}
if (this.actualProvider.id === 'anthropic' && this.actualProvider.authType === 'oauth') {
const claudeCodeSystemMessage = buildClaudeCodeSystemMessage(params.system)
const claudeCodeSystemMessage = buildClaudeCodeSystemModelMessage(params.system)
params.system = undefined // 清除原有system避免重复
if (Array.isArray(params.messages)) {
params.messages = [...claudeCodeSystemMessage, ...params.messages]
} else {
params.messages = claudeCodeSystemMessage
}
params.messages = [...claudeCodeSystemMessage, ...(params.messages || [])]
}
if (config.topicId && getEnableDeveloperMode()) {

View File

@@ -24,8 +24,10 @@ import { generateText } from 'ai'
import { isEmpty } from 'lodash'
import { MemoryProcessor } from '../../services/MemoryProcessor'
import { exaSearchTool } from '../tools/ExaSearchTool'
import { knowledgeSearchTool } from '../tools/KnowledgeSearchTool'
import { memorySearchTool } from '../tools/MemorySearchTool'
import { tavilySearchTool } from '../tools/TavilySearchTool'
import { webSearchToolWithPreExtractedKeywords } from '../tools/WebSearchTool'
const logger = loggerService.withContext('SearchOrchestrationPlugin')
@@ -316,13 +318,28 @@ export const searchOrchestrationPlugin = (assistant: Assistant, topicId: string)
const needsSearch = analysisResult.websearch.question && analysisResult.websearch.question[0] !== 'not_needed'
if (needsSearch) {
// onChunk({ type: ChunkType.EXTERNEL_TOOL_IN_PROGRESS })
// logger.info('🌐 Adding web search tool with pre-extracted keywords')
params.tools['builtin_web_search'] = webSearchToolWithPreExtractedKeywords(
assistant.webSearchProviderId,
analysisResult.websearch,
context.requestId
)
// 根据 Provider ID 动态选择工具
switch (assistant.webSearchProviderId) {
case 'exa':
logger.info('🌐 Adding Exa search tool (provider-specific)')
// Exa 工具直接接受单个查询字符串,使用第一个问题或合并所有问题
params.tools['builtin_exa_search'] = exaSearchTool(context.requestId)
break
case 'tavily':
logger.info('🌐 Adding Tavily search tool (provider-specific)')
// Tavily 工具直接接受单个查询字符串
params.tools['builtin_tavily_search'] = tavilySearchTool(context.requestId)
break
default:
logger.info('🌐 Adding web search tool with pre-extracted keywords')
// 其他 Provider 使用通用的 WebSearchTool
params.tools['builtin_web_search'] = webSearchToolWithPreExtractedKeywords(
assistant.webSearchProviderId,
analysisResult.websearch,
context.requestId
)
break
}
}
}

View File

@@ -1,24 +0,0 @@
import { SystemModelMessage } from 'ai'
export function buildClaudeCodeSystemMessage(system?: string): Array<SystemModelMessage> {
const defaultClaudeCodeSystem = `You are Claude Code, Anthropic's official CLI for Claude.`
if (!system || system.trim() === defaultClaudeCodeSystem) {
return [
{
role: 'system',
content: defaultClaudeCodeSystem
}
]
}
return [
{
role: 'system',
content: defaultClaudeCodeSystem
},
{
role: 'system',
content: system
}
]
}

View File

@@ -0,0 +1,166 @@
import { loggerService } from '@logger'
import { REFERENCE_PROMPT } from '@renderer/config/prompts'
import WebSearchService from '@renderer/services/WebSearchService'
import { ProviderSpecificParams, WebSearchProviderResponse } from '@renderer/types'
import { ExtractResults } from '@renderer/utils/extract'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import { z } from 'zod'
const logger = loggerService.withContext('ExaSearchTool')
/**
* Exa 专用搜索工具 - 暴露 Exa 的高级搜索能力给 LLM
* 支持 Neural Search、Category Filtering、Date Range 等功能
*/
export const exaSearchTool = (requestId: string) => {
const webSearchProvider = WebSearchService.getWebSearchProvider('exa')
if (!webSearchProvider) {
throw new Error('Exa provider not found or not configured')
}
return tool({
name: 'builtin_exa_search',
description: `Advanced AI-powered search using Exa.ai with neural understanding and filtering capabilities.
Key Features:
- Neural Search: AI-powered semantic search that understands intent
- Search Type: Choose between neural (AI), keyword (traditional), or auto mode
- Category Filter: Focus on specific content types (company, research paper, news, etc.)
- Date Range: Filter by publication date
- Auto-prompt: Let Exa optimize your query automatically
Best for: Research, finding specific types of content, semantic search, and understanding complex queries.`,
inputSchema: z.object({
query: z.string().describe('The search query - be specific and clear'),
numResults: z.number().min(1).max(20).optional().describe('Number of results to return (1-20, default: 5)'),
type: z
.enum(['neural', 'keyword', 'auto', 'fast'])
.optional()
.describe(
'Search type: neural (embeddings-based), keyword (Google-like SERP), auto (default, intelligently combines both), or fast (streamlined versions)'
),
category: z
.string()
.optional()
.describe(
'Filter by content category: company, research paper, news, github, tweet, movie, song, personal site, pdf, etc.'
),
startPublishedDate: z
.string()
.optional()
.describe('Start date filter based on published date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)'),
endPublishedDate: z
.string()
.optional()
.describe('End date filter based on published date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)'),
startCrawlDate: z
.string()
.optional()
.describe('Start date filter based on crawl date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)'),
endCrawlDate: z
.string()
.optional()
.describe('End date filter based on crawl date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)'),
useAutoprompt: z.boolean().optional().describe('Let Exa optimize your query automatically (recommended: true)')
}),
execute: async (params, { abortSignal }) => {
// 构建 provider 特定参数(排除 query 和 numResults这些由系统控制
const providerParams: ProviderSpecificParams = {
exa: {
type: params.type,
category: params.category,
startPublishedDate: params.startPublishedDate,
endPublishedDate: params.endPublishedDate,
startCrawlDate: params.startCrawlDate,
endCrawlDate: params.endCrawlDate,
useAutoprompt: params.useAutoprompt
}
}
// 构建 ExtractResults 结构
const extractResults: ExtractResults = {
websearch: {
question: [params.query]
}
}
// 统一调用 processWebsearch - 保留所有中间件时间戳、黑名单、tracing、压缩
const finalResults: WebSearchProviderResponse = await WebSearchService.processWebsearch(
webSearchProvider,
extractResults,
requestId,
abortSignal,
providerParams
)
logger.info(`Exa search completed: ${finalResults.results.length} results for "${params.query}"`)
return finalResults
},
toModelOutput: (results) => {
let summary = 'No search results found.'
if (results.query && results.results.length > 0) {
summary = `Found ${results.results.length} relevant sources using Exa AI search. Use [number] format to cite specific information.`
}
const citationData = results.results.map((result, index) => {
const citation: any = {
number: index + 1,
title: result.title,
content: result.content,
url: result.url
}
// 添加 Exa 特有的元数据
if ('favicon' in result && result.favicon) {
citation.favicon = result.favicon
}
if ('author' in result && result.author) {
citation.author = result.author
}
if ('publishedDate' in result && result.publishedDate) {
citation.publishedDate = result.publishedDate
}
if ('score' in result && result.score !== undefined) {
citation.score = result.score
}
if ('highlights' in result && result.highlights) {
citation.highlights = result.highlights
}
return citation
})
// 使用 REFERENCE_PROMPT 格式化引用
const referenceContent = `\`\`\`json\n${JSON.stringify(citationData, null, 2)}\n\`\`\``
const fullInstructions = REFERENCE_PROMPT.replace(
'{question}',
"Based on the Exa search results, please answer the user's question with proper citations."
).replace('{references}', referenceContent)
return {
type: 'content',
value: [
{
type: 'text',
text: 'Exa AI Search: Neural search with semantic understanding and rich metadata (author, publish date, highlights).'
},
{
type: 'text',
text: summary
},
{
type: 'text',
text: fullInstructions
}
]
}
}
})
}
export type ExaSearchToolOutput = InferToolOutput<ReturnType<typeof exaSearchTool>>
export type ExaSearchToolInput = InferToolInput<ReturnType<typeof exaSearchTool>>

View File

@@ -0,0 +1,161 @@
import { loggerService } from '@logger'
import { REFERENCE_PROMPT } from '@renderer/config/prompts'
import WebSearchService from '@renderer/services/WebSearchService'
import { ProviderSpecificParams, WebSearchProviderResponse } from '@renderer/types'
import { ExtractResults } from '@renderer/utils/extract'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import { z } from 'zod'
const logger = loggerService.withContext('TavilySearchTool')
/**
* Tavily 专用搜索工具 - 暴露 Tavily 的高级搜索能力给 LLM
* 支持 AI-powered answers、Search depth control、Topic filtering 等功能
*/
export const tavilySearchTool = (requestId: string) => {
const webSearchProvider = WebSearchService.getWebSearchProvider('tavily')
if (!webSearchProvider) {
throw new Error('Tavily provider not found or not configured')
}
return tool({
name: 'builtin_tavily_search',
description: `AI-powered search using Tavily with direct answers and comprehensive content extraction.
Key Features:
- Direct AI Answer: Get a concise, factual answer extracted from search results
- Search Depth: Choose between basic (fast) or advanced (comprehensive) search
- Topic Focus: Filter by general, news, or finance topics
- Full Content: Access complete webpage content, not just snippets
- Rich Media: Optionally include relevant images from search results
Best for: Quick factual answers, news monitoring, financial research, and comprehensive content analysis.`,
inputSchema: z.object({
query: z.string().describe('The search query - be specific and clear'),
maxResults: z
.number()
.min(1)
.max(20)
.optional()
.describe('Maximum number of results to return (1-20, default: 5)'),
topic: z
.enum(['general', 'news', 'finance'])
.optional()
.describe('Topic filter: general (default), news (latest news), or finance (financial/market data)'),
searchDepth: z
.enum(['basic', 'advanced'])
.optional()
.describe('Search depth: basic (faster, top results) or advanced (slower, more comprehensive)'),
includeAnswer: z
.boolean()
.optional()
.describe('Include AI-generated direct answer extracted from results (default: true)'),
includeRawContent: z
.boolean()
.optional()
.describe('Include full webpage content instead of just snippets (default: true)'),
includeImages: z.boolean().optional().describe('Include relevant images from search results (default: false)')
}),
execute: async (params, { abortSignal }) => {
try {
// 构建 provider 特定参数
const providerParams: ProviderSpecificParams = {
tavily: {
topic: params.topic,
searchDepth: params.searchDepth,
includeAnswer: params.includeAnswer,
includeRawContent: params.includeRawContent,
includeImages: params.includeImages
}
}
// 构建 ExtractResults 结构
const extractResults: ExtractResults = {
websearch: {
question: [params.query]
}
}
// 统一调用 processWebsearch - 保留所有中间件时间戳、黑名单、tracing、压缩
const finalResults: WebSearchProviderResponse = await WebSearchService.processWebsearch(
webSearchProvider,
extractResults,
requestId,
abortSignal,
providerParams
)
logger.info(`Tavily search completed: ${finalResults.results.length} results for "${params.query}"`)
return finalResults
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
logger.info('Tavily search aborted')
throw error
}
logger.error('Tavily search failed:', error as Error)
throw new Error(`Tavily search failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
},
toModelOutput: (results) => {
let summary = 'No search results found.'
if (results.query && results.results.length > 0) {
summary = `Found ${results.results.length} relevant sources using Tavily AI search. Use [number] format to cite specific information.`
}
const citationData = results.results.map((result, index) => {
const citation: any = {
number: index + 1,
title: result.title,
content: result.content,
url: result.url
}
// 添加 Tavily 特有的元数据
if ('answer' in result && result.answer) {
citation.answer = result.answer // Tavily 的直接答案
}
if ('images' in result && result.images && result.images.length > 0) {
citation.images = result.images // Tavily 的图片
}
if ('score' in result && result.score !== undefined) {
citation.score = result.score
}
return citation
})
// 使用 REFERENCE_PROMPT 格式化引用
const referenceContent = `\`\`\`json\n${JSON.stringify(citationData, null, 2)}\n\`\`\``
const fullInstructions = REFERENCE_PROMPT.replace(
'{question}',
"Based on the Tavily search results, please answer the user's question with proper citations."
).replace('{references}', referenceContent)
return {
type: 'content',
value: [
{
type: 'text',
text: 'Tavily AI Search: AI-powered with direct answers, full content extraction, and optional image results.'
},
{
type: 'text',
text: summary
},
{
type: 'text',
text: fullInstructions
}
]
}
}
})
}
export type TavilySearchToolOutput = InferToolOutput<ReturnType<typeof tavilySearchTool>>
export type TavilySearchToolInput = InferToolInput<ReturnType<typeof tavilySearchTool>>

View File

@@ -40,7 +40,7 @@ You can use this tool as-is to search with the prepared queries, or provide addi
.describe('Optional additional context, keywords, or specific focus to enhance the search')
}),
execute: async ({ additionalContext }) => {
execute: async ({ additionalContext }, { abortSignal }) => {
let finalQueries = [...extractedKeywords.question]
if (additionalContext?.trim()) {
@@ -67,7 +67,15 @@ You can use this tool as-is to search with the prepared queries, or provide addi
links: extractedKeywords.links
}
}
searchResults = await WebSearchService.processWebsearch(webSearchProvider!, extractResults, requestId)
// abortSignal?.addEventListener('abort', () => {
// console.log('tool_call_abortSignal', abortSignal?.aborted)
// })
searchResults = await WebSearchService.processWebsearch(
webSearchProvider!,
extractResults,
requestId,
abortSignal
)
return searchResults
},

View File

@@ -75,10 +75,15 @@ export interface CodeEditorProps {
/** CSS class name appended to the default `code-editor` class. */
className?: string
/**
* Whether the editor is editable.
* Whether the editor view is editable.
* @default true
*/
editable?: boolean
/**
* Set the editor state to read only but keep some user interactions, e.g., keymaps.
* @default false
*/
readOnly?: boolean
/**
* Whether the editor is expanded.
* If true, the height and maxHeight props are ignored.
@@ -114,6 +119,7 @@ const CodeEditor = ({
style,
className,
editable = true,
readOnly = false,
expanded = true,
wrapped = true
}: CodeEditorProps) => {
@@ -189,6 +195,7 @@ const CodeEditor = ({
maxHeight={expanded ? undefined : maxHeight}
minHeight={minHeight}
editable={editable}
readOnly={readOnly}
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
theme={activeCmTheme}
extensions={customExtensions}

View File

@@ -1,3 +1,4 @@
import { cn } from '@heroui/react'
import Scrollbar from '@renderer/components/Scrollbar'
import { ChevronRight } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
@@ -17,6 +18,10 @@ export interface HorizontalScrollContainerProps {
dependencies?: readonly unknown[]
scrollDistance?: number
className?: string
classNames?: {
container?: string
content?: string
}
gap?: string
expandable?: boolean
}
@@ -26,6 +31,7 @@ const HorizontalScrollContainer: React.FC<HorizontalScrollContainerProps> = ({
dependencies = [],
scrollDistance = 200,
className,
classNames,
gap = '8px',
expandable = false
}) => {
@@ -95,11 +101,16 @@ const HorizontalScrollContainer: React.FC<HorizontalScrollContainerProps> = ({
return (
<Container
className={className}
className={cn(className, classNames?.container)}
$expandable={expandable}
$disableHoverButton={isScrolledToEnd}
onClick={expandable ? handleContainerClick : undefined}>
<ScrollContent ref={scrollRef} $gap={gap} $isExpanded={isExpanded} $expandable={expandable}>
<ScrollContent
ref={scrollRef}
$gap={gap}
$isExpanded={isExpanded}
$expandable={expandable}
className={cn(classNames?.content)}>
{children}
</ScrollContent>
{canScroll && !isExpanded && !isScrolledToEnd && (

View File

@@ -38,6 +38,7 @@ interface PopupContainerProps {
message?: Message
messages?: Message[]
topic?: Topic
rawContent?: string
}
// 转换文件信息数组为树形结构
@@ -140,7 +141,8 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
resolve,
message,
messages,
topic
topic,
rawContent
}) => {
const defaultObsidianVault = store.getState().settings.defaultObsidianVault
const [state, setState] = useState({
@@ -229,7 +231,9 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
return
}
let markdown = ''
if (topic) {
if (rawContent) {
markdown = rawContent
} else if (topic) {
markdown = await topicToMarkdown(topic, exportReasoning)
} else if (messages && messages.length > 0) {
markdown = messagesToMarkdown(messages, exportReasoning)
@@ -299,7 +303,6 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
}
}
}
return (
<Modal
title={i18n.t('chat.topics.export.obsidian_atributes')}
@@ -410,9 +413,11 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
</Option>
</Select>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_reasoning')}>
<Switch checked={exportReasoning} onChange={setExportReasoning} />
</Form.Item>
{!rawContent && (
<Form.Item label={i18n.t('chat.topics.export.obsidian_reasoning')}>
<Switch checked={exportReasoning} onChange={setExportReasoning} />
</Form.Item>
)}
</Form>
</Modal>
)

View File

@@ -9,6 +9,7 @@ interface ObsidianExportOptions {
topic?: Topic
message?: Message
messages?: Message[]
rawContent?: string
}
export default class ObsidianExportPopup {
@@ -24,6 +25,7 @@ export default class ObsidianExportPopup {
topic={options.topic}
message={options.message}
messages={options.messages}
rawContent={options.rawContent}
obsidianTags={''}
open={true}
resolve={(v) => {

View File

@@ -55,12 +55,15 @@ const PopupContainer: React.FC<Props> = ({ text, title, extension, resolve }) =>
footer={null}>
{extension !== undefined ? (
<Editor
editable={false}
readOnly={true}
expanded={false}
height="100%"
style={{ height: '100%' }}
value={text}
language={extension}
options={{
keymap: true
}}
/>
) : (
<Text>{text}</Text>

View File

@@ -48,7 +48,8 @@ const RichEditor = ({
enableContentSearch = false,
isFullWidth = false,
fontFamily = 'default',
fontSize = 16
fontSize = 16,
enableSpellCheck = false
// toolbarItems: _toolbarItems // TODO: Implement custom toolbar items
}: RichEditorProps & { ref?: React.RefObject<RichEditorRef | null> }) => {
// Use the rich editor hook for complete editor management
@@ -71,6 +72,7 @@ const RichEditor = ({
onBlur,
placeholder,
editable,
enableSpellCheck,
scrollParent: () => scrollContainerRef.current,
onShowTableActionMenu: ({ position, actions }) => {
const iconMap: Record<string, React.ReactNode> = {

View File

@@ -14,6 +14,31 @@ export const RichEditorWrapper = styled.div<{
border-radius: 6px;
background: var(--color-background);
overflow-y: hidden;
.ProseMirror table,
.tiptap table {
table-layout: auto !important;
}
.ProseMirror table th,
.ProseMirror table td,
.tiptap th,
.tiptap td {
white-space: normal !important;
word-wrap: break-word !important;
word-break: break-word !important;
overflow-wrap: break-word !important;
overflow: visible !important;
text-overflow: clip !important;
}
.ProseMirror table th > *,
.ProseMirror table td > *,
.tiptap td > *,
.tiptap th > * {
white-space: normal !important;
overflow: visible !important;
text-overflow: clip !important;
}
width: ${({ $isFullWidth }) => ($isFullWidth ? '100%' : '60%')};
margin: ${({ $isFullWidth }) => ($isFullWidth ? '0' : '0 auto')};
font-family: ${({ $fontFamily }) => ($fontFamily === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)')};
@@ -21,6 +46,7 @@ export const RichEditorWrapper = styled.div<{
${({ $minHeight }) => $minHeight && `min-height: ${$minHeight}px;`}
${({ $maxHeight }) => $maxHeight && `max-height: ${$maxHeight}px;`}
`
export const ToolbarWrapper = styled.div`

View File

@@ -50,6 +50,8 @@ export interface RichEditorProps {
fontFamily?: 'default' | 'serif'
/** Font size in pixels */
fontSize?: number
/** Whether to enable spell check */
enableSpellCheck?: boolean
}
export interface ToolbarItem {

View File

@@ -57,6 +57,8 @@ export interface UseRichEditorOptions {
editable?: boolean
/** Whether to enable table of contents functionality */
enableTableOfContents?: boolean
/** Whether to enable spell check */
enableSpellCheck?: boolean
/** Show table action menu (row/column) with concrete actions and position */
onShowTableActionMenu?: (payload: {
type: 'row' | 'column'
@@ -126,6 +128,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
previewLength = 50,
placeholder = '',
editable = true,
enableSpellCheck = false,
onShowTableActionMenu,
scrollParent
} = options
@@ -410,7 +413,9 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
// Allow text selection even when not editable
style: editable
? ''
: 'user-select: text; -webkit-user-select: text; -moz-user-select: text; -ms-user-select: text;'
: 'user-select: text; -webkit-user-select: text; -moz-user-select: text; -ms-user-select: text;',
// Set spellcheck attribute on the contenteditable element
spellcheck: enableSpellCheck ? 'true' : 'false'
}
},
onUpdate: ({ editor }) => {

View File

@@ -237,7 +237,17 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
onSortEnd={onSortEnd}
className="tabs-sortable"
renderItem={(tab) => (
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
<Tab
key={tab.id}
active={tab.id === activeTabId}
onClick={() => handleTabClick(tab)}
onAuxClick={(e) => {
if (e.button === 1 && tab.id !== 'home') {
e.preventDefault()
e.stopPropagation()
closeTab(tab.id)
}
}}>
<TabHeader>
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps, minAppsCache)}</TabIcon>}
<TabTitle>{getTabTitle(tab.id)}</TabTitle>

View File

@@ -145,7 +145,7 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
{
id: 'dashscope',
name: i18n.t('minapps.qwen'),
url: 'https://tongyi.aliyun.com/qianwen/',
url: 'https://www.tongyi.com/',
logo: QwenModelLogo
},
{

View File

@@ -430,6 +430,12 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
}
],
anthropic: [
{
id: 'claude-sonnet-4-5-20250929',
provider: 'anthropic',
name: 'Claude Sonnet 4.5',
group: 'Claude 4.5'
},
{
id: 'claude-sonnet-4-20250514',
provider: 'anthropic',
@@ -698,6 +704,12 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
name: 'GLM-4.5-Flash',
group: 'GLM-4.5'
},
{
id: 'glm-4.6',
provider: 'zhipu',
name: 'GLM-4.6',
group: 'GLM-4.6'
},
{
id: 'glm-4.5',
provider: 'zhipu',

View File

@@ -178,9 +178,13 @@ export function isGeminiReasoningModel(model?: Model): boolean {
return false
}
// Gemini 支持思考模式的模型正则
export const GEMINI_THINKING_MODEL_REGEX =
/gemini-(?:2\.5.*(?:-latest)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\w-]+)*$/i
export const isSupportedThinkingTokenGeminiModel = (model: Model): boolean => {
const modelId = getLowerBaseModelName(model.id, '/')
if (modelId.includes('gemini-2.5')) {
if (GEMINI_THINKING_MODEL_REGEX.test(modelId)) {
if (modelId.includes('image') || modelId.includes('tts')) {
return false
}
@@ -335,14 +339,20 @@ export const isSupportedReasoningEffortPerplexityModel = (model: Model): boolean
export const isSupportedThinkingTokenZhipuModel = (model: Model): boolean => {
const modelId = getLowerBaseModelName(model.id, '/')
return modelId.includes('glm-4.5')
return ['glm-4.5', 'glm-4.6'].some((id) => modelId.includes(id))
}
export const isDeepSeekHybridInferenceModel = (model: Model) => {
const modelId = getLowerBaseModelName(model.id)
// deepseek官方使用chat和reasoner做推理控制其他provider需要单独判断id可能会有所差别
// openrouter: deepseek/deepseek-chat-v3.1 不知道会不会有其他provider仿照ds官方分出一个同id的作为非思考模式的模型这里有风险
return /deepseek-v3(?:\.1|-1-\d+)/.test(modelId) || modelId.includes('deepseek-chat-v3.1')
// Matches: "deepseek-v3" followed by ".digit" or "-digit".
// Optionally, this can be followed by ".alphanumeric_sequence" or "-alphanumeric_sequence"
// until the end of the string.
// Examples: deepseek-v3.1, deepseek-v3-1, deepseek-v3.1.2, deepseek-v3.1-alpha
// Does NOT match: deepseek-v3.123 (missing separator after '1'), deepseek-v3.x (x isn't a digit)
// TODO: move to utils and add test cases
return /deepseek-v3(?:\.\d|-\d)(?:(\.|-)\w+)?$/.test(modelId) || modelId.includes('deepseek-chat-v3.1')
}
export const isSupportedThinkingTokenDeepSeekModel = isDeepSeekHybridInferenceModel

View File

@@ -12,6 +12,7 @@ const visionAllowedModels = [
'gemini-1\\.5',
'gemini-2\\.0',
'gemini-2\\.5',
'gemini-(flash|pro|flash-lite)-latest',
'gemini-exp',
'claude-3',
'claude-sonnet-4',
@@ -21,7 +22,9 @@ const visionAllowedModels = [
'qwen-vl',
'qwen2-vl',
'qwen2.5-vl',
'qwen3-vl',
'qwen2.5-omni',
'qwen3-omni',
'qvq',
'internvl2',
'grok-vision-beta',

View File

@@ -11,9 +11,12 @@ export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
'i'
)
export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini-.*-flash.*$')
export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini.*-flash.*$')
export const GEMINI_SEARCH_REGEX = new RegExp('gemini-2\\..*', 'i')
export const GEMINI_SEARCH_REGEX = new RegExp(
'gemini-(?:2.*(?:-latest)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\\w-]+)*$',
'i'
)
export const PERPLEXITY_SEARCH_MODELS = [
'sonar-pro',

View File

@@ -108,7 +108,11 @@ export const useCodeTools = () => {
const environmentVariables = codeToolsState?.environmentVariables?.[codeToolsState.selectedCliTool] || ''
// 检查是否可以启动(所有必需字段都已填写)
const canLaunch = Boolean(codeToolsState.selectedCliTool && selectedModel && codeToolsState.currentDirectory)
const canLaunch = Boolean(
codeToolsState.selectedCliTool &&
codeToolsState.currentDirectory &&
(codeToolsState.selectedCliTool === codeTools.githubCopilotCli || selectedModel)
)
return {
// 状态

View File

@@ -48,6 +48,17 @@ export function useActiveTopic(assistantId: string, topic?: Topic) {
}
}, [activeTopic?.id, assistant])
useEffect(() => {
if (!assistant?.topics?.length || !activeTopic) {
return
}
const latestTopic = assistant.topics.find((item) => item.id === activeTopic.id)
if (latestTopic && latestTopic !== activeTopic) {
setActiveTopic(latestTopic)
}
}, [assistant?.topics, activeTopic])
return { activeTopic, setActiveTopic }
}

View File

@@ -1920,6 +1920,12 @@
"provider_settings": "Go to provider settings"
},
"notes": {
"auto_rename": {
"empty_note": "Note is empty, cannot generate name",
"failed": "Failed to generate note name",
"label": "Generate Note Name",
"success": "Note name generated successfully"
},
"characters": "Characters",
"collapse": "Collapse",
"content_placeholder": "Please enter the note content...",
@@ -2001,6 +2007,8 @@
"sort_updated_asc": "Update time (oldest first)",
"sort_updated_desc": "Update time (newest first)",
"sort_z2a": "File name (Z-A)",
"spell_check": "Spell Check",
"spell_check_tooltip": "Enable/Disable spell check",
"star": "Favorite note",
"starred_notes": "Collected notes",
"title": "Notes",

View File

@@ -1920,6 +1920,12 @@
"provider_settings": "跳转到服务商设置界面"
},
"notes": {
"auto_rename": {
"empty_note": "笔记为空,无法生成名称",
"failed": "生成笔记名称失败",
"label": "生成笔记名称",
"success": "笔记名称生成成功"
},
"characters": "字符",
"collapse": "收起",
"content_placeholder": "请输入笔记内容...",
@@ -2001,6 +2007,8 @@
"sort_updated_asc": "更新时间(从旧到新)",
"sort_updated_desc": "更新时间(从新到旧)",
"sort_z2a": "文件名Z-A",
"spell_check": "拼写检查",
"spell_check_tooltip": "启用/禁用拼写检查",
"star": "收藏笔记",
"starred_notes": "收藏的笔记",
"title": "笔记",

View File

@@ -1920,6 +1920,12 @@
"provider_settings": "跳轉到服務商設置界面"
},
"notes": {
"auto_rename": {
"empty_note": "筆記為空,無法生成名稱",
"failed": "生成筆記名稱失敗",
"label": "生成筆記名稱",
"success": "筆記名稱生成成功"
},
"characters": "字符",
"collapse": "收起",
"content_placeholder": "請輸入筆記內容...",
@@ -2001,6 +2007,8 @@
"sort_updated_asc": "更新時間(從舊到新)",
"sort_updated_desc": "更新時間(從新到舊)",
"sort_z2a": "文件名Z-A",
"spell_check": "拼寫檢查",
"spell_check_tooltip": "啟用/禁用拼寫檢查",
"star": "收藏筆記",
"starred_notes": "收藏的筆記",
"title": "筆記",

View File

@@ -1920,6 +1920,12 @@
"provider_settings": "Μετάβαση στις ρυθμίσεις παρόχου"
},
"notes": {
"auto_rename": {
"empty_note": "Το σημείωμα είναι κενό, δεν μπορεί να δημιουργηθεί όνομα",
"failed": "Αποτυχία δημιουργίας ονόματος σημείωσης",
"label": "Δημιουργία ονόματος σημείωσης",
"success": "Η δημιουργία του ονόματος σημειώσεων ολοκληρώθηκε με επιτυχία"
},
"characters": "χαρακτήρας",
"collapse": "σύμπτυξη",
"content_placeholder": "Παρακαλώ εισαγάγετε το περιεχόμενο των σημειώσεων...",
@@ -2001,6 +2007,8 @@
"sort_updated_asc": "χρόνος ενημέρωσης (από παλιά στα νέα)",
"sort_updated_desc": "χρόνος ενημέρωσης (από νεώτερο σε παλαιότερο)",
"sort_z2a": "όνομα αρχείου (Z-A)",
"spell_check": "Έλεγχος ορθογραφίας",
"spell_check_tooltip": "Ενεργοποίηση/Απενεργοποίηση ελέγχου ορθογραφίας",
"star": "Αγαπημένες σημειώσεις",
"starred_notes": "Σημειώσεις συλλογής",
"title": "σημειώσεις",

View File

@@ -1920,6 +1920,12 @@
"provider_settings": "Ir a la configuración del proveedor"
},
"notes": {
"auto_rename": {
"empty_note": "La nota está vacía, no se puede generar un nombre",
"failed": "Error al generar el nombre de la nota",
"label": "Generar nombre de nota",
"success": "Se ha generado correctamente el nombre de la nota"
},
"characters": "carácter",
"collapse": "ocultar",
"content_placeholder": "Introduzca el contenido de la nota...",
@@ -2001,6 +2007,8 @@
"sort_updated_asc": "Fecha de actualización (de más antigua a más reciente)",
"sort_updated_desc": "Fecha de actualización (de más nuevo a más antiguo)",
"sort_z2a": "Nombre de archivo (Z-A)",
"spell_check": "comprobación ortográfica",
"spell_check_tooltip": "Habilitar/deshabilitar revisión ortográfica",
"star": "Notas guardadas",
"starred_notes": "notas guardadas",
"title": "notas",

View File

@@ -1920,6 +1920,12 @@
"provider_settings": "Aller aux paramètres du fournisseur"
},
"notes": {
"auto_rename": {
"empty_note": "La note est vide, impossible de générer un nom",
"failed": "Échec de la génération du nom de note",
"label": "Générer un nom de note",
"success": "La génération du nom de note a réussi"
},
"characters": "caractère",
"collapse": "réduire",
"content_placeholder": "Veuillez saisir le contenu de la note...",
@@ -2001,6 +2007,8 @@
"sort_updated_asc": "Heure de mise à jour (du plus ancien au plus récent)",
"sort_updated_desc": "Date de mise à jour (du plus récent au plus ancien)",
"sort_z2a": "Nom de fichier (Z-A)",
"spell_check": "Vérification orthographique",
"spell_check_tooltip": "Activer/Désactiver la vérification orthographique",
"star": "Notes enregistrées",
"starred_notes": "notes de collection",
"title": "notes",

View File

@@ -1920,6 +1920,12 @@
"provider_settings": "プロバイダー設定に移動"
},
"notes": {
"auto_rename": {
"empty_note": "ノートが空です。名前を生成できません。",
"failed": "ノート名の生成に失敗しました",
"label": "ノート名の生成",
"success": "ノート名の生成に成功しました"
},
"characters": "文字",
"collapse": "閉じる",
"content_placeholder": "メモの内容を入力してください...",
@@ -2001,6 +2007,8 @@
"sort_updated_asc": "更新日時(古い順)",
"sort_updated_desc": "更新日時(新しい順)",
"sort_z2a": "ファイル名Z-A",
"spell_check": "スペルチェック",
"spell_check_tooltip": "スペルチェックの有効/無効",
"star": "お気に入りのノート",
"starred_notes": "収集したノート",
"title": "ノート",

View File

@@ -1920,6 +1920,12 @@
"provider_settings": "Ir para as configurações do provedor"
},
"notes": {
"auto_rename": {
"empty_note": "A nota está vazia, não é possível gerar um nome",
"failed": "Falha ao gerar o nome da nota",
"label": "Gerar nome da nota",
"success": "Nome da nota gerado com sucesso"
},
"characters": "caractere",
"collapse": "[minimizar]",
"content_placeholder": "Introduza o conteúdo da nota...",
@@ -2001,6 +2007,8 @@
"sort_updated_asc": "Tempo de atualização (do mais antigo para o mais recente)",
"sort_updated_desc": "atualização de tempo (do mais novo para o mais antigo)",
"sort_z2a": "Nome do arquivo (Z-A)",
"spell_check": "verificação ortográfica",
"spell_check_tooltip": "Ativar/Desativar verificação ortográfica",
"star": "Notas favoritas",
"starred_notes": "notas salvas",
"title": "nota",

View File

@@ -1920,6 +1920,12 @@
"provider_settings": "Перейти к настройкам поставщика"
},
"notes": {
"auto_rename": {
"empty_note": "Заметки пусты, имя невозможно сгенерировать",
"failed": "Создание названия заметки не удалось",
"label": "Создать название заметки",
"success": "Имя заметки успешно создано"
},
"characters": "Символы",
"collapse": "Свернуть",
"content_placeholder": "Введите содержимое заметки...",
@@ -2001,6 +2007,8 @@
"sort_updated_asc": "Время обновления (от старого к новому)",
"sort_updated_desc": "Время обновления (от нового к старому)",
"sort_z2a": "Имя файла (Я-А)",
"spell_check": "Проверка орфографии",
"spell_check_tooltip": "Включить/отключить проверку орфографии",
"star": "Избранные заметки",
"starred_notes": "Сохраненные заметки",
"title": "заметки",

View File

@@ -98,6 +98,10 @@ const CodeToolsPage: FC = () => {
return m.id.includes('openai') || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(m.provider)
}
if (selectedCliTool === codeTools.githubCopilotCli) {
return false
}
if (selectedCliTool === codeTools.qwenCode || selectedCliTool === codeTools.iFlowCli) {
if (m.supported_endpoint_types) {
return ['openai', 'openai-response'].some((type) =>
@@ -196,7 +200,7 @@ const CodeToolsPage: FC = () => {
}
}
if (!selectedModel) {
if (!selectedModel && selectedCliTool !== codeTools.githubCopilotCli) {
return { isValid: false, message: t('code.model_required') }
}
@@ -205,6 +209,11 @@ const CodeToolsPage: FC = () => {
// 准备启动环境
const prepareLaunchEnvironment = async (): Promise<Record<string, string> | null> => {
if (selectedCliTool === codeTools.githubCopilotCli) {
const userEnv = parseEnvironmentVariables(environmentVariables)
return userEnv
}
if (!selectedModel) return null
const modelProvider = getProviderByModel(selectedModel)
@@ -229,7 +238,9 @@ const CodeToolsPage: FC = () => {
// 执行启动操作
const executeLaunch = async (env: Record<string, string>) => {
window.api.codeTools.run(selectedCliTool, selectedModel?.id!, currentDirectory, env, {
const modelId = selectedCliTool === codeTools.githubCopilotCli ? '' : selectedModel?.id!
window.api.codeTools.run(selectedCliTool, modelId, currentDirectory, env, {
autoUpdateToLatest,
terminal: selectedTerminal
})
@@ -316,7 +327,12 @@ const CodeToolsPage: FC = () => {
banner
style={{ borderRadius: 'var(--list-item-border-radius)' }}
message={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<span>{t('code.bun_required_message')}</span>
<Button
type="primary"
@@ -345,46 +361,64 @@ const CodeToolsPage: FC = () => {
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('code.model')}
{selectedCliTool === 'claude-code' && (
<Popover
content={
<div style={{ width: 200 }}>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('code.supported_providers')}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{getClaudeSupportedProviders(allProviders).map((provider) => {
return (
<Link
key={provider.id}
style={{ color: 'var(--color-text)', display: 'flex', alignItems: 'center', gap: 4 }}
to={`/settings/provider?id=${provider.id}`}>
<ProviderLogo shape="square" src={getProviderLogo(provider.id)} size={20} />
{getProviderLabel(provider.id)}
<ArrowUpRight size={14} />
</Link>
)
})}
{selectedCliTool !== codeTools.githubCopilotCli && (
<SettingsItem>
<div className="settings-label">
{t('code.model')}
{selectedCliTool === 'claude-code' && (
<Popover
content={
<div style={{ width: 200 }}>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('code.supported_providers')}</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 8
}}>
{getClaudeSupportedProviders(allProviders).map((provider) => {
return (
<Link
key={provider.id}
style={{
color: 'var(--color-text)',
display: 'flex',
alignItems: 'center',
gap: 4
}}
to={`/settings/provider?id=${provider.id}`}>
<ProviderLogo shape="square" src={getProviderLogo(provider.id)} size={20} />
{getProviderLabel(provider.id)}
<ArrowUpRight size={14} />
</Link>
)
})}
</div>
</div>
</div>
}
trigger="hover"
placement="right">
<HelpCircle size={14} style={{ color: 'var(--color-text-3)', cursor: 'pointer' }} />
</Popover>
)}
</div>
<ModelSelector
providers={availableProviders}
predicate={modelPredicate}
style={{ width: '100%' }}
placeholder={t('code.model_placeholder')}
value={selectedModel ? getModelUniqId(selectedModel) : undefined}
onChange={handleModelChange}
allowClear
/>
</SettingsItem>
}
trigger="hover"
placement="right">
<HelpCircle
size={14}
style={{
color: 'var(--color-text-3)',
cursor: 'pointer'
}}
/>
</Popover>
)}
</div>
<ModelSelector
providers={availableProviders}
predicate={modelPredicate}
style={{ width: '100%' }}
placeholder={t('code.model_placeholder')}
value={selectedModel ? getModelUniqId(selectedModel) : undefined}
onChange={handleModelChange}
allowClear
/>
</SettingsItem>
)}
<SettingsItem>
<div className="settings-label">{t('code.working_directory')}</div>
@@ -403,11 +437,27 @@ const CodeToolsPage: FC = () => {
options={directories.map((dir) => ({
value: dir,
label: (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{dir}</span>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<span
style={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{dir}
</span>
<X
size={14}
style={{ marginLeft: 8, cursor: 'pointer', color: '#999' }}
style={{
marginLeft: 8,
cursor: 'pointer',
color: '#999'
}}
onClick={(e) => handleRemoveDirectory(dir, e)}
/>
</div>
@@ -429,7 +479,14 @@ const CodeToolsPage: FC = () => {
rows={2}
style={{ fontFamily: 'monospace' }}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-3)', marginTop: 4 }}>{t('code.env_vars_help')}</div>
<div
style={{
fontSize: 12,
color: 'var(--color-text-3)',
marginTop: 4
}}>
{t('code.env_vars_help')}
</div>
</SettingsItem>
{/* 终端选择 (macOS 和 Windows) */}
@@ -464,7 +521,12 @@ const CodeToolsPage: FC = () => {
selectedTerminal !== terminalApps.cmd &&
selectedTerminal !== terminalApps.powershell &&
selectedTerminal !== terminalApps.windowsTerminal && (
<div style={{ fontSize: 12, color: 'var(--color-text-3)', marginTop: 4 }}>
<div
style={{
fontSize: 12,
color: 'var(--color-text-3)',
marginTop: 4
}}>
{terminalCustomPaths[selectedTerminal]
? `${t('code.custom_path')}: ${terminalCustomPaths[selectedTerminal]}`
: t('code.custom_path_required')}

View File

@@ -20,7 +20,8 @@ export const CLI_TOOLS = [
{ value: codeTools.qwenCode, label: 'Qwen Code' },
{ value: codeTools.geminiCli, label: 'Gemini CLI' },
{ value: codeTools.openaiCodex, label: 'OpenAI Codex' },
{ value: codeTools.iFlowCli, label: 'iFlow CLI' }
{ value: codeTools.iFlowCli, label: 'iFlow CLI' },
{ value: codeTools.githubCopilotCli, label: 'GitHub Copilot CLI' }
]
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', 'cherryin']
@@ -43,7 +44,8 @@ export const CLI_TOOL_PROVIDER_MAP: Record<string, (providers: Provider[]) => Pr
[codeTools.qwenCode]: (providers) => providers.filter((p) => p.type.includes('openai')),
[codeTools.openaiCodex]: (providers) =>
providers.filter((p) => p.id === 'openai' || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(p.id)),
[codeTools.iFlowCli]: (providers) => providers.filter((p) => p.type.includes('openai'))
[codeTools.iFlowCli]: (providers) => providers.filter((p) => p.type.includes('openai')),
[codeTools.githubCopilotCli]: () => []
}
export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => {
@@ -158,6 +160,10 @@ export const generateToolEnvironment = ({
env.IFLOW_BASE_URL = baseUrl
env.IFLOW_MODEL_NAME = model.id
break
case codeTools.githubCopilotCli:
env.GITHUB_TOKEN = apiKey || ''
break
}
return env

View File

@@ -1,105 +0,0 @@
# Inputbar Unification Plan
## Goal
Create a single configurable input bar that supports chat topics, agent sessions, and other contexts (e.g. mini window) without duplicating UI logic. Remove `AgentSessionInputbar.tsx` in favour of the shared implementation.
## Tasks
### 1. Configuration Layer
- [ ] Add `InputbarScope` registry (e.g. `src/renderer/src/config/registry/inputbar.ts`).
- [ ] Define per-scope options (features toggles, placeholders, min/max rows, token counter, quick panel, attachments, knowledge picker, mention models, translate button, abort button, etc.).
- [ ] Register defaults for chat (`TopicType.Chat`), agent session (`TopicType.Session`), and mini window scope.
### 2. InputbarTools Registry System (NEW)
- [ ] Create `ToolDefinition` interface with key, label, icon, condition, dependencies, and render function
- [ ] Implement tool registration mechanism in `src/renderer/src/config/registry/inputbarTools.ts`
- [ ] Create `InputbarToolsProvider` for shared state management (files, mentionedModels, knowledgeBases, etc.)
- [ ] Define tool context interfaces (`ToolContext`, `ToolRenderContext`) for dependency injection
- [ ] Migrate existing tools to registry-based definitions:
- [ ] new_topic tool
- [ ] attachment tool
- [ ] thinking tool
- [ ] web_search tool
- [ ] url_context tool
- [ ] knowledge_base tool
- [ ] mcp_tools tool
- [ ] generate_image tool
- [ ] mention_models tool
- [ ] quick_phrases tool
- [ ] clear_topic tool
- [ ] toggle_expand tool
- [ ] new_context tool
- [ ] Simplify InputbarTools component to use registry (reduce from 19 props to 3-5)
- [ ] Integrate tool visibility/order configuration with InputbarScope
### 3. Shared UI Composer
- [ ] Extract common UI from `Inputbar.tsx` into new `InputComposer` component that reads config + callbacks.
- [ ] Ensure composer handles textarea sizing, focus, drag/drop, token estimation, attachments, toolbar slots based on config.
- [ ] Provide controlled props for text, files, mentioned models, loading states, quick panel interactions.
### 4. Chat Wrapper Migration
- [ ] Refactor `Inputbar.tsx` to:
- Resolve scope via topic type.
- Fetch config via registry.
- Supply send/abort/translate/knowledge handlers to composer.
- Remove inline UI duplication now covered by composer.
- [ ] Verify chat-specific behaviour (knowledge save, auto translate, quick panel, model mentions) via config flags and callbacks.
### 5. Agent Session Wrapper Migration
- [ ] Rebuild session input bar (currently `AgentSessionInputbar.tsx`) as thin wrapper using composer and session scope config.
- [ ] Use session-specific hooks for message creation, model resolution, aborting, and streaming state.
- [ ] Once parity confirmed, delete `AgentSessionInputbar.tsx` and update all imports.
### 6. Cross-cutting Cleanup
- [ ] Remove duplicated state caches (`_text`, `_files`, `_mentionedModelsCache`) once wrappers manage persistence appropriately.
- [ ] Update typings (`MessageInputBaseParams`, etc.) if composer needs shared interfaces.
- [ ] Ensure quick panel integration works for all scopes (guard behind config flag).
### 7. Verification
- [ ] Run `yarn build:check` (after cleaning existing lint issues in WebSearchTool/ReadTool).
- [ ] Manual QA for chat topics, agent sessions, and mini window input: send, abort, attachments, translate, quick panel triggers, knowledge save.
- [ ] Add doc entry summarising registry usage and scope configuration.
## Notes
- Aligns with the approach taken for `MessageMenubar` scope registry.
- Composer should accept refs for external focus triggers (e.g. `MessageGroup` or session auto-focus).
- Plan to remove now-unused session-specific styles/components once migration completes.
## Implementation Details
### InputbarTools Registry Architecture
**Problem**: Current InputbarTools has 19 props causing severe prop drilling and coupling.
**Solution**: Registry-based tool system with dependency injection:
```typescript
// Tool Definition
interface ToolDefinition {
key: string
label: string | ((t: TFunction) => string)
icon?: React.ComponentType
condition?: (context: ToolContext) => boolean
visibleInScopes?: InputbarScope[]
dependencies?: { hooks?, refs?, state? }
render: (context: ToolRenderContext) => ReactNode
}
// Context Provider for shared state
InputbarToolsProvider manages:
- files, mentionedModels, knowledgeBases states
- setText, resizeTextArea actions
- Tool refs management
// Simplified Component Interface
InputbarTools props reduced to:
- scope: InputbarScope
- assistantId: string
- onNewContext?: () => void
```
**Benefits**:
- Decoupled tool definitions
- Easy to add/remove tools per scope
- Type-safe dependency injection
- Maintains drag-drop functionality
- Reduces component complexity from 19 to 3-5 props

View File

@@ -1,4 +1,6 @@
import { cn } from '@heroui/react'
import { loggerService } from '@logger'
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
import Scrollbar from '@renderer/components/Scrollbar'
import { useMessageEditing } from '@renderer/context/MessageEditingContext'
import { useAssistant } from '@renderer/hooks/useAssistant'
@@ -225,20 +227,28 @@ const MessageItem: FC<Props> = ({
</MessageErrorBoundary>
</MessageContentContainer>
{showMenubar && (
<MessageFooter className="MessageFooter" $isLastMessage={isLastMessage} $messageStyle={messageStyle}>
<MessageMenubar
message={message}
assistant={assistant}
model={model}
index={index}
topic={topic}
isLastMessage={isLastMessage}
isAssistantMessage={isAssistantMessage}
isGrouped={isGrouped}
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
setModel={setModel}
onUpdateUseful={onUpdateUseful}
/>
<MessageFooter className="MessageFooter">
<HorizontalScrollContainer
classNames={{
content: cn(
'flex-1 items-center justify-between',
isLastMessage && messageStyle === 'plain' ? 'flex-row-reverse' : 'flex-row'
)
}}>
<MessageMenubar
message={message}
assistant={assistant}
model={model}
index={index}
topic={topic}
isLastMessage={isLastMessage}
isAssistantMessage={isAssistantMessage}
isGrouped={isGrouped}
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
setModel={setModel}
onUpdateUseful={onUpdateUseful}
/>
</HorizontalScrollContainer>
</MessageFooter>
)}
</>
@@ -282,10 +292,8 @@ const MessageContentContainer = styled(Scrollbar)`
overflow-y: auto;
`
const MessageFooter = styled.div<{ $isLastMessage: boolean; $messageStyle: 'plain' | 'bubble' }>`
const MessageFooter = styled.div`
display: flex;
flex-direction: ${({ $isLastMessage, $messageStyle }) =>
$isLastMessage && $messageStyle === 'plain' ? 'row-reverse' : 'row'};
align-items: center;
justify-content: space-between;
gap: 10px;

View File

@@ -337,17 +337,30 @@ const GroupContainer = styled.div`
const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }>`
width: 100%;
display: grid;
overflow-y: visible;
gap: 16px;
&.horizontal {
padding-bottom: 4px;
grid-template-columns: repeat(${({ $count }) => $count}, minmax(420px, 1fr));
overflow-y: hidden;
overflow-x: auto;
&::-webkit-scrollbar {
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--color-scrollbar-thumb);
border-radius: var(--scrollbar-thumb-radius);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--color-scrollbar-thumb-hover);
}
}
&.fold,
&.vertical {
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 8px;
overflow-y: auto;
overflow-x: hidden;
}
&.grid {
grid-template-columns: repeat(
@@ -355,11 +368,15 @@ const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }
minmax(0, 1fr)
);
grid-template-rows: auto;
overflow-y: auto;
overflow-x: hidden;
}
&.multi-select-mode {
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 10px;
overflow-y: auto;
overflow-x: hidden;
.grid {
height: auto;
}
@@ -385,7 +402,7 @@ interface MessageWrapperProps {
const MessageWrapper = styled.div<MessageWrapperProps>`
&.horizontal {
padding: 1px;
overflow-y: auto;
/* overflow-y: auto; */
.message {
height: 100%;
border: 0.5px solid var(--color-border);
@@ -405,8 +422,9 @@ const MessageWrapper = styled.div<MessageWrapperProps>`
}
}
&.grid {
display: block;
height: 300px;
overflow-y: hidden;
overflow: hidden;
border: 0.5px solid var(--color-border);
border-radius: 10px;
cursor: pointer;

View File

@@ -1,5 +1,8 @@
import { NormalToolResponse } from '@renderer/types'
import type { ToolMessageBlock } from '@renderer/types/newMessage'
import { MessageBlockStatus, ToolMessageBlock } from '@renderer/types/newMessage'
import { TFunction } from 'i18next'
import { Pause } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { MessageAgentTools } from './MessageAgentTools'
import { MessageKnowledgeSearchToolTitle } from './MessageKnowledgeSearch'
@@ -35,14 +38,28 @@ const isAgentTool = (toolName: string) => {
return false
}
const ChooseTool = (toolResponse: NormalToolResponse): React.ReactNode | null => {
const ChooseTool = (
toolResponse: NormalToolResponse,
status: MessageBlockStatus,
t: TFunction
): React.ReactNode | null => {
let toolName = toolResponse.tool.name
const toolType = toolResponse.tool.type
if (toolName.startsWith(prefix)) {
toolName = toolName.slice(prefix.length)
if (status === MessageBlockStatus.PAUSED) {
return (
<div className="flex items-center gap-1">
<Pause className="h-4 w-4" />
<span>{t('message.tools.aborted')}</span>
</div>
)
}
switch (toolName) {
case 'web_search':
case 'web_search_preview':
case 'exa_search':
case 'tavily_search':
return toolType === 'provider' ? null : <MessageWebSearchToolTitle toolResponse={toolResponse} />
case 'knowledge_search':
return <MessageKnowledgeSearchToolTitle toolResponse={toolResponse} />
@@ -58,12 +75,13 @@ const ChooseTool = (toolResponse: NormalToolResponse): React.ReactNode | null =>
}
export default function MessageTool({ block }: Props) {
const { t } = useTranslation()
// FIXME: 语义错误,这里已经不是 MCP tool 了,更改rawMcpToolResponse需要改用户数据, 所以暂时保留
const toolResponse = block.metadata?.rawMcpToolResponse as NormalToolResponse
if (!toolResponse) return null
const toolRenderer = ChooseTool(toolResponse as NormalToolResponse)
const toolRenderer = ChooseTool(toolResponse as NormalToolResponse, block.status, t)
if (!toolRenderer) return null

View File

@@ -1,3 +1,5 @@
import { ExaSearchToolInput, ExaSearchToolOutput } from '@renderer/aiCore/tools/ExaSearchTool'
import { TavilySearchToolInput, TavilySearchToolOutput } from '@renderer/aiCore/tools/TavilySearchTool'
import { WebSearchToolInput, WebSearchToolOutput } from '@renderer/aiCore/tools/WebSearchTool'
import Spinner from '@renderer/components/Spinner'
import { NormalToolResponse } from '@renderer/types'
@@ -8,17 +10,31 @@ import styled from 'styled-components'
const { Text } = Typography
// 联合类型 - 支持多种搜索工具
type SearchToolInput = WebSearchToolInput | ExaSearchToolInput | TavilySearchToolInput
type SearchToolOutput = WebSearchToolOutput | ExaSearchToolOutput | TavilySearchToolOutput
export const MessageWebSearchToolTitle = ({ toolResponse }: { toolResponse: NormalToolResponse }) => {
const { t } = useTranslation()
const toolInput = toolResponse.arguments as WebSearchToolInput
const toolOutput = toolResponse.response as WebSearchToolOutput
const toolInput = toolResponse.arguments as SearchToolInput
const toolOutput = toolResponse.response as SearchToolOutput
// 根据不同的工具类型获取查询内容
const getQueryText = () => {
if ('additionalContext' in toolInput) {
return toolInput.additionalContext ?? ''
}
if ('query' in toolInput) {
return toolInput.query ?? ''
}
return ''
}
return toolResponse.status !== 'done' ? (
<Spinner
text={
<PrepareToolWrapper>
{t('message.searching')}
<span>{toolInput?.additionalContext ?? ''}</span>
<span>{getQueryText()}</span>
</PrepareToolWrapper>
}
/>

View File

@@ -390,6 +390,7 @@ const Container = styled.div`
border-radius: var(--list-item-border-radius);
border: 0.5px solid transparent;
width: calc(var(--assistants-width) - 20px);
cursor: pointer;
&:hover {
background-color: var(--color-list-item-hover);

View File

@@ -130,8 +130,8 @@ const Assistants: FC<AssistantsProps> = ({
)}
</TagsContainer>
))}
{renderAddAssistantButton}
</div>
{renderAddAssistantButton}
</>
)
}

View File

@@ -1,11 +1,16 @@
import ActionIconButton from '@renderer/components/Buttons/ActionIconButton'
import CodeEditor from '@renderer/components/CodeEditor'
import { HSpaceBetweenStack } from '@renderer/components/Layout'
import RichEditor from '@renderer/components/RichEditor'
import { RichEditorRef } from '@renderer/components/RichEditor/types'
import Selector from '@renderer/components/Selector'
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
import { useSettings } from '@renderer/hooks/useSettings'
import { useAppDispatch } from '@renderer/store'
import { setEnableSpellCheck } from '@renderer/store/settings'
import { EditorView } from '@renderer/types'
import { Empty } from 'antd'
import { Empty, Tooltip } from 'antd'
import { SpellCheck } from 'lucide-react'
import { FC, memo, RefObject, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -21,7 +26,9 @@ interface NotesEditorProps {
const NotesEditor: FC<NotesEditorProps> = memo(
({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef }) => {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const { settings } = useNotesSettings()
const { enableSpellCheck } = useSettings()
const currentViewMode = useMemo(() => {
if (settings.defaultViewMode === 'edit') {
return settings.defaultEditMode
@@ -78,6 +85,7 @@ const NotesEditor: FC<NotesEditorProps> = memo(
isFullWidth={settings.isFullWidth}
fontFamily={settings.fontFamily}
fontSize={settings.fontSize}
enableSpellCheck={enableSpellCheck}
/>
)}
</RichEditorContainer>
@@ -92,8 +100,21 @@ const NotesEditor: FC<NotesEditorProps> = memo(
color: 'var(--color-text-3)',
display: 'flex',
alignItems: 'center',
gap: 8
gap: 12
}}>
{tmpViewMode === 'preview' && (
<Tooltip placement="top" title={t('notes.spell_check_tooltip')} mouseLeaveDelay={0} arrow>
<ActionIconButton
active={enableSpellCheck}
onClick={() => {
const newValue = !enableSpellCheck
dispatch(setEnableSpellCheck(newValue))
window.api.setEnableSpellCheck(newValue)
}}>
<SpellCheck size={18} />
</ActionIconButton>
</Tooltip>
)}
<Selector
value={tmpViewMode as EditorView}
onChange={(value: EditorView) => setTmpViewMode(value)}

View File

@@ -6,11 +6,14 @@ import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit'
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader'
import { useAppSelector } from '@renderer/store'
import { fetchNoteSummary } from '@renderer/services/ApiService'
import { RootState, useAppSelector } from '@renderer/store'
import { selectSortType } from '@renderer/store/note'
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
import { exportNote } from '@renderer/utils/export'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Dropdown, Input, InputRef, MenuProps } from 'antd'
import { ItemType, MenuItemType } from 'antd/es/menu/interface'
import {
ChevronDown,
ChevronRight,
@@ -20,11 +23,14 @@ import {
FileSearch,
Folder,
FolderOpen,
Sparkles,
Star,
StarOff
StarOff,
UploadIcon
} from 'lucide-react'
import { FC, memo, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
interface NotesSidebarProps {
@@ -50,6 +56,8 @@ interface TreeNodeProps {
selectedFolderId?: string | null
activeNodeId?: string
editingNodeId: string | null
renamingNodeIds: Set<string>
newlyRenamedNodeIds: Set<string>
draggedNodeId: string | null
dragOverNodeId: string | null
dragPosition: 'before' | 'inside' | 'after'
@@ -72,6 +80,8 @@ const TreeNode = memo<TreeNodeProps>(
selectedFolderId,
activeNodeId,
editingNodeId,
renamingNodeIds,
newlyRenamedNodeIds,
draggedNodeId,
dragOverNodeId,
dragPosition,
@@ -92,6 +102,8 @@ const TreeNode = memo<TreeNodeProps>(
? node.type === 'folder' && node.id === selectedFolderId
: node.id === activeNodeId
const isEditing = editingNodeId === node.id && inPlaceEdit.isEditing
const isRenaming = renamingNodeIds.has(node.id)
const isNewlyRenamed = newlyRenamedNodeIds.has(node.id)
const hasChildren = node.children && node.children.length > 0
const isDragging = draggedNodeId === node.id
const isDragOver = dragOverNodeId === node.id
@@ -99,6 +111,12 @@ const TreeNode = memo<TreeNodeProps>(
const isDragInside = isDragOver && dragPosition === 'inside'
const isDragAfter = isDragOver && dragPosition === 'after'
const getNodeNameClassName = () => {
if (isRenaming) return 'shimmer'
if (isNewlyRenamed) return 'typing'
return ''
}
return (
<div key={node.id}>
<Dropdown menu={{ items: getMenuItems(node) }} trigger={['contextMenu']}>
@@ -156,7 +174,7 @@ const TreeNode = memo<TreeNodeProps>(
size="small"
/>
) : (
<NodeName>{node.name}</NodeName>
<NodeName className={getNodeNameClassName()}>{node.name}</NodeName>
)}
</TreeNodeContent>
</TreeNodeContainer>
@@ -173,6 +191,8 @@ const TreeNode = memo<TreeNodeProps>(
selectedFolderId={selectedFolderId}
activeNodeId={activeNodeId}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
@@ -213,7 +233,10 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
const { bases } = useKnowledgeBases()
const { activeNode } = useActiveNode(notesTree)
const sortType = useAppSelector(selectSortType)
const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions)
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
const [renamingNodeIds, setRenamingNodeIds] = useState<Set<string>>(new Set())
const [newlyRenamedNodeIds, setNewlyRenamedNodeIds] = useState<Set<string>>(new Set())
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
const [dragOverNodeId, setDragOverNodeId] = useState<string | null>(null)
const [dragPosition, setDragPosition] = useState<'before' | 'inside' | 'after'>('inside')
@@ -336,6 +359,66 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
[bases.length, t]
)
const handleImageAction = useCallback(
async (node: NotesTreeNode, platform: 'copyImage' | 'exportImage') => {
try {
if (activeNode?.id !== node.id) {
onSelectNode(node)
await new Promise((resolve) => setTimeout(resolve, 500))
}
await exportNote({ node, platform })
} catch (error) {
logger.error(`Failed to ${platform === 'copyImage' ? 'copy' : 'export'} as image:`, error as Error)
window.toast.error(t('common.copy_failed'))
}
},
[activeNode, onSelectNode, t]
)
const handleAutoRename = useCallback(
async (note: NotesTreeNode) => {
if (note.type !== 'file') return
setRenamingNodeIds((prev) => new Set(prev).add(note.id))
try {
const content = await window.api.file.readExternal(note.externalPath)
if (!content || content.trim().length === 0) {
window.toast.warning(t('notes.auto_rename.empty_note'))
return
}
const summaryText = await fetchNoteSummary({ content })
if (summaryText) {
onRenameNode(note.id, summaryText)
window.toast.success(t('notes.auto_rename.success'))
} else {
window.toast.error(t('notes.auto_rename.failed'))
}
} catch (error) {
window.toast.error(t('notes.auto_rename.failed'))
logger.error(`Failed to auto-rename note: ${error}`)
} finally {
setRenamingNodeIds((prev) => {
const next = new Set(prev)
next.delete(note.id)
return next
})
setNewlyRenamedNodeIds((prev) => new Set(prev).add(note.id))
setTimeout(() => {
setNewlyRenamedNodeIds((prev) => {
const next = new Set(prev)
next.delete(note.id)
return next
})
}, 700)
}
},
[onRenameNode, t]
)
const handleDragStart = useCallback((e: React.DragEvent, node: NotesTreeNode) => {
setDraggedNodeId(node.id)
e.dataTransfer.effectAllowed = 'move'
@@ -490,7 +573,22 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
const getMenuItems = useCallback(
(node: NotesTreeNode) => {
const baseMenuItems: MenuProps['items'] = [
const baseMenuItems: MenuProps['items'] = []
// only show auto rename for file for now
if (node.type !== 'folder') {
baseMenuItems.push({
label: t('notes.auto_rename.label'),
key: 'auto-rename',
icon: <Sparkles size={14} />,
disabled: renamingNodeIds.has(node.id),
onClick: () => {
handleAutoRename(node)
}
})
}
baseMenuItems.push(
{
label: t('notes.rename'),
key: 'rename',
@@ -507,7 +605,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
window.api.openPath(node.externalPath)
}
}
]
)
if (node.type !== 'folder') {
baseMenuItems.push(
{
@@ -525,6 +623,58 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
onClick: () => {
handleExportKnowledge(node)
}
},
{
label: t('chat.topics.export.title'),
key: 'export',
icon: <UploadIcon size={14} />,
children: [
exportMenuOptions.image && {
label: t('chat.topics.copy.image'),
key: 'copy-image',
onClick: () => handleImageAction(node, 'copyImage')
},
exportMenuOptions.image && {
label: t('chat.topics.export.image'),
key: 'export-image',
onClick: () => handleImageAction(node, 'exportImage')
},
exportMenuOptions.markdown && {
label: t('chat.topics.export.md.label'),
key: 'markdown',
onClick: () => exportNote({ node, platform: 'markdown' })
},
exportMenuOptions.docx && {
label: t('chat.topics.export.word'),
key: 'word',
onClick: () => exportNote({ node, platform: 'docx' })
},
exportMenuOptions.notion && {
label: t('chat.topics.export.notion'),
key: 'notion',
onClick: () => exportNote({ node, platform: 'notion' })
},
exportMenuOptions.yuque && {
label: t('chat.topics.export.yuque'),
key: 'yuque',
onClick: () => exportNote({ node, platform: 'yuque' })
},
exportMenuOptions.obsidian && {
label: t('chat.topics.export.obsidian'),
key: 'obsidian',
onClick: () => exportNote({ node, platform: 'obsidian' })
},
exportMenuOptions.joplin && {
label: t('chat.topics.export.joplin'),
key: 'joplin',
onClick: () => exportNote({ node, platform: 'joplin' })
},
exportMenuOptions.siyuan && {
label: t('chat.topics.export.siyuan'),
key: 'siyuan',
onClick: () => exportNote({ node, platform: 'siyuan' })
}
].filter(Boolean) as ItemType<MenuItemType>[]
}
)
}
@@ -543,7 +693,17 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
return baseMenuItems
},
[t, handleStartEdit, onToggleStar, handleExportKnowledge, handleDeleteNode]
[
t,
handleStartEdit,
onToggleStar,
handleExportKnowledge,
handleImageAction,
handleDeleteNode,
renamingNodeIds,
handleAutoRename,
exportMenuOptions
]
)
const handleDropFiles = useCallback(
@@ -680,6 +840,8 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
@@ -724,6 +886,8 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
@@ -746,6 +910,8 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
@@ -933,6 +1099,44 @@ const NodeName = styled.div`
text-overflow: ellipsis;
font-size: 13px;
color: var(--color-text);
position: relative;
will-change: background-position, width;
--color-shimmer-mid: var(--color-text-1);
--color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent);
&.shimmer {
background: linear-gradient(to left, var(--color-shimmer-end), var(--color-shimmer-mid), var(--color-shimmer-end));
background-size: 200% 100%;
background-clip: text;
color: transparent;
animation: shimmer 3s linear infinite;
}
&.typing {
display: block;
white-space: nowrap;
overflow: hidden;
animation: typewriter 0.5s steps(40, end);
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@keyframes typewriter {
from {
width: 0;
}
to {
width: 100%;
}
}
`
const EditInput = styled(Input)`
@@ -953,7 +1157,7 @@ const DragOverIndicator = styled.div`
`
const DropHintNode = styled.div`
margin: 8px;
margin: 6px 0;
margin-bottom: 20px;
${TreeNodeContainer} {

View File

@@ -14,7 +14,7 @@ import { checkApi } from '@renderer/services/ApiService'
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
import { useAppDispatch } from '@renderer/store'
import { updateWebSearchProvider } from '@renderer/store/websearch'
import { isSystemProvider } from '@renderer/types'
import { isSystemProvider, isSystemProviderId, SystemProviderIds } from '@renderer/types'
import { ApiKeyConnectivity, HealthStatus } from '@renderer/types/healthCheck'
import {
formatApiHost,
@@ -56,7 +56,21 @@ interface Props {
providerId: string
}
const ANTHROPIC_COMPATIBLE_PROVIDER_IDS = ['deepseek', 'moonshot', 'zhipu', 'dashscope', 'modelscope', 'aihubmix']
const ANTHROPIC_COMPATIBLE_PROVIDER_IDS = [
SystemProviderIds.deepseek,
SystemProviderIds.moonshot,
SystemProviderIds.zhipu,
SystemProviderIds.dashscope,
SystemProviderIds.modelscope,
SystemProviderIds.aihubmix,
SystemProviderIds.grok
] as const
type AnthropicCompatibleProviderId = (typeof ANTHROPIC_COMPATIBLE_PROVIDER_IDS)[number]
const ANTHROPIC_COMPATIBLE_PROVIDER_ID_SET = new Set<string>(ANTHROPIC_COMPATIBLE_PROVIDER_IDS)
const isAnthropicCompatibleProviderId = (id: string): id is AnthropicCompatibleProviderId => {
return ANTHROPIC_COMPATIBLE_PROVIDER_ID_SET.has(id)
}
const ProviderSetting: FC<Props> = ({ providerId }) => {
const { provider, updateProvider, models } = useProvider(providerId)
@@ -265,7 +279,9 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
}, [provider.anthropicApiHost])
const canConfigureAnthropicHost = useMemo(() => {
return provider.type !== 'anthropic' && ANTHROPIC_COMPATIBLE_PROVIDER_IDS.includes(provider.id)
return (
provider.type !== 'anthropic' && isSystemProviderId(provider.id) && isAnthropicCompatibleProviderId(provider.id)
)
}, [provider])
const anthropicHostPreview = useMemo(() => {
@@ -396,7 +412,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
<>
<SettingSubtitle style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Tooltip title={t('settings.provider.api_host_tooltip')} mouseEnterDelay={0.3}>
<span>{t('settings.provider.api_host')}</span>
<SubtitleLabel>{t('settings.provider.api_host')}</SubtitleLabel>
</Tooltip>
<Button
type="text"
@@ -440,7 +456,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
justifyContent: 'space-between'
}}>
<Tooltip title={t('settings.provider.anthropic_api_host_tooltip')} mouseEnterDelay={0.3}>
<span>{t('settings.provider.anthropic_api_host')}</span>
<SubtitleLabel>{t('settings.provider.anthropic_api_host')}</SubtitleLabel>
</Tooltip>
</SettingSubtitle>
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
@@ -451,14 +467,13 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
onBlur={onUpdateAnthropicHost}
/>
</Space.Compact>
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
<SettingHelpText
style={{ marginLeft: 6, marginRight: '1em', whiteSpace: 'break-spaces', wordBreak: 'break-all' }}>
<SettingHelpTextRow style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '4px' }}>
<SettingHelpText style={{ marginLeft: 6, whiteSpace: 'break-spaces', wordBreak: 'break-all' }}>
{t('settings.provider.anthropic_api_host_preview', {
url: anthropicHostPreview || '—'
})}
</SettingHelpText>
<SettingHelpText style={{ minWidth: 'fit-content', whiteSpace: 'normal' }}>
<SettingHelpText style={{ marginLeft: 6 }}>
{t('settings.provider.anthropic_api_host_tip')}
</SettingHelpText>
</SettingHelpTextRow>
@@ -496,6 +511,15 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
)
}
const SubtitleLabel = styled.span`
display: inline-flex;
align-items: center;
line-height: inherit;
font-size: inherit;
font-weight: inherit;
color: inherit;
`
const ProviderName = styled.span`
font-size: 14px;
font-weight: 500;

View File

@@ -1,5 +1,5 @@
import { WebSearchState } from '@renderer/store/websearch'
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
import { ProviderSpecificParams, WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
export default abstract class BaseWebSearchProvider {
// @ts-ignore this
@@ -16,7 +16,8 @@ export default abstract class BaseWebSearchProvider {
abstract search(
query: string,
websearch: WebSearchState,
httpOptions?: RequestInit
httpOptions?: RequestInit,
providerParams?: ProviderSpecificParams
): Promise<WebSearchProviderResponse>
public getApiHost() {

View File

@@ -1,6 +1,6 @@
import { loggerService } from '@logger'
import { WebSearchState } from '@renderer/store/websearch'
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
import { ProviderSpecificParams, WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
import { BochaSearchParams, BochaSearchResponse } from '@renderer/utils/bocha'
import BaseWebSearchProvider from './BaseWebSearchProvider'
@@ -18,7 +18,12 @@ export default class BochaProvider extends BaseWebSearchProvider {
}
}
public async search(query: string, websearch: WebSearchState): Promise<WebSearchProviderResponse> {
public async search(
query: string,
websearch: WebSearchState,
httpOptions?: RequestInit,
_providerParams?: ProviderSpecificParams
): Promise<WebSearchProviderResponse> {
try {
if (!query.trim()) {
throw new Error('Search query cannot be empty')
@@ -44,7 +49,8 @@ export default class BochaProvider extends BaseWebSearchProvider {
headers: {
...this.defaultHeaders(),
...headers
}
},
signal: httpOptions?.signal
})
if (!response.ok) {

View File

@@ -1,9 +1,15 @@
import { WebSearchProviderResponse } from '@renderer/types'
import { WebSearchState } from '@renderer/store/websearch'
import { ProviderSpecificParams, WebSearchProviderResponse } from '@renderer/types'
import BaseWebSearchProvider from './BaseWebSearchProvider'
export default class DefaultProvider extends BaseWebSearchProvider {
search(): Promise<WebSearchProviderResponse> {
search(
_query: string,
_websearch: WebSearchState,
_httpOptions?: RequestInit,
_providerParams?: ProviderSpecificParams
): Promise<WebSearchProviderResponse> {
throw new Error('Method not implemented.')
}
}

View File

@@ -1,14 +1,53 @@
import { ExaClient } from '@agentic/exa'
import { loggerService } from '@logger'
import { WebSearchState } from '@renderer/store/websearch'
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
import {
ExaSearchResult as ExaSearchResultType,
ProviderSpecificParams,
WebSearchProvider,
WebSearchProviderResponse
} from '@renderer/types'
import BaseWebSearchProvider from './BaseWebSearchProvider'
const logger = loggerService.withContext('ExaProvider')
export default class ExaProvider extends BaseWebSearchProvider {
private exa: ExaClient
interface ExaSearchRequest {
query: string
numResults: number
contents?: {
text?: boolean
highlights?: boolean
summary?: boolean
}
useAutoprompt?: boolean
category?: string
type?: 'keyword' | 'neural' | 'auto' | 'fast'
startPublishedDate?: string
endPublishedDate?: string
startCrawlDate?: string
endCrawlDate?: string
includeDomains?: string[]
excludeDomains?: string[]
}
interface ExaSearchResult {
title: string | null
url: string | null
text?: string | null
author?: string | null
score?: number
publishedDate?: string | null
favicon?: string | null
highlights?: string[]
}
interface ExaSearchResponse {
autopromptString?: string
results: ExaSearchResult[]
resolvedSearchType?: string
}
export default class ExaProvider extends BaseWebSearchProvider {
constructor(provider: WebSearchProvider) {
super(provider)
if (!this.apiKey) {
@@ -17,34 +56,138 @@ export default class ExaProvider extends BaseWebSearchProvider {
if (!this.apiHost) {
throw new Error('API host is required for Exa provider')
}
this.exa = new ExaClient({ apiKey: this.apiKey, apiBaseUrl: this.apiHost })
}
public async search(query: string, websearch: WebSearchState): Promise<WebSearchProviderResponse> {
/**
* 统一的搜索方法 - 根据 providerParams 决定是否使用高级参数
*/
public async search(
query: string,
websearch: WebSearchState,
httpOptions?: RequestInit,
providerParams?: ProviderSpecificParams
): Promise<WebSearchProviderResponse> {
// 如果提供了 Exa 特定参数,使用高级搜索
if (providerParams?.exa) {
return this.searchWithParams({
query,
numResults: websearch.maxResults,
...providerParams.exa, // 展开高级参数
signal: httpOptions?.signal ?? undefined
})
}
// 否则使用默认参数
return this.searchWithParams({
query,
numResults: websearch.maxResults,
useAutoprompt: true,
signal: httpOptions?.signal ?? undefined
})
}
/**
* 使用完整参数进行搜索(支持 Exa 的所有高级功能)
*/
public async searchWithParams(params: {
query: string
numResults?: number
type?: 'keyword' | 'neural' | 'auto' | 'fast'
category?: string
startPublishedDate?: string
endPublishedDate?: string
startCrawlDate?: string
endCrawlDate?: string
useAutoprompt?: boolean
includeDomains?: string[]
excludeDomains?: string[]
signal?: AbortSignal
}): Promise<WebSearchProviderResponse> {
try {
if (!query.trim()) {
if (!params.query.trim()) {
throw new Error('Search query cannot be empty')
}
const response = await this.exa.search({
query,
numResults: Math.max(1, websearch.maxResults),
const requestBody: ExaSearchRequest = {
query: params.query,
numResults: Math.max(1, params.numResults || 5),
contents: {
text: true
}
text: true,
highlights: true // 获取高亮片段
},
useAutoprompt: params.useAutoprompt ?? true
}
// 添加可选参数
if (params.type) {
requestBody.type = params.type
}
if (params.category) {
requestBody.category = params.category
}
if (params.startPublishedDate) {
requestBody.startPublishedDate = params.startPublishedDate
}
if (params.endPublishedDate) {
requestBody.endPublishedDate = params.endPublishedDate
}
if (params.startCrawlDate) {
requestBody.startCrawlDate = params.startCrawlDate
}
if (params.endCrawlDate) {
requestBody.endCrawlDate = params.endCrawlDate
}
if (params.includeDomains && params.includeDomains.length > 0) {
requestBody.includeDomains = params.includeDomains
}
if (params.excludeDomains && params.excludeDomains.length > 0) {
requestBody.excludeDomains = params.excludeDomains
}
const response = await fetch(`${this.apiHost}/search`, {
method: 'POST',
headers: {
'x-api-key': this.apiKey!,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody),
signal: params.signal
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Exa API error (${response.status}): ${errorText}`)
}
const data: ExaSearchResponse = await response.json()
// 返回完整的 Exa 结果(包含 favicon、author、score 等字段)
return {
query: response.autopromptString,
results: response.results.slice(0, websearch.maxResults).map((result) => {
return {
query: data.autopromptString || params.query,
results: data.results.slice(0, params.numResults || 5).map(
(result): ExaSearchResultType => ({
title: result.title || 'No title',
content: result.text || '',
url: result.url || ''
}
})
url: result.url || '',
favicon: result.favicon || undefined,
publishedDate: result.publishedDate || undefined,
author: result.author || undefined,
score: result.score,
highlights: result.highlights
})
)
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw error
}
logger.error('Exa search failed:', error as Error)
throw new Error(`Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
}

View File

@@ -2,7 +2,12 @@ import { loggerService } from '@logger'
import { nanoid } from '@reduxjs/toolkit'
import store from '@renderer/store'
import { WebSearchState } from '@renderer/store/websearch'
import { WebSearchProvider, WebSearchProviderResponse, WebSearchProviderResult } from '@renderer/types'
import {
ProviderSpecificParams,
WebSearchProvider,
WebSearchProviderResponse,
WebSearchProviderResult
} from '@renderer/types'
import { createAbortPromise } from '@renderer/utils/abortController'
import { isAbortError } from '@renderer/utils/error'
import { fetchWebContent, noContent } from '@renderer/utils/fetch'
@@ -27,7 +32,8 @@ export default class LocalSearchProvider extends BaseWebSearchProvider {
public async search(
query: string,
websearch: WebSearchState,
httpOptions?: RequestInit
httpOptions?: RequestInit,
_providerParams?: ProviderSpecificParams
): Promise<WebSearchProviderResponse> {
const uid = nanoid()
const language = store.getState().settings.language

View File

@@ -1,7 +1,7 @@
import { SearxngClient } from '@agentic/searxng'
import { loggerService } from '@logger'
import { WebSearchState } from '@renderer/store/websearch'
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
import { ProviderSpecificParams, WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
import { fetchWebContent, noContent } from '@renderer/utils/fetch'
import axios from 'axios'
import ky from 'ky'
@@ -95,7 +95,12 @@ export default class SearxngProvider extends BaseWebSearchProvider {
}
}
public async search(query: string, websearch: WebSearchState): Promise<WebSearchProviderResponse> {
public async search(
query: string,
websearch: WebSearchState,
httpOptions?: RequestInit,
_providerParams?: ProviderSpecificParams
): Promise<WebSearchProviderResponse> {
try {
if (!query) {
throw new Error('Search query cannot be empty')
@@ -124,7 +129,7 @@ export default class SearxngProvider extends BaseWebSearchProvider {
// Fetch content for each URL concurrently
const fetchPromises = validItems.map(async (item) => {
// Logger.log(`Fetching content for ${item.url}...`)
return await fetchWebContent(item.url, 'markdown', this.provider.usingBrowser)
return await fetchWebContent(item.url, 'markdown', this.provider.usingBrowser, httpOptions)
})
// Wait for all fetches to complete

View File

@@ -1,14 +1,45 @@
import { TavilyClient } from '@agentic/tavily'
import { loggerService } from '@logger'
import { WebSearchState } from '@renderer/store/websearch'
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
import {
ProviderSpecificParams,
TavilySearchResult as TavilySearchResultType,
WebSearchProvider,
WebSearchProviderResponse
} from '@renderer/types'
import BaseWebSearchProvider from './BaseWebSearchProvider'
const logger = loggerService.withContext('TavilyProvider')
export default class TavilyProvider extends BaseWebSearchProvider {
private tvly: TavilyClient
interface TavilySearchRequest {
query: string
max_results?: number
topic?: 'general' | 'news' | 'finance'
search_depth?: 'basic' | 'advanced'
include_answer?: boolean
include_raw_content?: boolean
include_images?: boolean
include_domains?: string[]
exclude_domains?: string[]
}
interface TavilySearchResult {
title: string
url: string
content: string
raw_content?: string
score?: number
}
interface TavilySearchResponse {
query: string
results: TavilySearchResult[]
answer?: string
images?: string[]
response_time?: number
}
export default class TavilyProvider extends BaseWebSearchProvider {
constructor(provider: WebSearchProvider) {
super(provider)
if (!this.apiKey) {
@@ -17,30 +48,119 @@ export default class TavilyProvider extends BaseWebSearchProvider {
if (!this.apiHost) {
throw new Error('API host is required for Tavily provider')
}
this.tvly = new TavilyClient({ apiKey: this.apiKey, apiBaseUrl: this.apiHost })
}
public async search(query: string, websearch: WebSearchState): Promise<WebSearchProviderResponse> {
/**
* 统一的搜索方法 - 根据 providerParams 决定是否使用高级参数
*/
public async search(
query: string,
websearch: WebSearchState,
httpOptions?: RequestInit,
providerParams?: ProviderSpecificParams
): Promise<WebSearchProviderResponse> {
// 如果提供了 Tavily 特定参数,使用高级搜索
if (providerParams?.tavily) {
return this.searchWithParams({
query,
maxResults: websearch.maxResults,
...providerParams.tavily, // 展开高级参数
signal: httpOptions?.signal ?? undefined
})
}
// 否则使用默认参数
return this.searchWithParams({
query,
maxResults: websearch.maxResults,
includeRawContent: true,
signal: httpOptions?.signal ?? undefined
})
}
/**
* 使用完整参数进行搜索(支持 Tavily 的所有高级功能)
*/
public async searchWithParams(params: {
query: string
maxResults?: number
topic?: 'general' | 'news' | 'finance'
searchDepth?: 'basic' | 'advanced'
includeAnswer?: boolean
includeRawContent?: boolean
includeImages?: boolean
includeDomains?: string[]
excludeDomains?: string[]
signal?: AbortSignal
}): Promise<WebSearchProviderResponse> {
try {
if (!query.trim()) {
if (!params.query.trim()) {
throw new Error('Search query cannot be empty')
}
const result = await this.tvly.search({
query,
max_results: Math.max(1, websearch.maxResults)
const requestBody: TavilySearchRequest = {
query: params.query,
max_results: Math.max(1, params.maxResults || 5),
include_raw_content: params.includeRawContent ?? true,
include_answer: params.includeAnswer ?? true,
include_images: params.includeImages ?? false
}
// 添加可选参数
if (params.topic) {
requestBody.topic = params.topic
}
if (params.searchDepth) {
requestBody.search_depth = params.searchDepth
}
if (params.includeDomains && params.includeDomains.length > 0) {
requestBody.include_domains = params.includeDomains
}
if (params.excludeDomains && params.excludeDomains.length > 0) {
requestBody.exclude_domains = params.excludeDomains
}
const response = await fetch(`${this.apiHost}/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
...requestBody,
api_key: this.apiKey
}),
signal: params.signal
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Tavily API error (${response.status}): ${errorText}`)
}
const data: TavilySearchResponse = await response.json()
// 返回完整的 Tavily 结果(包含 answer、images 等字段)
return {
query: result.query,
results: result.results.slice(0, websearch.maxResults).map((result) => {
return {
title: result.title || 'No title',
content: result.content || '',
url: result.url || ''
}
})
query: data.query,
results: data.results.slice(0, params.maxResults || 5).map(
(item): TavilySearchResultType => ({
title: item.title || 'No title',
content: item.raw_content || item.content || '',
url: item.url || '',
rawContent: item.raw_content,
score: item.score,
answer: data.answer, // Tavily 的直接答案
images: data.images // Tavily 的图片
})
)
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw error
}
logger.error('Tavily search failed:', error as Error)
throw new Error(`Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
}

View File

@@ -1,6 +1,6 @@
import { loggerService } from '@logger'
import { WebSearchState } from '@renderer/store/websearch'
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
import { ProviderSpecificParams, WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
import BaseWebSearchProvider from './BaseWebSearchProvider'
@@ -43,7 +43,12 @@ export default class ZhipuProvider extends BaseWebSearchProvider {
}
}
public async search(query: string, websearch: WebSearchState): Promise<WebSearchProviderResponse> {
public async search(
query: string,
websearch: WebSearchState,
httpOptions?: RequestInit,
_providerParams?: ProviderSpecificParams
): Promise<WebSearchProviderResponse> {
try {
if (!query.trim()) {
throw new Error('Search query cannot be empty')
@@ -62,7 +67,8 @@ export default class ZhipuProvider extends BaseWebSearchProvider {
'Content-Type': 'application/json',
...this.defaultHeaders()
},
body: JSON.stringify(requestBody)
body: JSON.stringify(requestBody),
signal: httpOptions?.signal
})
if (!response.ok) {

View File

@@ -1,6 +1,6 @@
import { withSpanResult } from '@renderer/services/SpanManagerService'
import type { WebSearchState } from '@renderer/store/websearch'
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
import { ProviderSpecificParams, WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
import { filterResultWithBlacklist } from '@renderer/utils/blacklistMatchPattern'
import BaseWebSearchProvider from './BaseWebSearchProvider'
@@ -24,10 +24,11 @@ export default class WebSearchEngineProvider {
public async search(
query: string,
websearch: WebSearchState,
httpOptions?: RequestInit
httpOptions?: RequestInit,
providerParams?: ProviderSpecificParams
): Promise<WebSearchProviderResponse> {
const callSearch = async ({ query, websearch }) => {
return await this.sdk.search(query, websearch, httpOptions)
const callSearch = async ({ query, websearch, providerParams }) => {
return await this.sdk.search(query, websearch, httpOptions, providerParams)
}
const traceParams = {
@@ -38,7 +39,7 @@ export default class WebSearchEngineProvider {
modelName: this.modelName
}
const result = await withSpanResult(callSearch, traceParams, { query, websearch })
const result = await withSpanResult(callSearch, traceParams, { query, websearch, providerParams })
return await filterResultWithBlacklist(result, websearch)
}

View File

@@ -251,6 +251,68 @@ export async function fetchMessagesSummary({ messages, assistant }: { messages:
}
}
export async function fetchNoteSummary({ content, assistant }: { content: string; assistant?: Assistant }) {
let prompt = (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.title')
const resolvedAssistant = assistant || getDefaultAssistant()
const model = getQuickModel() || resolvedAssistant.model || getDefaultModel()
if (prompt && containsSupportedVariables(prompt)) {
prompt = await replacePromptVariables(prompt, model.name)
}
const provider = getProviderByModel(model)
if (!hasApiKey(provider)) {
return null
}
const AI = new AiProviderNew(model)
// only 2000 char and no images
const truncatedContent = content.substring(0, 2000)
const purifiedContent = purifyMarkdownImages(truncatedContent)
const summaryAssistant = {
...resolvedAssistant,
settings: {
...resolvedAssistant.settings,
reasoning_effort: undefined,
qwenThinkMode: false
},
prompt,
model
}
const llmMessages = {
system: prompt,
prompt: purifiedContent
}
const middlewareConfig: AiSdkMiddlewareConfig = {
streamOutput: false,
enableReasoning: false,
isPromptToolUse: false,
isSupportedToolUse: false,
isImageGenerationEndpoint: false,
enableWebSearch: false,
enableGenerateImage: false,
enableUrlContext: false,
mcpTools: []
}
try {
const { getText } = await AI.completions(model.id, llmMessages, {
...middlewareConfig,
assistant: summaryAssistant,
callType: 'summary'
})
const text = getText()
return removeSpecialCharactersForTopicName(text) || null
} catch (error: any) {
return null
}
}
// export async function fetchSearchSummary({ messages, assistant }: { messages: Message[]; assistant: Assistant }) {
// const model = getQuickModel() || assistant.model || getDefaultModel()
// const provider = getProviderByModel(model)

View File

@@ -89,6 +89,7 @@ export async function restore() {
}
await handleData(data)
notificationService.send({
id: uuid(),
type: 'success',
@@ -850,6 +851,12 @@ export async function handleData(data: Record<string, any>) {
if (data.version >= 2) {
localStorage.setItem('persist:cherry-studio', data.localStorage['persist:cherry-studio'])
// remove notes_tree from indexedDB
if (data.indexedDB['notes_tree']) {
delete data.indexedDB['notes_tree']
}
await restoreDatabase(data.indexedDB)
if (data.version === 3) {

View File

@@ -10,6 +10,7 @@ import {
KnowledgeBase,
KnowledgeItem,
KnowledgeReference,
ProviderSpecificParams,
WebSearchProvider,
WebSearchProviderResponse,
WebSearchProviderResult,
@@ -161,13 +162,17 @@ class WebSearchService {
* @public
* @param provider 搜索提供商
* @param query 搜索查询
* @param httpOptions HTTP选项包含signal等
* @param spanId Span ID用于追踪
* @param providerParams Provider特定参数如Exa的category、Tavily的searchDepth等
* @returns 搜索响应
*/
public async search(
provider: WebSearchProvider,
query: string,
httpOptions?: RequestInit,
spanId?: string
spanId?: string,
providerParams?: ProviderSpecificParams
): Promise<WebSearchProviderResponse> {
const websearch = this.getWebSearchState()
const webSearchEngine = new WebSearchEngineProvider(provider, spanId)
@@ -178,7 +183,7 @@ class WebSearchService {
formattedQuery = `today is ${dayjs().format('YYYY-MM-DD')} \r\n ${query}`
}
return await webSearchEngine.search(formattedQuery, websearch, httpOptions)
return await webSearchEngine.search(formattedQuery, websearch, httpOptions, providerParams)
}
/**
@@ -424,13 +429,17 @@ class WebSearchService {
* @param webSearchProvider - 要使用的网络搜索提供商
* @param extractResults - 包含搜索问题和链接的提取结果对象
* @param requestId - 唯一的请求标识符,用于状态跟踪和资源管理
* @param externalSignal - 可选的 AbortSignal 用于取消请求
* @param providerParams - 可选的 Provider 特定参数(如 Exa 的 category、Tavily 的 searchDepth 等)
*
* @returns 包含搜索结果的响应对象
*/
public async processWebsearch(
webSearchProvider: WebSearchProvider,
extractResults: ExtractResults,
requestId: string
requestId: string,
externalSignal?: AbortSignal,
providerParams?: ProviderSpecificParams
): Promise<WebSearchProviderResponse> {
// 重置状态
await this.setWebSearchStatus(requestId, { phase: 'default' })
@@ -441,8 +450,8 @@ class WebSearchService {
return { results: [] }
}
// 使用请求特定的signal如果没有则回退到全局signal
const signal = this.getRequestState(requestId).signal || this.signal
// 优先使用外部传入的signal其次是请求特定的signal最后回退到全局signal
const signal = externalSignal || this.getRequestState(requestId).signal || this.signal
const span = webSearchProvider.topicId
? addSpan({
@@ -473,8 +482,9 @@ class WebSearchService {
return { query: 'summaries', results: contents }
}
// 执行搜索
const searchPromises = questions.map((q) =>
this.search(webSearchProvider, q, { signal }, span?.spanContext().spanId)
this.search(webSearchProvider, q, { signal }, span?.spanContext().spanId, providerParams)
)
const searchResults = await Promise.allSettled(searchPromises)

View File

@@ -84,7 +84,8 @@ export const createToolCallbacks = (deps: ToolCallbacksDependencies) => {
}
blockManager.smartBlockUpdate(existingBlockId, changes, MessageBlockType.TOOL, true)
// Handle citation block creation for web search results
if (toolResponse.tool.name === 'builtin_web_search' && toolResponse.response) {
const webSearchTools = ['builtin_web_search', 'builtin_exa_search', 'builtin_tavily_search']
if (webSearchTools.includes(toolResponse.tool.name) && toolResponse.response) {
const citationBlock = createCitationBlock(
assistantMsgId,
{

View File

@@ -26,12 +26,17 @@ export const initialState: CodeToolsState = {
[codeTools.qwenCode]: null,
[codeTools.claudeCode]: null,
[codeTools.geminiCli]: null,
[codeTools.openaiCodex]: null
[codeTools.openaiCodex]: null,
[codeTools.iFlowCli]: null,
[codeTools.githubCopilotCli]: null
},
environmentVariables: {
'qwen-code': '',
'claude-code': '',
'gemini-cli': ''
'gemini-cli': '',
'openai-codex': '',
'iflow-cli': '',
'github-copilot-cli': ''
},
directories: [],
currentDirectory: '',
@@ -63,7 +68,10 @@ const codeToolsSlice = createSlice({
state.environmentVariables = {
'qwen-code': '',
'claude-code': '',
'gemini-cli': ''
'gemini-cli': '',
'openai-codex': '',
'iflow-cli': '',
'github-copilot-cli': ''
}
}
state.environmentVariables[state.selectedCliTool] = action.payload

View File

@@ -4,6 +4,7 @@ import { createEntityAdapter, createSelector, createSlice, type PayloadAction }
import { AISDKWebSearchResult, Citation, WebSearchProviderResponse, WebSearchSource } from '@renderer/types'
import type { CitationMessageBlock, MessageBlock } from '@renderer/types/newMessage'
import { MessageBlockType } from '@renderer/types/newMessage'
import { adaptSearchResultsToCitations } from '@renderer/utils/searchResultAdapters'
import type OpenAI from 'openai'
import type { RootState } from './index' // 确认 RootState 从 store/index.ts 导出
@@ -217,17 +218,12 @@ export const formatCitationsFromBlock = (block: CitationMessageBlock | undefined
type: 'websearch'
})) || []
break
case WebSearchSource.WEBSEARCH:
formattedCitations =
(block.response.results as WebSearchProviderResponse)?.results?.map((result, index) => ({
number: index + 1,
url: result.url,
title: result.title,
content: result.content,
showFavicon: true,
type: 'websearch'
})) || []
case WebSearchSource.WEBSEARCH: {
const results = (block.response.results as WebSearchProviderResponse)?.results || []
// 使用适配器统一转换,自动处理 Provider 特定字段(如 Exa 的 favicon、Tavily 的 answer 等)
formattedCitations = adaptSearchResultsToCitations(results)
break
}
case WebSearchSource.AISDK:
formattedCitations =
(block.response?.results as AISDKWebSearchResult[])?.map((result, index) => ({

View File

@@ -94,6 +94,15 @@ function addProvider(state: RootState, id: string) {
}
}
// Fix missing provider
function fixMissingProvider(state: RootState) {
SYSTEM_PROVIDERS.forEach((p) => {
if (!state.llm.providers.find((provider) => provider.id === p.id)) {
state.llm.providers.push(p)
}
})
}
// add ocr provider
function addOcrProvider(state: RootState, provider: BuiltinOcrProvider) {
if (!state.ocr.providers.find((p) => p.id === provider.id)) {
@@ -2580,6 +2589,7 @@ const migrateConfig = {
'159': (state: RootState) => {
try {
addProvider(state, 'ovms')
fixMissingProvider(state)
return state
} catch (error) {
logger.error('migrate 158 error', error as Error)
@@ -2635,11 +2645,13 @@ const migrateConfig = {
case 'cherryai':
provider.anthropicApiHost = 'https://api.cherry-ai.com'
break
case 'grok':
provider.anthropicApiHost = 'https://api.x.ai'
}
})
return state
} catch (error) {
logger.error('migrate 159 error', error as Error)
logger.error('migrate 160 error', error as Error)
return state
}
}

View File

@@ -575,17 +575,63 @@ export type WebSearchProvider = {
modelName?: string
}
export type WebSearchProviderResult = {
// 基础搜索结果(所有 Provider 必须实现)
export interface BaseSearchResult {
title: string
content: string
url: string
}
// Exa Provider 特定扩展
export interface ExaSearchResult extends BaseSearchResult {
favicon?: string
publishedDate?: string
author?: string
score?: number
highlights?: string[]
}
// Tavily Provider 特定扩展
export interface TavilySearchResult extends BaseSearchResult {
answer?: string // Tavily 的 AI 直接答案
images?: string[]
rawContent?: string
score?: number
}
// 联合类型 - 向后兼容
export type WebSearchProviderResult = BaseSearchResult | ExaSearchResult | TavilySearchResult
export type WebSearchProviderResponse = {
query?: string
results: WebSearchProviderResult[]
}
// Provider 特定参数类型
export interface ExaSearchParams {
type?: 'neural' | 'keyword' | 'auto' | 'fast'
category?: string
startPublishedDate?: string
endPublishedDate?: string
startCrawlDate?: string
endCrawlDate?: string
useAutoprompt?: boolean
}
export interface TavilySearchParams {
topic?: 'general' | 'news' | 'finance'
searchDepth?: 'basic' | 'advanced'
includeAnswer?: boolean
includeRawContent?: boolean
includeImages?: boolean
}
// 联合类型 - 支持不同 Provider 的特定参数
export interface ProviderSpecificParams {
exa?: ExaSearchParams
tavily?: TavilySearchParams
}
export type AISDKWebSearchResult = Omit<Extract<LanguageModelV2Source, { sourceType: 'url' }>, 'sourceType'>
export type WebSearchResults =
@@ -813,6 +859,7 @@ export interface Citation {
hostname?: string
content?: string
showFavicon?: boolean
favicon?: string // 新增:直接的 favicon URL来自 Provider
type?: string
metadata?: Record<string, any>
}

View File

@@ -9,6 +9,7 @@ import { setExportState } from '@renderer/store/runtime'
import type { Topic } from '@renderer/types'
import type { Message } from '@renderer/types/newMessage'
import { removeSpecialCharactersForFileName } from '@renderer/utils/file'
import { captureScrollableAsBlob, captureScrollableAsDataURL } from '@renderer/utils/image'
import { convertMathFormula, markdownToPlainText } from '@renderer/utils/markdown'
import { getCitationContent, getMainTextContent, getThinkingContent } from '@renderer/utils/messageUtils/find'
import { markdownToBlocks } from '@tryfabric/martian'
@@ -1082,3 +1083,103 @@ export const exportTopicToNotes = async (topic: Topic, folderPath: string): Prom
throw error
}
}
const exportNoteAsMarkdown = async (noteName: string, content: string): Promise<void> => {
const markdown = `# ${noteName}\n\n${content}`
const fileName = removeSpecialCharactersForFileName(noteName) + '.md'
const result = await window.api.file.save(fileName, markdown)
if (result) {
window.toast.success(i18n.t('message.success.markdown.export.specified'))
}
}
const getScrollableElement = (): HTMLElement | null => {
const notesPage = document.querySelector('#notes-page')
if (!notesPage) return null
const allDivs = notesPage.querySelectorAll('div')
for (const div of Array.from(allDivs)) {
const style = window.getComputedStyle(div)
if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
if (div.querySelector('.ProseMirror')) {
return div as HTMLElement
}
}
}
return null
}
const getScrollableRef = (): { current: HTMLElement } | null => {
const element = getScrollableElement()
if (!element) {
window.toast.warning(i18n.t('notes.no_content_to_copy'))
return null
}
return { current: element }
}
const exportNoteAsImageToClipboard = async (): Promise<void> => {
const scrollableRef = getScrollableRef()
if (!scrollableRef) return
await captureScrollableAsBlob(scrollableRef, async (blob) => {
if (blob) {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
window.toast.success(i18n.t('common.copied'))
}
})
}
const exportNoteAsImageFile = async (noteName: string): Promise<void> => {
const scrollableRef = getScrollableRef()
if (!scrollableRef) return
const dataUrl = await captureScrollableAsDataURL(scrollableRef)
if (dataUrl) {
const fileName = removeSpecialCharactersForFileName(noteName)
await window.api.file.saveImage(fileName, dataUrl)
}
}
interface NoteExportOptions {
node: { name: string; externalPath: string }
platform: 'markdown' | 'docx' | 'notion' | 'yuque' | 'obsidian' | 'joplin' | 'siyuan' | 'copyImage' | 'exportImage'
}
export const exportNote = async ({ node, platform }: NoteExportOptions): Promise<void> => {
try {
const content = await window.api.file.readExternal(node.externalPath)
switch (platform) {
case 'copyImage':
return await exportNoteAsImageToClipboard()
case 'exportImage':
return await exportNoteAsImageFile(node.name)
case 'markdown':
return await exportNoteAsMarkdown(node.name, content)
case 'docx':
window.api.export.toWord(`# ${node.name}\n\n${content}`, removeSpecialCharactersForFileName(node.name))
return
case 'notion':
await exportMessageToNotion(node.name, content)
return
case 'yuque':
await exportMarkdownToYuque(node.name, `# ${node.name}\n\n${content}`)
return
case 'obsidian': {
const { default: ObsidianExportPopup } = await import('@renderer/components/Popups/ObsidianExportPopup')
await ObsidianExportPopup.show({ title: node.name, processingMethod: '1', rawContent: content })
return
}
case 'joplin':
await exportMarkdownToJoplin(node.name, content)
return
case 'siyuan':
await exportMarkdownToSiyuan(node.name, `# ${node.name}\n\n${content}`)
return
}
} catch (error) {
logger.error(`Failed to export note to ${platform}:`, error as Error)
throw error
}
}

View File

@@ -0,0 +1,77 @@
/**
* 搜索结果适配器
* 将不同 Provider 的搜索结果统一转换为 Citation 格式
*/
import type { Citation, WebSearchProviderResult } from '@renderer/types'
/**
* 将 WebSearchProviderResult 转换为 Citation
* 自动识别并处理不同 Provider 的额外字段
*
* @param result - 搜索结果(可能包含 Provider 特定字段)
* @param index - 结果序号从0开始
* @returns Citation 对象
*/
export function adaptSearchResultToCitation(result: WebSearchProviderResult, index: number): Citation {
// 基础字段(所有 Provider 都有)
const citation: Citation = {
number: index + 1,
url: result.url,
title: result.title,
content: result.content,
showFavicon: true,
type: 'websearch'
}
// Exa Provider 特定字段
if ('favicon' in result && result.favicon) {
citation.favicon = result.favicon
}
// 收集元数据
const metadata: Record<string, any> = {}
// Exa 元数据
if ('publishedDate' in result && result.publishedDate) {
metadata.publishedDate = result.publishedDate
}
if ('author' in result && result.author) {
metadata.author = result.author
}
if ('score' in result && result.score !== undefined) {
metadata.score = result.score
}
if ('highlights' in result && result.highlights && result.highlights.length > 0) {
metadata.highlights = result.highlights
}
// Tavily 元数据
if ('answer' in result && result.answer) {
metadata.answer = result.answer
}
if ('images' in result && result.images && result.images.length > 0) {
metadata.images = result.images
}
// 只在有元数据时添加
if (Object.keys(metadata).length > 0) {
citation.metadata = metadata
}
return citation
}
/**
* 批量转换搜索结果为 Citations
*
* @param results - 搜索结果数组
* @returns Citation 数组
*/
export function adaptSearchResultsToCitations(results: WebSearchProviderResult[]): Citation[] {
return results.map((result, index) => adaptSearchResultToCitation(result, index))
}

257
yarn.lock
View File

@@ -74,169 +74,157 @@ __metadata:
languageName: node
linkType: hard
"@ai-sdk/amazon-bedrock@npm:^3.0.21":
version: 3.0.21
resolution: "@ai-sdk/amazon-bedrock@npm:3.0.21"
"@ai-sdk/amazon-bedrock@npm:^3.0.29":
version: 3.0.29
resolution: "@ai-sdk/amazon-bedrock@npm:3.0.29"
dependencies:
"@ai-sdk/anthropic": "npm:2.0.17"
"@ai-sdk/anthropic": "npm:2.0.22"
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.9"
"@ai-sdk/provider-utils": "npm:3.0.10"
"@smithy/eventstream-codec": "npm:^4.0.1"
"@smithy/util-utf8": "npm:^4.0.0"
aws4fetch: "npm:^1.0.20"
peerDependencies:
zod: ^3.25.76 || ^4
checksum: 10c0/2d15baaad53e389666cede9673e2b43f5299e2cedb70f5b7afc656b7616e73775a9108c2cc1beee4644ff4c66ad41c8dd0b412373dd05caa4fc3d477c4343ea8
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/7add02e6c13774943929bb5d568b3110f6badc6d95cb56c6d3011cafc45778e27c0133417dd7fe835e7f0b1ae7767c22a7d5e3d39f725e2aa44e2b6e47d95fb7
languageName: node
linkType: hard
"@ai-sdk/anthropic@npm:2.0.17, @ai-sdk/anthropic@npm:^2.0.17":
version: 2.0.17
resolution: "@ai-sdk/anthropic@npm:2.0.17"
"@ai-sdk/anthropic@npm:2.0.22, @ai-sdk/anthropic@npm:^2.0.22":
version: 2.0.22
resolution: "@ai-sdk/anthropic@npm:2.0.22"
dependencies:
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.9"
"@ai-sdk/provider-utils": "npm:3.0.10"
peerDependencies:
zod: ^3.25.76 || ^4
checksum: 10c0/783b6a953f3854c4303ad7c30dd56d4706486c7d1151adb17071d87933418c59c26bce53d5c26d34c4d4728eaac4a856ce49a336caed26a7216f982fea562814
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/d922d2ff606b2429fb14c099628ba6734ef7c9b0e9225635f3faaf2d067362dea6ae0e920a35c05ccf15a01c59fef93ead5f147a9609dd3dd8c3ac18a3123b85
languageName: node
linkType: hard
"@ai-sdk/azure@npm:^2.0.30":
version: 2.0.30
resolution: "@ai-sdk/azure@npm:2.0.30"
"@ai-sdk/azure@npm:^2.0.42":
version: 2.0.42
resolution: "@ai-sdk/azure@npm:2.0.42"
dependencies:
"@ai-sdk/openai": "npm:2.0.30"
"@ai-sdk/openai": "npm:2.0.42"
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.9"
"@ai-sdk/provider-utils": "npm:3.0.10"
peerDependencies:
zod: ^3.25.76 || ^4
checksum: 10c0/22af450e28026547badc891a627bcb3cfa2d030864089947172506810f06cfa4c74c453aabd6a0d5c05ede5ffdee381b9278772ce781eca0c7c826c7d7ae3dc3
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/14d3d6edac691df57879a9a7efc46d5d00b6bde5b64cd62a67a7668455c341171119ae90a431e57ac37009bced19add50b3da26998376b7e56e080bc2c997c00
languageName: node
linkType: hard
"@ai-sdk/deepseek@npm:^1.0.17":
version: 1.0.17
resolution: "@ai-sdk/deepseek@npm:1.0.17"
"@ai-sdk/deepseek@npm:^1.0.20":
version: 1.0.20
resolution: "@ai-sdk/deepseek@npm:1.0.20"
dependencies:
"@ai-sdk/openai-compatible": "npm:1.0.17"
"@ai-sdk/openai-compatible": "npm:1.0.19"
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.9"
"@ai-sdk/provider-utils": "npm:3.0.10"
peerDependencies:
zod: ^3.25.76 || ^4
checksum: 10c0/c408701343bb28ed0b3e034b8789e6de1dfd6cfc6a9b53feb68f155889e29a9fbbcf05bd99e63f60809cf05ee4b158abaccdf1cbcd9df92c0987094220a61d08
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/e66ece8cf6371c2bac5436ed82cd1e2bb5c367fae6df60090f91cff62bf241f4df0abded99c33558013f8dc0bcc7d962f2126086eba8587ba929da50afd3d806
languageName: node
linkType: hard
"@ai-sdk/gateway@npm:1.0.23":
version: 1.0.23
resolution: "@ai-sdk/gateway@npm:1.0.23"
"@ai-sdk/gateway@npm:1.0.32":
version: 1.0.32
resolution: "@ai-sdk/gateway@npm:1.0.32"
dependencies:
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.9"
"@ai-sdk/provider-utils": "npm:3.0.10"
peerDependencies:
zod: ^3.25.76 || ^4
checksum: 10c0/b1e1a6ab63b9191075eed92c586cd927696f8997ad24f056585aee3f5fffd283d981aa6b071a2560ecda4295445b80a4cfd321fa63c06e7ac54a06bc4c84887f
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/82c98db6e4e8e235e1ff66410318ebe77cc1518ebf06d8d4757b4f30aaa3bf7075d3028816438551fef2f89e2d4c8c26e4efcd9913a06717aee1308dad3ddc30
languageName: node
linkType: hard
"@ai-sdk/google-vertex@npm:^3.0.27":
version: 3.0.27
resolution: "@ai-sdk/google-vertex@npm:3.0.27"
"@ai-sdk/google-vertex@npm:^3.0.33":
version: 3.0.33
resolution: "@ai-sdk/google-vertex@npm:3.0.33"
dependencies:
"@ai-sdk/anthropic": "npm:2.0.17"
"@ai-sdk/google": "npm:2.0.14"
"@ai-sdk/anthropic": "npm:2.0.22"
"@ai-sdk/google": "npm:2.0.17"
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.9"
"@ai-sdk/provider-utils": "npm:3.0.10"
google-auth-library: "npm:^9.15.0"
peerDependencies:
zod: ^3.25.76 || ^4
checksum: 10c0/7017838aef9c04c18ce9acec52eb602ee0a38d68a7496977a3898411f1ac235b2d7776011fa686084b90b0881e65c69596014e5465b8ed0d0e313b5db1f967a7
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/d440e46f702385985a34f2260074eb41cf2516036598039c8c72d6155825114452942c3c012a181da7661341bee9a38958e5f9a53bba145b9c5dc4446411a651
languageName: node
linkType: hard
"@ai-sdk/google@npm:2.0.14":
version: 2.0.14
resolution: "@ai-sdk/google@npm:2.0.14"
"@ai-sdk/google@npm:2.0.17":
version: 2.0.17
resolution: "@ai-sdk/google@npm:2.0.17"
dependencies:
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.9"
"@ai-sdk/provider-utils": "npm:3.0.10"
peerDependencies:
zod: ^3.25.76 || ^4
checksum: 10c0/2c04839cf58c33514a54c9de8190c363b5cacfbfc8404fea5d2ec36ad0af5ced4fc571f978e7aa35876bd9afae138f4c700d2bc1f64a78a37d0401f6797bf8f3
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/174bcde507e5bf4bf95f20dbe4eaba73870715b13779e320f3df44995606e4d7ccd1e1f4b759d224deaf58bdfc6aa2e43a24dcbe5fa335ddfe91df1b06114218
languageName: node
linkType: hard
"@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch":
version: 2.0.14
resolution: "@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch::version=2.0.14&hash=351f1a"
"@ai-sdk/mistral@npm:^2.0.17":
version: 2.0.17
resolution: "@ai-sdk/mistral@npm:2.0.17"
dependencies:
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.9"
"@ai-sdk/provider-utils": "npm:3.0.10"
peerDependencies:
zod: ^3.25.76 || ^4
checksum: 10c0/1ed5a0732a82b981d51f63c6241ed8ee94d5c29a842764db770305cfc2f49ab6e528cac438b5357fc7b02194104c7b76d4390a1dc1d019ace9c174b0849e0da6
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/58a129357c93cc7f2b15b2ba6ccfb9df3fb72e06163641602ea41c858f835cd76985d66665a56e4ed3fa1eb19ca75a83ae12986d466ec41942e9bf13d558c441
languageName: node
linkType: hard
"@ai-sdk/mistral@npm:^2.0.14":
version: 2.0.14
resolution: "@ai-sdk/mistral@npm:2.0.14"
"@ai-sdk/openai-compatible@npm:1.0.19, @ai-sdk/openai-compatible@npm:^1.0.19":
version: 1.0.19
resolution: "@ai-sdk/openai-compatible@npm:1.0.19"
dependencies:
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.9"
"@ai-sdk/provider-utils": "npm:3.0.10"
peerDependencies:
zod: ^3.25.76 || ^4
checksum: 10c0/420be3a039095830aaf59b6f82c1f986ff4800ba5b9438e1dd85530026a42c9454a6e632b6a1a1839816609f4752d0a19140d8943ad78bb976fb5d6a37714e16
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/5b7b21fb515e829c3d8a499a5760ffc035d9b8220695996110e361bd79e9928859da4ecf1ea072735bcbe4977c6dd0661f543871921692e86f8b5bfef14fe0e5
languageName: node
linkType: hard
"@ai-sdk/openai-compatible@npm:1.0.17, @ai-sdk/openai-compatible@npm:^1.0.17":
version: 1.0.17
resolution: "@ai-sdk/openai-compatible@npm:1.0.17"
"@ai-sdk/openai@npm:2.0.42, @ai-sdk/openai@npm:^2.0.42":
version: 2.0.42
resolution: "@ai-sdk/openai@npm:2.0.42"
dependencies:
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.9"
"@ai-sdk/provider-utils": "npm:3.0.10"
peerDependencies:
zod: ^3.25.76 || ^4
checksum: 10c0/53ab6111e0f44437a2e268a51fb747600844d85b0cd0d170fb87a7b68af3eb21d7728d7bbf14d71c9fcf36e7a0f94ad75f0ad6b1070e473c867ab08ef84f6564
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/b1ab158aafc86735e53c4621ffe125d469bc1732c533193652768a9f66ecd4d169303ce7ca59069b7baf725da49e55bcf81210848f09f66deaf2a8335399e6d7
languageName: node
linkType: hard
"@ai-sdk/openai@npm:2.0.30, @ai-sdk/openai@npm:^2.0.30":
version: 2.0.30
resolution: "@ai-sdk/openai@npm:2.0.30"
"@ai-sdk/perplexity@npm:^2.0.11":
version: 2.0.11
resolution: "@ai-sdk/perplexity@npm:2.0.11"
dependencies:
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.9"
"@ai-sdk/provider-utils": "npm:3.0.10"
peerDependencies:
zod: ^3.25.76 || ^4
checksum: 10c0/90a57c1b10dac46c0bbe7e16cf9202557fb250d9f0e94a2a5fb7d95b5ea77815a56add78b00238d3823f0313c9b2c42abe865478d28a6196f72b341d32dd40af
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/a8722b68f529b3d1baaa1ba4624c61efe732f22b24dfc20e27afae07bb25d72532bcb62d022191ab5e49df24496af619eabc092a4e6ad293b3fe231ef61b6467
languageName: node
linkType: hard
"@ai-sdk/perplexity@npm:^2.0.9":
version: 2.0.9
resolution: "@ai-sdk/perplexity@npm:2.0.9"
dependencies:
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.9"
peerDependencies:
zod: ^3.25.76 || ^4
checksum: 10c0/2023aadc26c41430571c4897df79074e7a95a12f2238ad57081355484066bcf9e8dfde1da60fa6af12fc9fb2a195899326f753c69f4913dc005a33367f150349
languageName: node
linkType: hard
"@ai-sdk/provider-utils@npm:3.0.9, @ai-sdk/provider-utils@npm:^3.0.9":
version: 3.0.9
resolution: "@ai-sdk/provider-utils@npm:3.0.9"
"@ai-sdk/provider-utils@npm:3.0.10, @ai-sdk/provider-utils@npm:^3.0.10":
version: 3.0.10
resolution: "@ai-sdk/provider-utils@npm:3.0.10"
dependencies:
"@ai-sdk/provider": "npm:2.0.0"
"@standard-schema/spec": "npm:^1.0.0"
eventsource-parser: "npm:^3.0.5"
peerDependencies:
zod: ^3.25.76 || ^4
checksum: 10c0/f8b659343d7e22ae099f7b6fc514591c0408012eb0aa00f7a912798b6d7d7305cafa8f18a07c7adec0bb5d39d9b6256b76d65c5393c3fc843d1361c52f1f8080
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/d2c16abdb84ba4ef48c9f56190b5ffde224b9e6ae5147c5c713d2623627732d34b96aa9aef2a2ea4b0c49e1b863cc963c7d7ff964a1dc95f0f036097aaaaaa98
languageName: node
linkType: hard
@@ -249,16 +237,16 @@ __metadata:
languageName: node
linkType: hard
"@ai-sdk/xai@npm:^2.0.18":
version: 2.0.18
resolution: "@ai-sdk/xai@npm:2.0.18"
"@ai-sdk/xai@npm:^2.0.23":
version: 2.0.23
resolution: "@ai-sdk/xai@npm:2.0.23"
dependencies:
"@ai-sdk/openai-compatible": "npm:1.0.17"
"@ai-sdk/openai-compatible": "npm:1.0.19"
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.9"
"@ai-sdk/provider-utils": "npm:3.0.10"
peerDependencies:
zod: ^3.25.76 || ^4
checksum: 10c0/7134501a2d315ec13605558aa24d7f5662885fe8b0491a634abefeb0c5c88517149677d1beff0c8abeec78a6dcd14573a2f57d96fa54a1d63d03820ac7ff827a
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/4cf6b3bc71024797d1b2e37b57fb746f7387f9a7c1da530fd040aad1a840603a1a86fb7df7e428c723eba9b1547f89063d68f84e6e08444d2d4f152dee321dc3
languageName: node
linkType: hard
@@ -397,7 +385,7 @@ __metadata:
languageName: node
linkType: hard
"@anthropic-ai/claude-agent-sdk@npm:^0.1.1":
"@anthropic-ai/claude-agent-sdk@npm:0.1.1":
version: 0.1.1
resolution: "@anthropic-ai/claude-agent-sdk@npm:0.1.1"
dependencies:
@@ -424,6 +412,33 @@ __metadata:
languageName: node
linkType: hard
"@anthropic-ai/claude-agent-sdk@patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.1#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch":
version: 0.1.1
resolution: "@anthropic-ai/claude-agent-sdk@patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.1#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch::version=0.1.1&hash=f97b6e"
dependencies:
"@img/sharp-darwin-arm64": "npm:^0.33.5"
"@img/sharp-darwin-x64": "npm:^0.33.5"
"@img/sharp-linux-arm": "npm:^0.33.5"
"@img/sharp-linux-arm64": "npm:^0.33.5"
"@img/sharp-linux-x64": "npm:^0.33.5"
"@img/sharp-win32-x64": "npm:^0.33.5"
dependenciesMeta:
"@img/sharp-darwin-arm64":
optional: true
"@img/sharp-darwin-x64":
optional: true
"@img/sharp-linux-arm":
optional: true
"@img/sharp-linux-arm64":
optional: true
"@img/sharp-linux-x64":
optional: true
"@img/sharp-win32-x64":
optional: true
checksum: 10c0/4312b2cb008a332f52d63b1b005d16482c9cbdb3377729422287506c12e9003e0b376e8b8ef3d127908238c36f799608eda85d9b760a96cd836b3a5f7752104f
languageName: node
linkType: hard
"@anthropic-ai/sdk@npm:>=0.50.3 <1":
version: 0.56.0
resolution: "@anthropic-ai/sdk@npm:0.56.0"
@@ -2352,14 +2367,14 @@ __metadata:
version: 0.0.0-use.local
resolution: "@cherrystudio/ai-core@workspace:packages/aiCore"
dependencies:
"@ai-sdk/anthropic": "npm:^2.0.17"
"@ai-sdk/azure": "npm:^2.0.30"
"@ai-sdk/deepseek": "npm:^1.0.17"
"@ai-sdk/openai": "npm:^2.0.30"
"@ai-sdk/openai-compatible": "npm:^1.0.17"
"@ai-sdk/anthropic": "npm:^2.0.22"
"@ai-sdk/azure": "npm:^2.0.42"
"@ai-sdk/deepseek": "npm:^1.0.20"
"@ai-sdk/openai": "npm:^2.0.42"
"@ai-sdk/openai-compatible": "npm:^1.0.19"
"@ai-sdk/provider": "npm:^2.0.0"
"@ai-sdk/provider-utils": "npm:^3.0.9"
"@ai-sdk/xai": "npm:^2.0.18"
"@ai-sdk/provider-utils": "npm:^3.0.10"
"@ai-sdk/xai": "npm:^2.0.23"
tsdown: "npm:^0.12.9"
typescript: "npm:^5.0.0"
vitest: "npm:^3.2.4"
@@ -14166,12 +14181,12 @@ __metadata:
"@agentic/exa": "npm:^7.3.3"
"@agentic/searxng": "npm:^7.3.3"
"@agentic/tavily": "npm:^7.3.3"
"@ai-sdk/amazon-bedrock": "npm:^3.0.21"
"@ai-sdk/google-vertex": "npm:^3.0.27"
"@ai-sdk/mistral": "npm:^2.0.14"
"@ai-sdk/perplexity": "npm:^2.0.9"
"@ai-sdk/amazon-bedrock": "npm:^3.0.29"
"@ai-sdk/google-vertex": "npm:^3.0.33"
"@ai-sdk/mistral": "npm:^2.0.17"
"@ai-sdk/perplexity": "npm:^2.0.11"
"@ant-design/v5-patch-for-react-19": "npm:^1.0.3"
"@anthropic-ai/claude-agent-sdk": "npm:^0.1.1"
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.1#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch"
"@anthropic-ai/sdk": "npm:^0.41.0"
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch"
"@aws-sdk/client-bedrock": "npm:^3.840.0"
@@ -14291,7 +14306,7 @@ __metadata:
"@viz-js/lang-dot": "npm:^1.0.5"
"@viz-js/viz": "npm:^3.14.0"
"@xyflow/react": "npm:^12.4.4"
ai: "npm:^5.0.44"
ai: "npm:^5.0.59"
antd: "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch"
archiver: "npm:^7.0.1"
async-mutex: "npm:^0.5.0"
@@ -14316,7 +14331,7 @@ __metadata:
dotenv-cli: "npm:^7.4.2"
drizzle-kit: "npm:^0.31.4"
drizzle-orm: "npm:^0.44.5"
electron: "npm:37.4.0"
electron: "npm:37.6.0"
electron-builder: "npm:26.0.15"
electron-devtools-installer: "npm:^3.2.0"
electron-reload: "npm:^2.0.0-alpha.1"
@@ -14554,17 +14569,17 @@ __metadata:
languageName: node
linkType: hard
"ai@npm:^5.0.44":
version: 5.0.44
resolution: "ai@npm:5.0.44"
"ai@npm:^5.0.59":
version: 5.0.59
resolution: "ai@npm:5.0.59"
dependencies:
"@ai-sdk/gateway": "npm:1.0.23"
"@ai-sdk/gateway": "npm:1.0.32"
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.9"
"@ai-sdk/provider-utils": "npm:3.0.10"
"@opentelemetry/api": "npm:1.9.0"
peerDependencies:
zod: ^3.25.76 || ^4
checksum: 10c0/528c7e165f75715194204051ce0aa341d8dca7d5536c2abcf3df83ccda7399ed5d91deaa45a81340f93d2461b1c2fc5f740f7804dfd396927c71b0667403569b
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/daa956e753b93fbc30afbfba5be2ebb73e3c280dae3064e13949f04d5a22c0f4ea5698cc87e24a23ed6585d9cf7febee61b915292dbbd4286dc40c449cf2b845
languageName: node
linkType: hard
@@ -17963,16 +17978,16 @@ __metadata:
languageName: node
linkType: hard
"electron@npm:37.4.0":
version: 37.4.0
resolution: "electron@npm:37.4.0"
"electron@npm:37.6.0":
version: 37.6.0
resolution: "electron@npm:37.6.0"
dependencies:
"@electron/get": "npm:^2.0.0"
"@types/node": "npm:^22.7.7"
extract-zip: "npm:^2.0.1"
bin:
electron: cli.js
checksum: 10c0/92a0c41190e234d302bc612af6cce9af08cd07f6699c1ff21a9365297e73dc9d88c6c4c25ddabf352447e3e555878d2ab0f2f31a14e210dda6de74d2787ff323
checksum: 10c0/d67b7f0ff902f9184c2a7445507746343f8b39f3616d9d26128e7515e0184252cfc8ac97a3f1458f9ea9b4af6ab5b3208282014e8d91c0e1505ff21f5fa57ce6
languageName: node
linkType: hard