Merge remote-tracking branch 'origin/main' into feat/access-key

This commit is contained in:
suyao 2025-12-12 13:15:42 +08:00
commit e21c6ce2fc
No known key found for this signature in database
59 changed files with 1672 additions and 539 deletions

293
.github/workflows/sync-to-gitcode.yml vendored Normal file
View File

@ -0,0 +1,293 @@
name: Sync Release to GitCode
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. v1.0.0)'
required: true
clean:
description: 'Clean node_modules before build'
type: boolean
default: false
permissions:
contents: read
jobs:
build-and-sync-to-gitcode:
runs-on: [self-hosted, windows-signing]
steps:
- name: Get tag name
id: get-tag
shell: bash
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
fi
- name: Check out Git repository
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ steps.get-tag.outputs.tag }}
- name: Set package.json version
shell: bash
run: |
TAG="${{ steps.get-tag.outputs.tag }}"
VERSION="${TAG#v}"
npm version "$VERSION" --no-git-tag-version --allow-same-version
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- name: Install corepack
shell: bash
run: corepack enable && corepack prepare yarn@4.9.1 --activate
- name: Clean node_modules
if: ${{ github.event.inputs.clean == 'true' }}
shell: bash
run: rm -rf node_modules
- name: Install Dependencies
shell: bash
run: yarn install
- name: Build Windows with code signing
shell: bash
run: yarn build:win
env:
WIN_SIGN: true
CHERRY_CERT_PATH: ${{ secrets.CHERRY_CERT_PATH }}
CHERRY_CERT_KEY: ${{ secrets.CHERRY_CERT_KEY }}
CHERRY_CERT_CSP: ${{ secrets.CHERRY_CERT_CSP }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
- name: List built Windows artifacts
shell: bash
run: |
echo "Built Windows artifacts:"
ls -la dist/*.exe dist/*.blockmap dist/latest*.yml
- name: Download GitHub release assets
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ steps.get-tag.outputs.tag }}
run: |
echo "Downloading release assets for $TAG_NAME..."
mkdir -p release-assets
cd release-assets
# Download all assets from the release
gh release download "$TAG_NAME" \
--repo "${{ github.repository }}" \
--pattern "*" \
--skip-existing
echo "Downloaded GitHub release assets:"
ls -la
- name: Replace Windows files with signed versions
shell: bash
run: |
echo "Replacing Windows files with signed versions..."
# Verify signed files exist first
if ! ls dist/*.exe 1>/dev/null 2>&1; then
echo "ERROR: No signed .exe files found in dist/"
exit 1
fi
# Remove unsigned Windows files from downloaded assets
# *.exe, *.exe.blockmap, latest.yml (Windows only)
rm -f release-assets/*.exe release-assets/*.exe.blockmap release-assets/latest.yml 2>/dev/null || true
# Copy signed Windows files with error checking
cp dist/*.exe release-assets/ || { echo "ERROR: Failed to copy .exe files"; exit 1; }
cp dist/*.exe.blockmap release-assets/ || { echo "ERROR: Failed to copy .blockmap files"; exit 1; }
cp dist/latest.yml release-assets/ || { echo "ERROR: Failed to copy latest.yml"; exit 1; }
echo "Final release assets:"
ls -la release-assets/
- name: Get release info
id: release-info
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ steps.get-tag.outputs.tag }}
LANG: C.UTF-8
LC_ALL: C.UTF-8
run: |
# Always use gh cli to avoid special character issues
RELEASE_NAME=$(gh release view "$TAG_NAME" --repo "${{ github.repository }}" --json name -q '.name')
# Use delimiter to safely handle special characters in release name
{
echo 'name<<EOF'
echo "$RELEASE_NAME"
echo 'EOF'
} >> $GITHUB_OUTPUT
# Extract releaseNotes from electron-builder.yml (from releaseNotes: | to end of file, remove 4-space indent)
sed -n '/releaseNotes: |/,$ { /releaseNotes: |/d; s/^ //; p }' electron-builder.yml > release_body.txt
- name: Create GitCode release and upload files
shell: bash
env:
GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }}
GITCODE_OWNER: ${{ vars.GITCODE_OWNER }}
GITCODE_REPO: ${{ vars.GITCODE_REPO }}
GITCODE_API_URL: ${{ vars.GITCODE_API_URL }}
TAG_NAME: ${{ steps.get-tag.outputs.tag }}
RELEASE_NAME: ${{ steps.release-info.outputs.name }}
LANG: C.UTF-8
LC_ALL: C.UTF-8
run: |
# Validate required environment variables
if [ -z "$GITCODE_TOKEN" ]; then
echo "ERROR: GITCODE_TOKEN is not set"
exit 1
fi
if [ -z "$GITCODE_OWNER" ]; then
echo "ERROR: GITCODE_OWNER is not set"
exit 1
fi
if [ -z "$GITCODE_REPO" ]; then
echo "ERROR: GITCODE_REPO is not set"
exit 1
fi
API_URL="${GITCODE_API_URL:-https://api.gitcode.com/api/v5}"
echo "Creating GitCode release..."
echo "Tag: $TAG_NAME"
echo "Repo: $GITCODE_OWNER/$GITCODE_REPO"
# Step 1: Create release
# Use --rawfile to read body directly from file, avoiding shell variable encoding issues
jq -n \
--arg tag "$TAG_NAME" \
--arg name "$RELEASE_NAME" \
--rawfile body release_body.txt \
'{
tag_name: $tag,
name: $name,
body: $body,
target_commitish: "main"
}' > /tmp/release_payload.json
RELEASE_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
--connect-timeout 30 --max-time 60 \
"${API_URL}/repos/${GITCODE_OWNER}/${GITCODE_REPO}/releases" \
-H "Content-Type: application/json; charset=utf-8" \
-H "Authorization: Bearer ${GITCODE_TOKEN}" \
--data-binary "@/tmp/release_payload.json")
HTTP_CODE=$(echo "$RELEASE_RESPONSE" | tail -n1)
RESPONSE_BODY=$(echo "$RELEASE_RESPONSE" | sed '$d')
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
echo "Release created successfully"
else
echo "Warning: Release creation returned HTTP $HTTP_CODE"
echo "$RESPONSE_BODY"
exit 1
fi
# Step 2: Upload files to release
echo "Uploading files to GitCode release..."
# Function to upload a single file with retry
upload_file() {
local file="$1"
local filename=$(basename "$file")
local max_retries=3
local retry=0
echo "Uploading: $filename"
# URL encode the filename
encoded_filename=$(printf '%s' "$filename" | jq -sRr @uri)
while [ $retry -lt $max_retries ]; do
# Get upload URL
UPLOAD_INFO=$(curl -s --connect-timeout 30 --max-time 60 \
-H "Authorization: Bearer ${GITCODE_TOKEN}" \
"${API_URL}/repos/${GITCODE_OWNER}/${GITCODE_REPO}/releases/${TAG_NAME}/upload_url?file_name=${encoded_filename}")
UPLOAD_URL=$(echo "$UPLOAD_INFO" | jq -r '.url // empty')
if [ -n "$UPLOAD_URL" ]; then
# Write headers to temp file to avoid shell escaping issues
echo "$UPLOAD_INFO" | jq -r '.headers | to_entries[] | "header = \"" + .key + ": " + .value + "\""' > /tmp/upload_headers.txt
# Upload file using PUT with headers from file
UPLOAD_RESPONSE=$(curl -s -w "\n%{http_code}" -X PUT \
-K /tmp/upload_headers.txt \
--data-binary "@${file}" \
"$UPLOAD_URL")
HTTP_CODE=$(echo "$UPLOAD_RESPONSE" | tail -n1)
RESPONSE_BODY=$(echo "$UPLOAD_RESPONSE" | sed '$d')
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
echo " Uploaded: $filename"
return 0
else
echo " Failed (HTTP $HTTP_CODE), retry $((retry + 1))/$max_retries"
echo " Response: $RESPONSE_BODY"
fi
else
echo " Failed to get upload URL, retry $((retry + 1))/$max_retries"
echo " Response: $UPLOAD_INFO"
fi
retry=$((retry + 1))
[ $retry -lt $max_retries ] && sleep 3
done
echo " Failed: $filename after $max_retries retries"
exit 1
}
# Upload non-yml/json files first
for file in release-assets/*; do
if [ -f "$file" ]; then
filename=$(basename "$file")
if [[ ! "$filename" =~ \.(yml|yaml|json)$ ]]; then
upload_file "$file"
fi
fi
done
# Upload yml/json files last
for file in release-assets/*; do
if [ -f "$file" ]; then
filename=$(basename "$file")
if [[ "$filename" =~ \.(yml|yaml|json)$ ]]; then
upload_file "$file"
fi
fi
done
echo "GitCode release sync completed!"
- name: Cleanup temp files
if: always()
shell: bash
run: |
rm -f /tmp/release_payload.json /tmp/upload_headers.txt release_body.txt
rm -rf release-assets/

View File

@ -50,7 +50,7 @@
"generate:icons": "electron-icon-builder --input=./build/logo.png --output=build",
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
"analyze:main": "VISUALIZER_MAIN=true yarn build",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"",
"typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false",
"check:i18n": "dotenv -e .env -- tsx scripts/check-i18n.ts",
@ -257,6 +257,7 @@
"clsx": "^2.1.1",
"code-inspector-plugin": "^0.20.14",
"color": "^5.0.0",
"concurrently": "^9.2.1",
"country-flag-emoji-polyfill": "0.1.8",
"dayjs": "^1.11.11",
"dexie": "^4.0.8",

View File

@ -6,6 +6,7 @@ import { type Tool } from 'ai'
import { createOpenRouterOptions, createXaiOptions, mergeProviderOptions } from '../../../options'
import type { ProviderOptionsMap } from '../../../options/types'
import type { AiRequestContext } from '../../'
import type { OpenRouterSearchConfig } from './openrouter'
/**
@ -95,28 +96,84 @@ export type WebSearchToolInputSchema = {
'openai-chat': InferToolInput<OpenAIChatWebSearchTool>
}
export const switchWebSearchTool = (config: WebSearchPluginConfig, params: any) => {
if (config.openai) {
if (!params.tools) params.tools = {}
params.tools.web_search = openai.tools.webSearch(config.openai)
} else if (config['openai-chat']) {
if (!params.tools) params.tools = {}
params.tools.web_search_preview = openai.tools.webSearchPreview(config['openai-chat'])
} else if (config.anthropic) {
if (!params.tools) params.tools = {}
params.tools.web_search = anthropic.tools.webSearch_20250305(config.anthropic)
} else if (config.google) {
// case 'google-vertex':
if (!params.tools) params.tools = {}
params.tools.web_search = google.tools.googleSearch(config.google || {})
} else if (config.xai) {
const searchOptions = createXaiOptions({
searchParameters: { ...config.xai, mode: 'on' }
})
params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions)
} else if (config.openrouter) {
const searchOptions = createOpenRouterOptions(config.openrouter)
params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions)
/**
* Helper function to ensure params.tools object exists
*/
const ensureToolsObject = (params: any) => {
if (!params.tools) params.tools = {}
}
/**
* Helper function to apply tool-based web search configuration
*/
const applyToolBasedSearch = (params: any, toolName: string, toolInstance: any) => {
ensureToolsObject(params)
params.tools[toolName] = toolInstance
}
/**
* Helper function to apply provider options-based web search configuration
*/
const applyProviderOptionsSearch = (params: any, searchOptions: any) => {
params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions)
}
export const switchWebSearchTool = (config: WebSearchPluginConfig, params: any, context?: AiRequestContext) => {
const providerId = context?.providerId
// Provider-specific configuration map
const providerHandlers: Record<string, () => void> = {
openai: () => {
const cfg = config.openai ?? DEFAULT_WEB_SEARCH_CONFIG.openai
applyToolBasedSearch(params, 'web_search', openai.tools.webSearch(cfg))
},
'openai-chat': () => {
const cfg = (config['openai-chat'] ?? DEFAULT_WEB_SEARCH_CONFIG['openai-chat']) as OpenAISearchPreviewConfig
applyToolBasedSearch(params, 'web_search_preview', openai.tools.webSearchPreview(cfg))
},
anthropic: () => {
const cfg = config.anthropic ?? DEFAULT_WEB_SEARCH_CONFIG.anthropic
applyToolBasedSearch(params, 'web_search', anthropic.tools.webSearch_20250305(cfg))
},
google: () => {
const cfg = (config.google ?? DEFAULT_WEB_SEARCH_CONFIG.google) as GoogleSearchConfig
applyToolBasedSearch(params, 'web_search', google.tools.googleSearch(cfg))
},
xai: () => {
const cfg = config.xai ?? DEFAULT_WEB_SEARCH_CONFIG.xai
const searchOptions = createXaiOptions({ searchParameters: { ...cfg, mode: 'on' } })
applyProviderOptionsSearch(params, searchOptions)
},
openrouter: () => {
const cfg = (config.openrouter ?? DEFAULT_WEB_SEARCH_CONFIG.openrouter) as OpenRouterSearchConfig
const searchOptions = createOpenRouterOptions(cfg)
applyProviderOptionsSearch(params, searchOptions)
}
}
// Try provider-specific handler first
const handler = providerId && providerHandlers[providerId]
if (handler) {
handler()
return params
}
// Fallback: apply based on available config keys (prioritized order)
const fallbackOrder: Array<keyof WebSearchPluginConfig> = [
'openai',
'openai-chat',
'anthropic',
'google',
'xai',
'openrouter'
]
for (const key of fallbackOrder) {
if (config[key]) {
providerHandlers[key]()
break
}
}
return params
}

View File

@ -17,8 +17,22 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR
name: 'webSearch',
enforce: 'pre',
transformParams: async (params: any) => {
switchWebSearchTool(config, params)
transformParams: async (params: any, context) => {
let { providerId } = context
// For cherryin providers, extract the actual provider from the model's provider string
// Expected format: "cherryin.{actualProvider}" (e.g., "cherryin.gemini")
if (providerId === 'cherryin' || providerId === 'cherryin-chat') {
const provider = params.model?.provider
if (provider && typeof provider === 'string' && provider.includes('.')) {
const extractedProviderId = provider.split('.')[1]
if (extractedProviderId) {
providerId = extractedProviderId
}
}
}
switchWebSearchTool(config, params, { ...context, providerId })
return params
}
})

View File

@ -55,6 +55,8 @@ export enum IpcChannel {
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
Webview_SearchHotkey = 'webview:search-hotkey',
Webview_PrintToPDF = 'webview:print-to-pdf',
Webview_SaveAsHTML = 'webview:save-as-html',
// Open
Open_Path = 'open:path',
@ -241,6 +243,8 @@ export enum IpcChannel {
System_GetHostname = 'system:getHostname',
System_GetCpuName = 'system:getCpuName',
System_CheckGitBash = 'system:checkGitBash',
System_GetGitBashPath = 'system:getGitBashPath',
System_SetGitBashPath = 'system:setGitBashPath',
// DevTools
System_ToggleDevTools = 'system:toggleDevTools',

View File

@ -5,9 +5,17 @@ exports.default = async function (configuration) {
const { path } = configuration
if (configuration.path) {
try {
const certPath = process.env.CHERRY_CERT_PATH
const keyContainer = process.env.CHERRY_CERT_KEY
const csp = process.env.CHERRY_CERT_CSP
if (!certPath || !keyContainer || !csp) {
throw new Error('CHERRY_CERT_PATH, CHERRY_CERT_KEY or CHERRY_CERT_CSP is not set')
}
console.log('Start code signing...')
console.log('Signing file:', path)
const signCommand = `signtool sign /tr http://timestamp.comodoca.com /td sha256 /fd sha256 /a /v "${path}"`
const signCommand = `signtool sign /tr http://timestamp.comodoca.com /td sha256 /fd sha256 /v /f "${certPath}" /csp "${csp}" /k "${keyContainer}" "${path}"`
execSync(signCommand, { stdio: 'inherit' })
console.log('Code signing completed')
} catch (error) {

View File

@ -6,7 +6,7 @@ import { loggerService } from '@logger'
import { isLinux, isMac, isPortable, isWin } from '@main/constant'
import { generateSignature } from '@main/integration/cherryai'
import anthropicService from '@main/services/AnthropicService'
import { findGitBash, getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
import { findGitBash, getBinaryPath, isBinaryExists, runInstallScript, validateGitBashPath } from '@main/utils/process'
import { handleZoomFactor } from '@main/utils/zoom'
import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import type { UpgradeChannel } from '@shared/config/constant'
@ -35,7 +35,7 @@ import appService from './services/AppService'
import AppUpdater from './services/AppUpdater'
import BackupManager from './services/BackupManager'
import { codeToolsService } from './services/CodeToolsService'
import { configManager } from './services/ConfigManager'
import { ConfigKeys, configManager } from './services/ConfigManager'
import CopilotService from './services/CopilotService'
import DxtService from './services/DxtService'
import { ExportService } from './services/ExportService'
@ -500,7 +500,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}
try {
const bashPath = findGitBash()
const customPath = configManager.get(ConfigKeys.GitBashPath) as string | undefined
const bashPath = findGitBash(customPath)
if (bashPath) {
logger.info('Git Bash is available', { path: bashPath })
@ -514,6 +515,35 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
return false
}
})
ipcMain.handle(IpcChannel.System_GetGitBashPath, () => {
if (!isWin) {
return null
}
const customPath = configManager.get(ConfigKeys.GitBashPath) as string | undefined
return customPath ?? null
})
ipcMain.handle(IpcChannel.System_SetGitBashPath, (_, newPath: string | null) => {
if (!isWin) {
return false
}
if (!newPath) {
configManager.set(ConfigKeys.GitBashPath, null)
return true
}
const validated = validateGitBashPath(newPath)
if (!validated) {
return false
}
configManager.set(ConfigKeys.GitBashPath, validated)
return true
})
ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => {
const win = BrowserWindow.fromWebContents(e.sender)
win && win.webContents.toggleDevTools()
@ -767,7 +797,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Mcp_AbortTool, mcpService.abortTool)
ipcMain.handle(IpcChannel.Mcp_GetServerVersion, mcpService.getServerVersion)
ipcMain.handle(IpcChannel.Mcp_GetServerLogs, mcpService.getServerLogs)
ipcMain.handle(IpcChannel.Mcp_GetServerLogs, mcpService.getServerLogs)
// DXT upload handler
ipcMain.handle(IpcChannel.Mcp_UploadDxt, async (event, fileBuffer: ArrayBuffer, fileName: string) => {
@ -846,6 +875,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
webview.session.setSpellCheckerEnabled(isEnable)
})
// Webview print and save handlers
ipcMain.handle(IpcChannel.Webview_PrintToPDF, async (_, webviewId: number) => {
const { printWebviewToPDF } = await import('./services/WebviewService')
return await printWebviewToPDF(webviewId)
})
ipcMain.handle(IpcChannel.Webview_SaveAsHTML, async (_, webviewId: number) => {
const { saveWebviewAsHTML } = await import('./services/WebviewService')
return await saveWebviewAsHTML(webviewId)
})
// store sync
storeSyncService.registerIpcHandler()

View File

@ -31,7 +31,8 @@ export enum ConfigKeys {
DisableHardwareAcceleration = 'disableHardwareAcceleration',
Proxy = 'proxy',
EnableDeveloperMode = 'enableDeveloperMode',
ClientId = 'clientId'
ClientId = 'clientId',
GitBashPath = 'gitBashPath'
}
export class ConfigManager {

View File

@ -162,6 +162,7 @@ class McpService {
this.cleanup = this.cleanup.bind(this)
this.checkMcpConnectivity = this.checkMcpConnectivity.bind(this)
this.getServerVersion = this.getServerVersion.bind(this)
this.getServerLogs = this.getServerLogs.bind(this)
}
private getServerKey(server: MCPServer): string {
@ -392,15 +393,8 @@ class McpService {
source: 'stdio'
})
})
;(stdioTransport as any).stdout?.on('data', (data: any) => {
const msg = data.toString()
this.emitServerLog(server, {
timestamp: Date.now(),
level: 'stdout',
message: msg.trim(),
source: 'stdio'
})
})
// StdioClientTransport does not expose stdout as a readable stream for raw logging
// (stdout is reserved for JSON-RPC). Avoid attaching a listener that would never fire.
return stdioTransport
} else {
throw new Error('Either baseUrl or command must be provided')

View File

@ -1,5 +1,6 @@
import { IpcChannel } from '@shared/IpcChannel'
import { app, session, shell, webContents } from 'electron'
import { app, dialog, session, shell, webContents } from 'electron'
import { promises as fs } from 'fs'
/**
* init the useragent of the webview session
@ -53,11 +54,17 @@ const attachKeyboardHandler = (contents: Electron.WebContents) => {
return
}
const isFindShortcut = (input.control || input.meta) && key === 'f'
const isEscape = key === 'escape'
const isEnter = key === 'enter'
// Helper to check if this is a shortcut we handle
const isHandledShortcut = (k: string) => {
const isFindShortcut = (input.control || input.meta) && k === 'f'
const isPrintShortcut = (input.control || input.meta) && k === 'p'
const isSaveShortcut = (input.control || input.meta) && k === 's'
const isEscape = k === 'escape'
const isEnter = k === 'enter'
return isFindShortcut || isPrintShortcut || isSaveShortcut || isEscape || isEnter
}
if (!isFindShortcut && !isEscape && !isEnter) {
if (!isHandledShortcut(key)) {
return
}
@ -66,11 +73,20 @@ const attachKeyboardHandler = (contents: Electron.WebContents) => {
return
}
const isFindShortcut = (input.control || input.meta) && key === 'f'
const isPrintShortcut = (input.control || input.meta) && key === 'p'
const isSaveShortcut = (input.control || input.meta) && key === 's'
// Always prevent Cmd/Ctrl+F to override the guest page's native find dialog
if (isFindShortcut) {
event.preventDefault()
}
// Prevent default print/save dialogs and handle them with custom logic
if (isPrintShortcut || isSaveShortcut) {
event.preventDefault()
}
// Send the hotkey event to the renderer
// The renderer will decide whether to preventDefault for Escape and Enter
// based on whether the search bar is visible
@ -100,3 +116,130 @@ export function initWebviewHotkeys() {
attachKeyboardHandler(contents)
})
}
/**
* Print webview content to PDF
* @param webviewId The webview webContents id
* @returns Path to saved PDF file or null if user cancelled
*/
export async function printWebviewToPDF(webviewId: number): Promise<string | null> {
const webview = webContents.fromId(webviewId)
if (!webview) {
throw new Error('Webview not found')
}
try {
// Get the page title for default filename
const pageTitle = await webview.executeJavaScript('document.title || "webpage"').catch(() => 'webpage')
// Sanitize filename by removing invalid characters
const sanitizedTitle = pageTitle.replace(/[<>:"/\\|?*]/g, '-').substring(0, 100)
const defaultFilename = sanitizedTitle ? `${sanitizedTitle}.pdf` : `webpage-${Date.now()}.pdf`
// Show save dialog
const { canceled, filePath } = await dialog.showSaveDialog({
title: 'Save as PDF',
defaultPath: defaultFilename,
filters: [{ name: 'PDF Files', extensions: ['pdf'] }]
})
if (canceled || !filePath) {
return null
}
// Generate PDF with settings to capture full page
const pdfData = await webview.printToPDF({
margins: {
marginType: 'default'
},
printBackground: true,
landscape: false,
pageSize: 'A4',
preferCSSPageSize: true
})
// Save PDF to file
await fs.writeFile(filePath, pdfData)
return filePath
} catch (error) {
throw new Error(`Failed to print to PDF: ${(error as Error).message}`)
}
}
/**
* Save webview content as HTML
* @param webviewId The webview webContents id
* @returns Path to saved HTML file or null if user cancelled
*/
export async function saveWebviewAsHTML(webviewId: number): Promise<string | null> {
const webview = webContents.fromId(webviewId)
if (!webview) {
throw new Error('Webview not found')
}
try {
// Get the page title for default filename
const pageTitle = await webview.executeJavaScript('document.title || "webpage"').catch(() => 'webpage')
// Sanitize filename by removing invalid characters
const sanitizedTitle = pageTitle.replace(/[<>:"/\\|?*]/g, '-').substring(0, 100)
const defaultFilename = sanitizedTitle ? `${sanitizedTitle}.html` : `webpage-${Date.now()}.html`
// Show save dialog
const { canceled, filePath } = await dialog.showSaveDialog({
title: 'Save as HTML',
defaultPath: defaultFilename,
filters: [
{ name: 'HTML Files', extensions: ['html', 'htm'] },
{ name: 'All Files', extensions: ['*'] }
]
})
if (canceled || !filePath) {
return null
}
// Get the HTML content with safe error handling
const html = await webview.executeJavaScript(`
(() => {
try {
// Build complete DOCTYPE string if present
let doctype = '';
if (document.doctype) {
const dt = document.doctype;
doctype = '<!DOCTYPE ' + (dt.name || 'html');
// Add PUBLIC identifier if publicId is present
if (dt.publicId) {
// Escape single quotes in publicId
const escapedPublicId = String(dt.publicId).replace(/'/g, "\\'");
doctype += " PUBLIC '" + escapedPublicId + "'";
// Add systemId if present (required when publicId is present)
if (dt.systemId) {
const escapedSystemId = String(dt.systemId).replace(/'/g, "\\'");
doctype += " '" + escapedSystemId + "'";
}
} else if (dt.systemId) {
// SYSTEM identifier (without PUBLIC)
const escapedSystemId = String(dt.systemId).replace(/'/g, "\\'");
doctype += " SYSTEM '" + escapedSystemId + "'";
}
doctype += '>';
}
return doctype + (document.documentElement?.outerHTML || '');
} catch (error) {
// Fallback: just return the HTML without DOCTYPE if there's an error
return document.documentElement?.outerHTML || '';
}
})()
`)
// Save HTML to file
await fs.writeFile(filePath, html, 'utf-8')
return filePath
} catch (error) {
throw new Error(`Failed to save as HTML: ${(error as Error).message}`)
}
}

View File

@ -15,6 +15,8 @@ import { query } from '@anthropic-ai/claude-agent-sdk'
import { loggerService } from '@logger'
import { config as apiConfigService } from '@main/apiServer/config'
import { validateModelId } from '@main/apiServer/utils'
import { ConfigKeys, configManager } from '@main/services/ConfigManager'
import { validateGitBashPath } from '@main/utils/process'
import getLoginShellEnvironment from '@main/utils/shell-env'
import { app } from 'electron'
@ -107,6 +109,8 @@ class ClaudeCodeService implements AgentServiceInterface {
Object.entries(loginShellEnv).filter(([key]) => !key.toLowerCase().endsWith('_proxy'))
) as Record<string, string>
const customGitBashPath = validateGitBashPath(configManager.get(ConfigKeys.GitBashPath) as string | undefined)
const env = {
...loginShellEnvWithoutProxies,
// TODO: fix the proxy api server
@ -126,7 +130,8 @@ class ClaudeCodeService implements AgentServiceInterface {
// Set CLAUDE_CONFIG_DIR to app's userData directory to avoid path encoding issues
// on Windows when the username contains non-ASCII characters (e.g., Chinese characters)
// This prevents the SDK from using the user's home directory which may have encoding problems
CLAUDE_CONFIG_DIR: path.join(app.getPath('userData'), '.claude')
CLAUDE_CONFIG_DIR: path.join(app.getPath('userData'), '.claude'),
...(customGitBashPath ? { CLAUDE_CODE_GIT_BASH_PATH: customGitBashPath } : {})
}
const errorChunks: string[] = []

View File

@ -3,7 +3,7 @@ import fs from 'fs'
import path from 'path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { findExecutable, findGitBash } from '../process'
import { findExecutable, findGitBash, validateGitBashPath } from '../process'
// Mock dependencies
vi.mock('child_process')
@ -289,7 +289,133 @@ describe.skipIf(process.platform !== 'win32')('process utilities', () => {
})
})
describe('validateGitBashPath', () => {
it('returns null when path is null', () => {
const result = validateGitBashPath(null)
expect(result).toBeNull()
})
it('returns null when path is undefined', () => {
const result = validateGitBashPath(undefined)
expect(result).toBeNull()
})
it('returns normalized path when valid bash.exe exists', () => {
const customPath = 'C:\\PortableGit\\bin\\bash.exe'
vi.mocked(fs.existsSync).mockImplementation((p) => p === 'C:\\PortableGit\\bin\\bash.exe')
const result = validateGitBashPath(customPath)
expect(result).toBe('C:\\PortableGit\\bin\\bash.exe')
})
it('returns null when file does not exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(false)
const result = validateGitBashPath('C:\\missing\\bash.exe')
expect(result).toBeNull()
})
it('returns null when path is not bash.exe', () => {
const customPath = 'C:\\PortableGit\\bin\\git.exe'
vi.mocked(fs.existsSync).mockReturnValue(true)
const result = validateGitBashPath(customPath)
expect(result).toBeNull()
})
})
describe('findGitBash', () => {
describe('customPath parameter', () => {
beforeEach(() => {
delete process.env.CLAUDE_CODE_GIT_BASH_PATH
})
it('uses customPath when valid', () => {
const customPath = 'C:\\CustomGit\\bin\\bash.exe'
vi.mocked(fs.existsSync).mockImplementation((p) => p === customPath)
const result = findGitBash(customPath)
expect(result).toBe(customPath)
expect(execFileSync).not.toHaveBeenCalled()
})
it('falls back when customPath is invalid', () => {
const customPath = 'C:\\Invalid\\bash.exe'
const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe'
const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe'
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === customPath) return false
if (p === gitPath) return true
if (p === bashPath) return true
return false
})
vi.mocked(execFileSync).mockReturnValue(gitPath)
const result = findGitBash(customPath)
expect(result).toBe(bashPath)
})
it('prioritizes customPath over env override', () => {
const customPath = 'C:\\CustomGit\\bin\\bash.exe'
const envPath = 'C:\\EnvGit\\bin\\bash.exe'
process.env.CLAUDE_CODE_GIT_BASH_PATH = envPath
vi.mocked(fs.existsSync).mockImplementation((p) => p === customPath || p === envPath)
const result = findGitBash(customPath)
expect(result).toBe(customPath)
})
})
describe('env override', () => {
beforeEach(() => {
delete process.env.CLAUDE_CODE_GIT_BASH_PATH
})
it('uses CLAUDE_CODE_GIT_BASH_PATH when valid', () => {
const envPath = 'C:\\OverrideGit\\bin\\bash.exe'
process.env.CLAUDE_CODE_GIT_BASH_PATH = envPath
vi.mocked(fs.existsSync).mockImplementation((p) => p === envPath)
const result = findGitBash()
expect(result).toBe(envPath)
expect(execFileSync).not.toHaveBeenCalled()
})
it('falls back when CLAUDE_CODE_GIT_BASH_PATH is invalid', () => {
const envPath = 'C:\\Invalid\\bash.exe'
const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe'
const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe'
process.env.CLAUDE_CODE_GIT_BASH_PATH = envPath
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === envPath) return false
if (p === gitPath) return true
if (p === bashPath) return true
return false
})
vi.mocked(execFileSync).mockReturnValue(gitPath)
const result = findGitBash()
expect(result).toBe(bashPath)
})
})
describe('git.exe path derivation', () => {
it('should derive bash.exe from standard Git installation (Git/cmd/git.exe)', () => {
const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe'

View File

@ -131,15 +131,37 @@ export function findExecutable(name: string): string | null {
/**
* Find Git Bash executable on Windows
* @param customPath - Optional custom path from config
* @returns Full path to bash.exe or null if not found
*/
export function findGitBash(): string | null {
export function findGitBash(customPath?: string | null): string | null {
// Git Bash is Windows-only
if (!isWin) {
return null
}
// 1. Find git.exe and derive bash.exe path
// 1. Check custom path from config first
if (customPath) {
const validated = validateGitBashPath(customPath)
if (validated) {
logger.debug('Using custom Git Bash path from config', { path: validated })
return validated
}
logger.warn('Custom Git Bash path provided but invalid', { path: customPath })
}
// 2. Check environment variable override
const envOverride = process.env.CLAUDE_CODE_GIT_BASH_PATH
if (envOverride) {
const validated = validateGitBashPath(envOverride)
if (validated) {
logger.debug('Using CLAUDE_CODE_GIT_BASH_PATH override for bash.exe', { path: validated })
return validated
}
logger.warn('CLAUDE_CODE_GIT_BASH_PATH provided but path is invalid', { path: envOverride })
}
// 3. Find git.exe and derive bash.exe path
const gitPath = findExecutable('git')
if (gitPath) {
// Try multiple possible locations for bash.exe relative to git.exe
@ -164,7 +186,7 @@ export function findGitBash(): string | null {
})
}
// 2. Fallback: check common Git Bash paths directly
// 4. Fallback: check common Git Bash paths directly
const commonBashPaths = [
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'),
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'),
@ -181,3 +203,25 @@ export function findGitBash(): string | null {
logger.debug('Git Bash not found - checked git derivation and common paths')
return null
}
export function validateGitBashPath(customPath?: string | null): string | null {
if (!customPath) {
return null
}
const resolved = path.resolve(customPath)
if (!fs.existsSync(resolved)) {
logger.warn('Custom Git Bash path does not exist', { path: resolved })
return null
}
const isExe = resolved.toLowerCase().endsWith('bash.exe')
if (!isExe) {
logger.warn('Custom Git Bash path is not bash.exe', { path: resolved })
return null
}
logger.debug('Validated custom Git Bash path', { path: resolved })
return resolved
}

View File

@ -124,7 +124,10 @@ const api = {
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType),
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname),
getCpuName: () => ipcRenderer.invoke(IpcChannel.System_GetCpuName),
checkGitBash: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.System_CheckGitBash)
checkGitBash: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.System_CheckGitBash),
getGitBashPath: (): Promise<string | null> => ipcRenderer.invoke(IpcChannel.System_GetGitBashPath),
setGitBashPath: (newPath: string | null): Promise<boolean> =>
ipcRenderer.invoke(IpcChannel.System_SetGitBashPath, newPath)
},
devTools: {
toggle: () => ipcRenderer.invoke(IpcChannel.System_ToggleDevTools)
@ -434,6 +437,8 @@ const api = {
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal),
setSpellCheckEnabled: (webviewId: number, isEnable: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable),
printToPDF: (webviewId: number) => ipcRenderer.invoke(IpcChannel.Webview_PrintToPDF, webviewId),
saveAsHTML: (webviewId: number) => ipcRenderer.invoke(IpcChannel.Webview_SaveAsHTML, webviewId),
onFindShortcut: (callback: (payload: WebviewKeyEvent) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: WebviewKeyEvent) => {
callback(payload)

View File

@ -255,7 +255,7 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A
const cherryinProvider = getProviderById(SystemProviderIds.cherryin)
if (cherryinProvider) {
extraOptions.anthropicBaseURL = cherryinProvider.anthropicApiHost
extraOptions.geminiBaseURL = cherryinProvider.apiHost + '/gemini/v1beta'
extraOptions.geminiBaseURL = cherryinProvider.apiHost + '/v1beta/models'
}
}

View File

@ -754,7 +754,8 @@ describe('reasoning utils', () => {
const result = getGeminiReasoningParams(assistant, model)
expect(result).toEqual({
thinkingConfig: {
includeThoughts: true
includeThoughts: true,
thinkingBudget: -1
}
})
})

View File

@ -589,6 +589,7 @@ export function getGeminiReasoningParams(
if (effortRatio > 1) {
return {
thinkingConfig: {
thinkingBudget: -1,
includeThoughts: true
}
}

View File

@ -25,7 +25,7 @@ type ViewMode = 'split' | 'code' | 'preview'
const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onSave, onClose }) => {
const { t } = useTranslation()
const [viewMode, setViewMode] = useState<ViewMode>('split')
const [isFullscreen, setIsFullscreen] = useState(false)
const [isFullscreen, setIsFullscreen] = useState(true)
const [saved, setSaved] = useTemporaryValue(false, 2000)
const codeEditorRef = useRef<CodeEditorHandles>(null)
const previewFrameRef = useRef<HTMLIFrameElement>(null)
@ -78,7 +78,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
</HeaderLeft>
<HeaderCenter>
<ViewControls onDoubleClick={(e) => e.stopPropagation()}>
<ViewControls onDoubleClick={(e) => e.stopPropagation()} className="nodrag">
<ViewButton
size="small"
type={viewMode === 'split' ? 'primary' : 'default'}

View File

@ -106,6 +106,51 @@ const WebviewContainer = memo(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appid, url])
// Setup keyboard shortcuts handler for print and save
useEffect(() => {
if (!webviewRef.current) return
const unsubscribe = window.api?.webview?.onFindShortcut?.(async (payload) => {
// Get webviewId when event is triggered
const webviewId = webviewRef.current?.getWebContentsId()
// Only handle events for this webview
if (!webviewId || payload.webviewId !== webviewId) return
const key = payload.key?.toLowerCase()
const isModifier = payload.control || payload.meta
if (!isModifier || !key) return
try {
if (key === 'p') {
// Print to PDF
logger.info(`Printing webview ${appid} to PDF`)
const filePath = await window.api.webview.printToPDF(webviewId)
if (filePath) {
window.toast?.success?.(`PDF saved to: ${filePath}`)
logger.info(`PDF saved to: ${filePath}`)
}
} else if (key === 's') {
// Save as HTML
logger.info(`Saving webview ${appid} as HTML`)
const filePath = await window.api.webview.saveAsHTML(webviewId)
if (filePath) {
window.toast?.success?.(`HTML saved to: ${filePath}`)
logger.info(`HTML saved to: ${filePath}`)
}
}
} catch (error) {
logger.error(`Failed to handle shortcut for webview ${appid}:`, error as Error)
window.toast?.error?.(`Failed: ${(error as Error).message}`)
}
})
return () => {
unsubscribe?.()
}
}, [appid])
// Update webview settings when they change
useEffect(() => {
if (!webviewRef.current) return

View File

@ -60,6 +60,7 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
const [form, setForm] = useState<BaseAgentForm>(() => buildAgentForm(agent))
const [hasGitBash, setHasGitBash] = useState<boolean>(true)
const [customGitBashPath, setCustomGitBashPath] = useState<string>('')
useEffect(() => {
if (open) {
@ -70,7 +71,11 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
const checkGitBash = useCallback(
async (showToast = false) => {
try {
const gitBashInstalled = await window.api.system.checkGitBash()
const [gitBashInstalled, savedPath] = await Promise.all([
window.api.system.checkGitBash(),
window.api.system.getGitBashPath().catch(() => null)
])
setCustomGitBashPath(savedPath ?? '')
setHasGitBash(gitBashInstalled)
if (showToast) {
if (gitBashInstalled) {
@ -93,6 +98,46 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
const selectedPermissionMode = form.configuration?.permission_mode ?? 'default'
const handlePickGitBash = useCallback(async () => {
try {
const selected = await window.api.file.select({
title: t('agent.gitBash.pick.title', 'Select Git Bash executable'),
filters: [{ name: 'Executable', extensions: ['exe'] }],
properties: ['openFile']
})
if (!selected || selected.length === 0) {
return
}
const pickedPath = selected[0].path
const ok = await window.api.system.setGitBashPath(pickedPath)
if (!ok) {
window.toast.error(
t('agent.gitBash.pick.invalidPath', 'Selected file is not a valid Git Bash executable (bash.exe).')
)
return
}
setCustomGitBashPath(pickedPath)
await checkGitBash(true)
} catch (error) {
logger.error('Failed to pick Git Bash path', error as Error)
window.toast.error(t('agent.gitBash.pick.failed', 'Failed to set Git Bash path'))
}
}, [checkGitBash, t])
const handleClearGitBash = useCallback(async () => {
try {
await window.api.system.setGitBashPath(null)
setCustomGitBashPath('')
await checkGitBash(true)
} catch (error) {
logger.error('Failed to clear Git Bash path', error as Error)
window.toast.error(t('agent.gitBash.pick.failed', 'Failed to set Git Bash path'))
}
}, [checkGitBash, t])
const onPermissionModeChange = useCallback((value: PermissionMode) => {
setForm((prev) => {
const parsedConfiguration = AgentConfigurationSchema.parse(prev.configuration ?? {})
@ -324,6 +369,9 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
<Button size="small" onClick={() => checkGitBash(true)}>
{t('agent.gitBash.error.recheck', 'Recheck Git Bash Installation')}
</Button>
<Button size="small" style={{ marginLeft: 8 }} onClick={handlePickGitBash}>
{t('agent.gitBash.pick.button', 'Select Git Bash Path')}
</Button>
</div>
}
type="error"
@ -331,6 +379,33 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
style={{ marginBottom: 16 }}
/>
)}
{hasGitBash && customGitBashPath && (
<Alert
message={t('agent.gitBash.found.title', 'Git Bash configured')}
description={
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div>
{t('agent.gitBash.customPath', {
defaultValue: 'Using custom path: {{path}}',
path: customGitBashPath
})}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Button size="small" onClick={handlePickGitBash}>
{t('agent.gitBash.pick.button', 'Select Git Bash Path')}
</Button>
<Button size="small" onClick={handleClearGitBash}>
{t('agent.gitBash.clear.button', 'Clear custom path')}
</Button>
</div>
</div>
}
type="success"
showIcon
style={{ marginBottom: 16 }}
/>
)}
<FormRow>
<FormItem style={{ flex: 1 }}>
<Label>

View File

@ -89,10 +89,7 @@ const Sidebar: FC = () => {
)}
</MainMenusContainer>
<Menus>
<Tooltip
title={t('settings.theme.title') + ': ' + getThemeModeLabel(settedTheme)}
mouseEnterDelay={0.8}
placement="right">
<Tooltip title={t('settings.theme.title') + ': ' + getThemeModeLabel(settedTheme)} placement="right">
<Icon theme={theme} onClick={toggleTheme}>
{settedTheme === ThemeMode.dark ? (
<Moon size={20} className="icon" />

View File

@ -31,12 +31,26 @@
}
},
"gitBash": {
"autoDetected": "Using auto-detected Git Bash",
"clear": {
"button": "Clear custom path"
},
"customPath": "Using custom path: {{path}}",
"error": {
"description": "Git Bash is required to run agents on Windows. The agent cannot function without it. Please install Git for Windows from",
"recheck": "Recheck Git Bash Installation",
"title": "Git Bash Required"
},
"found": {
"title": "Git Bash configured"
},
"notFound": "Git Bash not found. Please install it first.",
"pick": {
"button": "Select Git Bash Path",
"failed": "Failed to set Git Bash path",
"invalidPath": "Selected file is not a valid Git Bash executable (bash.exe).",
"title": "Select Git Bash executable"
},
"success": "Git Bash detected successfully!"
},
"input": {
@ -471,6 +485,14 @@
"url_placeholder": "Enter JSON URL"
},
"manage": {
"batch_delete": {
"button": "Batch Delete",
"confirm": "Are you sure you want to delete the selected {{count}} assistants?"
},
"mode": {
"delete": "Delete",
"sort": "Sort"
},
"title": "Manage Assistants"
},
"my_agents": "My Assistants",
@ -1185,6 +1207,7 @@
"saved": "Saved",
"search": "Search",
"select": "Select",
"select_all": "Select All",
"selected": "Selected",
"selectedItems": "Selected {{count}} items",
"selectedMessages": "Selected {{count}} messages",

View File

@ -31,12 +31,26 @@
}
},
"gitBash": {
"autoDetected": "使用自动检测的 Git Bash",
"clear": {
"button": "清除自定义路径"
},
"customPath": "使用自定义路径:{{path}}",
"error": {
"description": "在 Windows 上运行智能体需要 Git Bash。没有它智能体无法运行。请从以下地址安装 Git for Windows",
"recheck": "重新检测 Git Bash 安装",
"title": "需要 Git Bash"
},
"found": {
"title": "已配置 Git Bash"
},
"notFound": "未找到 Git Bash。请先安装。",
"pick": {
"button": "选择 Git Bash 路径",
"failed": "设置 Git Bash 路径失败",
"invalidPath": "选择的文件不是有效的 Git Bash 可执行文件bash.exe。",
"title": "选择 Git Bash 可执行文件"
},
"success": "成功检测到 Git Bash"
},
"input": {
@ -471,6 +485,14 @@
"url_placeholder": "输入 JSON URL"
},
"manage": {
"batch_delete": {
"button": "批量删除",
"confirm": "确定要删除选中的 {{count}} 个助手吗?"
},
"mode": {
"delete": "删除",
"sort": "排序"
},
"title": "管理助手"
},
"my_agents": "我的助手",
@ -1185,6 +1207,7 @@
"saved": "已保存",
"search": "搜索",
"select": "选择",
"select_all": "全选",
"selected": "已选择",
"selectedItems": "已选择 {{count}} 项",
"selectedMessages": "选中 {{count}} 条消息",

View File

@ -31,12 +31,26 @@
}
},
"gitBash": {
"autoDetected": "使用自動偵測的 Git Bash",
"clear": {
"button": "清除自訂路徑"
},
"customPath": "使用自訂路徑:{{path}}",
"error": {
"description": "在 Windows 上執行代理程式需要 Git Bash。沒有它代理程式無法運作。請從以下地址安裝 Git for Windows",
"recheck": "重新檢測 Git Bash 安裝",
"title": "需要 Git Bash"
},
"found": {
"title": "已配置 Git Bash"
},
"notFound": "找不到 Git Bash。請先安裝。",
"pick": {
"button": "選擇 Git Bash 路徑",
"failed": "設定 Git Bash 路徑失敗",
"invalidPath": "選擇的檔案不是有效的 Git Bash 可執行檔bash.exe。",
"title": "選擇 Git Bash 可執行檔"
},
"success": "成功偵測到 Git Bash"
},
"input": {
@ -471,6 +485,14 @@
"url_placeholder": "輸入 JSON URL"
},
"manage": {
"batch_delete": {
"button": "批次刪除",
"confirm": "您確定要刪除所選的 {{count}} 個助理嗎?"
},
"mode": {
"delete": "刪除",
"sort": "排序"
},
"title": "管理助手"
},
"my_agents": "我的助手",
@ -1185,6 +1207,7 @@
"saved": "已儲存",
"search": "搜尋",
"select": "選擇",
"select_all": "全選",
"selected": "已選擇",
"selectedItems": "已選擇 {{count}} 項",
"selectedMessages": "選中 {{count}} 條訊息",

View File

@ -31,12 +31,26 @@
}
},
"gitBash": {
"autoDetected": "Automatisch ermitteltes Git Bash wird verwendet",
"clear": {
"button": "Benutzerdefinierten Pfad löschen"
},
"customPath": "Benutzerdefinierter Pfad: {{path}}",
"error": {
"description": "Git Bash ist erforderlich, um Agents unter Windows auszuführen. Der Agent kann ohne es nicht funktionieren. Bitte installieren Sie Git für Windows von",
"recheck": "Überprüfe die Git Bash-Installation erneut",
"title": "Git Bash erforderlich"
},
"found": {
"title": "Git Bash konfiguriert"
},
"notFound": "Git Bash nicht gefunden. Bitte installieren Sie es zuerst.",
"pick": {
"button": "Git Bash Pfad auswählen",
"failed": "Git Bash Pfad konnte nicht gesetzt werden",
"invalidPath": "Die ausgewählte Datei ist keine gültige Git Bash ausführbare Datei (bash.exe).",
"title": "Git Bash ausführbare Datei auswählen"
},
"success": "Git Bash erfolgreich erkannt!"
},
"input": {
@ -471,6 +485,14 @@
"url_placeholder": "JSON-URL eingeben"
},
"manage": {
"batch_delete": {
"button": "Stapel löschen",
"confirm": "Sind Sie sicher, dass Sie die ausgewählten {{count}} Assistenten löschen möchten?"
},
"mode": {
"delete": "Löschen",
"sort": "Sortieren"
},
"title": "Assistenten verwalten"
},
"my_agents": "Meine Assistenten",
@ -1185,6 +1207,7 @@
"saved": "Gespeichert",
"search": "Suchen",
"select": "Auswählen",
"select_all": "Alle auswählen",
"selected": "Ausgewählt",
"selectedItems": "{{count}} Elemente ausgewählt",
"selectedMessages": "{{count}} Nachrichten ausgewählt",

View File

@ -31,12 +31,26 @@
}
},
"gitBash": {
"autoDetected": "[to be translated]:Using auto-detected Git Bash",
"clear": {
"button": "[to be translated]:Clear custom path"
},
"customPath": "[to be translated]:Using custom path: {{path}}",
"error": {
"description": "Το Git Bash απαιτείται για την εκτέλεση πρακτόρων στα Windows. Ο πράκτορας δεν μπορεί να λειτουργήσει χωρίς αυτό. Παρακαλούμε εγκαταστήστε το Git για Windows από",
"recheck": "Επανέλεγχος Εγκατάστασης του Git Bash",
"title": "Απαιτείται Git Bash"
},
"found": {
"title": "[to be translated]:Git Bash configured"
},
"notFound": "Το Git Bash δεν βρέθηκε. Παρακαλώ εγκαταστήστε το πρώτα.",
"pick": {
"button": "[to be translated]:Select Git Bash Path",
"failed": "[to be translated]:Failed to set Git Bash path",
"invalidPath": "[to be translated]:Selected file is not a valid Git Bash executable (bash.exe).",
"title": "[to be translated]:Select Git Bash executable"
},
"success": "Το Git Bash εντοπίστηκε με επιτυχία!"
},
"input": {
@ -471,6 +485,14 @@
"url_placeholder": "Εισάγετε JSON URL"
},
"manage": {
"batch_delete": {
"button": "Μαζική Διαγραφή",
"confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τους επιλεγμένους {{count}} βοηθούς;"
},
"mode": {
"delete": "Διαγραφή",
"sort": "Ταξινόμηση"
},
"title": "Διαχείριση βοηθών"
},
"my_agents": "Οι βοηθοί μου",
@ -1185,6 +1207,7 @@
"saved": "Αποθηκεύτηκε",
"search": "Αναζήτηση",
"select": "Επιλογή",
"select_all": "Επιλογή Όλων",
"selected": "Επιλεγμένο",
"selectedItems": "Επιλέχθηκαν {{count}} αντικείμενα",
"selectedMessages": "Επιλέχθηκαν {{count}} μηνύματα",

View File

@ -31,12 +31,26 @@
}
},
"gitBash": {
"autoDetected": "Usando Git Bash detectado automáticamente",
"clear": {
"button": "Borrar ruta personalizada"
},
"customPath": "Usando ruta personalizada: {{path}}",
"error": {
"description": "Se requiere Git Bash para ejecutar agentes en Windows. El agente no puede funcionar sin él. Instale Git para Windows desde",
"recheck": "Volver a verificar la instalación de Git Bash",
"title": "Git Bash Requerido"
},
"found": {
"title": "Git Bash configurado"
},
"notFound": "Git Bash no encontrado. Por favor, instálalo primero.",
"pick": {
"button": "Seleccionar ruta de Git Bash",
"failed": "No se pudo configurar la ruta de Git Bash",
"invalidPath": "El archivo seleccionado no es un ejecutable válido de Git Bash (bash.exe).",
"title": "Seleccionar ejecutable de Git Bash"
},
"success": "¡Git Bash detectado con éxito!"
},
"input": {
@ -471,6 +485,14 @@
"url_placeholder": "Introducir URL JSON"
},
"manage": {
"batch_delete": {
"button": "Eliminación por lotes",
"confirm": "¿Estás seguro de que quieres eliminar los {{count}} asistentes seleccionados?"
},
"mode": {
"delete": "Eliminar",
"sort": "Ordenar"
},
"title": "Gestionar asistentes"
},
"my_agents": "Mis asistentes",
@ -1185,6 +1207,7 @@
"saved": "Guardado",
"search": "Buscar",
"select": "Seleccionar",
"select_all": "Seleccionar todo",
"selected": "Seleccionado",
"selectedItems": "{{count}} elementos seleccionados",
"selectedMessages": "{{count}} mensajes seleccionados",

View File

@ -31,12 +31,26 @@
}
},
"gitBash": {
"autoDetected": "Utilisation de Git Bash détecté automatiquement",
"clear": {
"button": "Effacer le chemin personnalisé"
},
"customPath": "Utilisation du chemin personnalisé : {{path}}",
"error": {
"description": "Git Bash est requis pour exécuter des agents sur Windows. L'agent ne peut pas fonctionner sans. Veuillez installer Git pour Windows depuis",
"recheck": "Revérifier l'installation de Git Bash",
"title": "Git Bash requis"
},
"notFound": "Git Bash introuvable. Veuillez linstaller dabord.",
"found": {
"title": "Git Bash configuré"
},
"notFound": "Git Bash non trouvé. Veuillez l'installer d'abord.",
"pick": {
"button": "Sélectionner le chemin Git Bash",
"failed": "Échec de la configuration du chemin Git Bash",
"invalidPath": "Le fichier sélectionné n'est pas un exécutable Git Bash valide (bash.exe).",
"title": "Sélectionner l'exécutable Git Bash"
},
"success": "Git Bash détecté avec succès !"
},
"input": {
@ -471,6 +485,14 @@
"url_placeholder": "Saisir l'URL JSON"
},
"manage": {
"batch_delete": {
"button": "Suppression par lot",
"confirm": "Êtes-vous sûr de vouloir supprimer les {{count}} assistants sélectionnés ?"
},
"mode": {
"delete": "Supprimer",
"sort": "Trier"
},
"title": "Gérer les assistants"
},
"my_agents": "Mes assistants",
@ -1185,6 +1207,7 @@
"saved": "enregistré",
"search": "Rechercher",
"select": "Sélectionner",
"select_all": "Tout sélectionner",
"selected": "Sélectionné",
"selectedItems": "{{count}} éléments sélectionnés",
"selectedMessages": "{{count}} messages sélectionnés",

View File

@ -31,12 +31,26 @@
}
},
"gitBash": {
"autoDetected": "[to be translated]:Using auto-detected Git Bash",
"clear": {
"button": "[to be translated]:Clear custom path"
},
"customPath": "[to be translated]:Using custom path: {{path}}",
"error": {
"description": "Windowsでエージェントを実行するにはGit Bashが必要です。これがないとエージェントは動作しません。以下からGit for Windowsをインストールしてください。",
"recheck": "Git Bashのインストールを再確認してください",
"title": "Git Bashが必要です"
},
"found": {
"title": "[to be translated]:Git Bash configured"
},
"notFound": "Git Bash が見つかりません。先にインストールしてください。",
"pick": {
"button": "[to be translated]:Select Git Bash Path",
"failed": "[to be translated]:Failed to set Git Bash path",
"invalidPath": "[to be translated]:Selected file is not a valid Git Bash executable (bash.exe).",
"title": "[to be translated]:Select Git Bash executable"
},
"success": "Git Bashが正常に検出されました"
},
"input": {
@ -471,6 +485,14 @@
"url_placeholder": "JSON URLを入力"
},
"manage": {
"batch_delete": {
"button": "バッチ削除",
"confirm": "選択した{{count}}件のアシスタントを削除してもよろしいですか?"
},
"mode": {
"delete": "削除",
"sort": "並べ替え"
},
"title": "アシスタントを管理"
},
"my_agents": "マイアシスタント",
@ -1185,6 +1207,7 @@
"saved": "保存されました",
"search": "検索",
"select": "選択",
"select_all": "すべて選択",
"selected": "選択済み",
"selectedItems": "{{count}}件の項目を選択しました",
"selectedMessages": "{{count}}件のメッセージを選択しました",

View File

@ -31,12 +31,26 @@
}
},
"gitBash": {
"autoDetected": "Usando Git Bash detectado automaticamente",
"clear": {
"button": "Limpar caminho personalizado"
},
"customPath": "Usando caminho personalizado: {{path}}",
"error": {
"description": "O Git Bash é necessário para executar agentes no Windows. O agente não pode funcionar sem ele. Por favor, instale o Git para Windows a partir de",
"recheck": "Reverificar a Instalação do Git Bash",
"title": "Git Bash Necessário"
},
"found": {
"title": "Git Bash configurado"
},
"notFound": "Git Bash não encontrado. Por favor, instale-o primeiro.",
"pick": {
"button": "Selecionar caminho do Git Bash",
"failed": "Falha ao configurar o caminho do Git Bash",
"invalidPath": "O arquivo selecionado não é um executável válido do Git Bash (bash.exe).",
"title": "Selecionar executável do Git Bash"
},
"success": "Git Bash detectado com sucesso!"
},
"input": {
@ -471,6 +485,14 @@
"url_placeholder": "Inserir URL JSON"
},
"manage": {
"batch_delete": {
"button": "Exclusão em Lote",
"confirm": "Tem certeza de que deseja excluir os {{count}} assistentes selecionados?"
},
"mode": {
"delete": "Excluir",
"sort": "Ordenar"
},
"title": "Gerir assistentes"
},
"my_agents": "Os meus assistentes",
@ -1185,6 +1207,7 @@
"saved": "Guardado",
"search": "Pesquisar",
"select": "Selecionar",
"select_all": "Selecionar Tudo",
"selected": "Selecionado",
"selectedItems": "{{count}} itens selecionados",
"selectedMessages": "{{count}} mensagens selecionadas",

View File

@ -31,12 +31,26 @@
}
},
"gitBash": {
"autoDetected": "Используется автоматически обнаруженный Git Bash",
"clear": {
"button": "Очистить пользовательский путь"
},
"customPath": "Используется пользовательский путь: {{path}}",
"error": {
"description": "Для запуска агентов в Windows требуется Git Bash. Без него агент не может работать. Пожалуйста, установите Git для Windows с",
"recheck": "Повторная проверка установки Git Bash",
"title": "Требуется Git Bash"
},
"found": {
"title": "Git Bash настроен"
},
"notFound": "Git Bash не найден. Пожалуйста, сначала установите его.",
"pick": {
"button": "Выбрать путь Git Bash",
"failed": "Не удалось настроить путь Git Bash",
"invalidPath": "Выбранный файл не является допустимым исполняемым файлом Git Bash (bash.exe).",
"title": "Выберите исполняемый файл Git Bash"
},
"success": "Git Bash успешно обнаружен!"
},
"input": {
@ -471,6 +485,14 @@
"url_placeholder": "Введите JSON URL"
},
"manage": {
"batch_delete": {
"button": "Массовое удаление",
"confirm": "Вы уверены, что хотите удалить выбранных {{count}} ассистентов?"
},
"mode": {
"delete": "Удалить",
"sort": "Сортировать"
},
"title": "Управление помощниками"
},
"my_agents": "Мои помощники",
@ -1185,6 +1207,7 @@
"saved": "Сохранено",
"search": "Поиск",
"select": "Выбрать",
"select_all": "Выбрать все",
"selected": "Выбрано",
"selectedItems": "Выбрано {{count}} элементов",
"selectedMessages": "Выбрано {{count}} сообщений",

View File

@ -19,6 +19,7 @@ import styled from 'styled-components'
import AssistantsDrawer from './components/AssistantsDrawer'
import ChatNavbarContent from './components/ChatNavbarContent'
import SettingsButton from './components/SettingsButton'
import UpdateAppButton from './components/UpdateAppButton'
interface Props {
@ -65,14 +66,6 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
})
}
// const handleUpdateModel = useCallback(
// async (model: ApiModel) => {
// if (!activeSession || !activeAgent) return
// return updateModel(activeSession.id, model.id, { showSuccessToast: false })
// },
// [activeAgent, activeSession, updateModel]
// )
return (
<NavbarHeader className="home-navbar" style={{ height: 'var(--navbar-height)' }}>
<div className="flex h-full min-w-0 flex-1 shrink items-center overflow-auto">
@ -107,6 +100,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
</div>
<HStack alignItems="center" gap={8}>
{isTopNavbar && <UpdateAppButton />}
<SettingsButton assistant={assistant} />
{isTopNavbar && (
<Tooltip title={t('navbar.expand')} mouseEnterDelay={0.8}>
<NarrowIcon onClick={handleNarrowModeToggle}>

View File

@ -429,7 +429,7 @@ const FileBlocksContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 15px;
padding: 0;
margin: 8px 0;
background: transparent;
border-radius: 4px;

View File

@ -83,11 +83,11 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message, topic, isGro
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [model?.provider, showMinappIcon])
const hideHeader = isBubbleStyle ? isUserMessage && !isMultiSelectMode : false
if (hideHeader) {
return null
}
const userNameJustifyContent = useMemo(() => {
if (!isBubbleStyle) return 'flex-start'
if (isUserMessage && !isMultiSelectMode) return 'flex-end'
return 'flex-start'
}, [isBubbleStyle, isUserMessage, isMultiSelectMode])
return (
<Container className="message-header">
@ -121,7 +121,7 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message, topic, isGro
</>
)}
<UserWrap>
<HStack alignItems="center">
<HStack alignItems="center" justifyContent={userNameJustifyContent}>
<UserName isBubbleStyle={isBubbleStyle} theme={theme}>
{username}
</UserName>

View File

@ -63,7 +63,10 @@ const Prompt: FC<Props> = ({ assistant, topic }) => {
}
return (
<Container className="system-prompt" onClick={() => AssistantSettingsPopup.show({ assistant })} $isDark={isDark}>
<Container
className="system-prompt"
onClick={() => AssistantSettingsPopup.show({ assistant, tab: 'prompt' })}
$isDark={isDark}>
<Text $isVisible={isVisible}>{displayText}</Text>
</Container>
)

View File

@ -1,45 +0,0 @@
import type { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { SettingDivider } from '@renderer/pages/settings'
import { SessionSettingsPopup } from '@renderer/pages/settings/AgentSettings'
import AdvancedSettings from '@renderer/pages/settings/AgentSettings/AdvancedSettings'
import EssentialSettings from '@renderer/pages/settings/AgentSettings/EssentialSettings'
import type { GetAgentSessionResponse } from '@renderer/types'
import { Button } from 'antd'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
session: GetAgentSessionResponse | undefined | null
update: ReturnType<typeof useUpdateSession>['updateSession']
}
const SessionSettingsTab: FC<Props> = ({ session, update }) => {
const { t } = useTranslation()
const onMoreSetting = () => {
if (session?.id) {
SessionSettingsPopup.show({
agentId: session.agent_id,
sessionId: session.id
})
}
}
if (!session) {
return null
}
return (
<div className="w-[var(--assistants-width)] p-2 px-3 pt-4">
<EssentialSettings agentBase={session} update={update} showModelSetting={false} />
<SettingDivider />
<AdvancedSettings agentBase={session} update={update} />
<SettingDivider />
<Button size="small" block onClick={onMoreSetting}>
{t('settings.moresetting.label')}
</Button>
</div>
)
}
export default SessionSettingsTab

View File

@ -1,24 +1,17 @@
import EditableNumber from '@renderer/components/EditableNumber'
import { HStack } from '@renderer/components/Layout'
import Scrollbar from '@renderer/components/Scrollbar'
import Selector from '@renderer/components/Selector'
import { HelpTooltip } from '@renderer/components/TooltipIcons'
import {
DEFAULT_CONTEXTCOUNT,
DEFAULT_MAX_TOKENS,
DEFAULT_TEMPERATURE,
MAX_CONTEXT_COUNT
} from '@renderer/config/constant'
import { isOpenAIModel, isSupportVerbosityModel } from '@renderer/config/models'
import { UNKNOWN } from '@renderer/config/translate'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useProvider } from '@renderer/hooks/useProvider'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import useTranslate from '@renderer/hooks/useTranslate'
import { SettingDivider, SettingRow, SettingRowTitle } from '@renderer/pages/settings'
import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
import { CollapsibleSettingGroup } from '@renderer/pages/settings/SettingGroup'
import { getDefaultModel } from '@renderer/services/AssistantService'
import { useAppDispatch } from '@renderer/store'
@ -52,19 +45,17 @@ import {
setShowTranslateConfirm,
setThoughtAutoCollapse
} from '@renderer/store/settings'
import type { Assistant, AssistantSettings, CodeStyleVarious, MathEngine } from '@renderer/types'
import type { Assistant, CodeStyleVarious, MathEngine } from '@renderer/types'
import { isGroqSystemProvider, ThemeMode } from '@renderer/types'
import { modalConfirm } from '@renderer/utils'
import { getSendMessageShortcutLabel } from '@renderer/utils/input'
import {
isOpenAICompatibleProvider,
isSupportServiceTierProvider,
isSupportVerbosityProvider
} from '@renderer/utils/provider'
import { Button, Col, InputNumber, Row, Slider, Switch } from 'antd'
import { Settings2 } from 'lucide-react'
import { Col, Row, Slider, Switch } from 'antd'
import type { FC } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -76,20 +67,15 @@ interface Props {
}
const SettingsTab: FC<Props> = (props) => {
const { assistant, updateAssistantSettings } = useAssistant(props.assistant.id)
const { chat } = useRuntime()
const { assistant } = useAssistant(props.assistant.id)
const { provider } = useProvider(assistant.model.provider)
const { messageStyle, fontSize, language } = useSettings()
const { theme } = useTheme()
const { themeNames } = useCodeStyle()
const [temperature, setTemperature] = useState(assistant?.settings?.temperature ?? DEFAULT_TEMPERATURE)
const [enableTemperature, setEnableTemperature] = useState(assistant?.settings?.enableTemperature ?? true)
const [contextCount, setContextCount] = useState(assistant?.settings?.contextCount ?? DEFAULT_CONTEXTCOUNT)
const [enableMaxTokens, setEnableMaxTokens] = useState(assistant?.settings?.enableMaxTokens ?? false)
const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0)
const [fontSizeValue, setFontSizeValue] = useState(fontSize)
const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput)
const { translateLanguages } = useTranslate()
const { t } = useTranslation()
@ -128,28 +114,6 @@ const SettingsTab: FC<Props> = (props) => {
confirmRegenerateMessage
} = useSettings()
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {
updateAssistantSettings(settings)
}
const onTemperatureChange = (value) => {
if (!isNaN(value as number)) {
onUpdateAssistantSettings({ temperature: value })
}
}
const onContextCountChange = (value) => {
if (!isNaN(value as number)) {
onUpdateAssistantSettings({ contextCount: value })
}
}
const onMaxTokensChange = (value) => {
if (!isNaN(value as number)) {
onUpdateAssistantSettings({ maxTokens: value })
}
}
const codeStyle = useMemo(() => {
return codeEditor.enabled
? theme === ThemeMode.light
@ -176,15 +140,6 @@ const SettingsTab: FC<Props> = (props) => {
[dispatch, theme, codeEditor.enabled]
)
useEffect(() => {
setTemperature(assistant?.settings?.temperature ?? DEFAULT_TEMPERATURE)
setEnableTemperature(assistant?.settings?.enableTemperature ?? true)
setContextCount(assistant?.settings?.contextCount ?? DEFAULT_CONTEXTCOUNT)
setEnableMaxTokens(assistant?.settings?.enableMaxTokens ?? false)
setMaxTokens(assistant?.settings?.maxTokens ?? DEFAULT_MAX_TOKENS)
setStreamOutput(assistant?.settings?.streamOutput ?? true)
}, [assistant])
const model = assistant.model || getDefaultModel()
const showOpenAiSettings =
@ -193,173 +148,36 @@ const SettingsTab: FC<Props> = (props) => {
isSupportServiceTierProvider(provider) ||
(isSupportVerbosityModel(model) && isSupportVerbosityProvider(provider))
const isTopicSettings = chat.activeTopicOrSession === 'topic'
return (
<Container className="settings-tab">
{props.assistant.id !== 'fake' && (
<CollapsibleSettingGroup
title={t('assistants.settings.title')}
defaultExpanded={true}
extra={
<HStack alignItems="center" gap={2}>
<Button
type="text"
size="small"
icon={<Settings2 size={16} />}
onClick={() => AssistantSettingsPopup.show({ assistant, tab: 'model' })}
/>
</HStack>
}>
<SettingGroup style={{ marginTop: 5 }}>
<Row align="middle">
<SettingRowTitleSmall>
{t('chat.settings.temperature.label')}
<HelpTooltip title={t('chat.settings.temperature.tip')} />
</SettingRowTitleSmall>
<Switch
size="small"
style={{ marginLeft: 'auto' }}
checked={enableTemperature}
onChange={(enabled) => {
setEnableTemperature(enabled)
onUpdateAssistantSettings({ enableTemperature: enabled })
}}
/>
</Row>
{enableTemperature ? (
<Row align="middle" gutter={10}>
<Col span={23}>
<Slider
min={0}
max={2}
onChange={setTemperature}
onChangeComplete={onTemperatureChange}
value={typeof temperature === 'number' ? temperature : 0}
step={0.1}
/>
</Col>
</Row>
) : (
<SettingDivider />
)}
<Row align="middle" gutter={10} justify="space-between">
<SettingRowTitleSmall>
{t('chat.settings.context_count.label')}
<HelpTooltip title={t('chat.settings.context_count.tip')} />
</SettingRowTitleSmall>
<Col span={8}>
<EditableNumber
min={0}
max={20}
step={1}
value={contextCount}
changeOnBlur
onChange={(value) => {
if (value !== null && value >= 0 && value <= 20) {
setContextCount(value)
onContextCountChange(value)
}
}}
formatter={(value) => (value === MAX_CONTEXT_COUNT ? t('chat.settings.max') : (value ?? ''))}
style={{ width: '100%' }}
/>
</Col>
</Row>
<Row align="middle" gutter={10}>
<Col span={24}>
<Slider
min={0}
max={20}
onChange={setContextCount}
onChangeComplete={onContextCountChange}
value={Math.min(contextCount, 20)}
tooltip={{ open: false }}
step={1}
marks={{
0: '0',
10: '10',
20: '20'
}}
/>
</Col>
</Row>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('models.stream_output')}</SettingRowTitleSmall>
<Switch
size="small"
checked={streamOutput}
onChange={(checked) => {
setStreamOutput(checked)
onUpdateAssistantSettings({ streamOutput: checked })
}}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<Row align="middle">
<SettingRowTitleSmall>
{t('chat.settings.max_tokens.label')}
<HelpTooltip title={t('chat.settings.max_tokens.tip')} />
</SettingRowTitleSmall>
</Row>
<Switch
size="small"
checked={enableMaxTokens}
onChange={async (enabled) => {
if (enabled) {
const confirmed = await modalConfirm({
title: t('chat.settings.max_tokens.confirm'),
content: t('chat.settings.max_tokens.confirm_content'),
okButtonProps: {
danger: true
}
})
if (!confirmed) return
}
setEnableMaxTokens(enabled)
onUpdateAssistantSettings({ enableMaxTokens: enabled })
}}
/>
</SettingRow>
{enableMaxTokens && (
<Row align="middle" gutter={10} style={{ marginTop: 10 }}>
<Col span={24}>
<InputNumber
disabled={!enableMaxTokens}
min={0}
max={10000000}
step={100}
value={typeof maxTokens === 'number' ? maxTokens : 0}
changeOnBlur
onChange={(value) => value && setMaxTokens(value)}
onBlur={() => onMaxTokensChange(maxTokens)}
style={{ width: '100%' }}
/>
</Col>
</Row>
)}
<SettingDivider />
</SettingGroup>
</CollapsibleSettingGroup>
)}
{showOpenAiSettings && (
<OpenAISettingsGroup
model={model}
providerId={provider.id}
SettingGroup={SettingGroup}
SettingRowTitleSmall={SettingRowTitleSmall}
/>
)}
{isGroqSystemProvider(provider) && (
<GroqSettingsGroup SettingGroup={SettingGroup} SettingRowTitleSmall={SettingRowTitleSmall} />
{isTopicSettings && (
<>
{showOpenAiSettings && (
<OpenAISettingsGroup
model={model}
providerId={provider.id}
SettingGroup={SettingGroup}
SettingRowTitleSmall={SettingRowTitleSmall}
/>
)}
{isGroqSystemProvider(provider) && (
<GroqSettingsGroup SettingGroup={SettingGroup} SettingRowTitleSmall={SettingRowTitleSmall} />
)}
</>
)}
<CollapsibleSettingGroup title={t('settings.messages.title')} defaultExpanded={true}>
<SettingGroup>
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.prompt')}</SettingRowTitleSmall>
<Switch size="small" checked={showPrompt} onChange={(checked) => dispatch(setShowPrompt(checked))} />
</SettingRow>
<SettingDivider />
{isTopicSettings && (
<>
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.prompt')}</SettingRowTitleSmall>
<Switch size="small" checked={showPrompt} onChange={(checked) => dispatch(setShowPrompt(checked))} />
</SettingRow>
<SettingDivider />
</>
)}
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.use_serif_font')}</SettingRowTitleSmall>
<Switch
@ -381,15 +199,19 @@ const SettingsTab: FC<Props> = (props) => {
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.show_message_outline')}</SettingRowTitleSmall>
<Switch
size="small"
checked={showMessageOutline}
onChange={(checked) => dispatch(setShowMessageOutline(checked))}
/>
</SettingRow>
<SettingDivider />
{isTopicSettings && (
<>
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.show_message_outline')}</SettingRowTitleSmall>
<Switch
size="small"
checked={showMessageOutline}
onChange={(checked) => dispatch(setShowMessageOutline(checked))}
/>
</SettingRow>
<SettingDivider />
</>
)}
<SettingRow>
<SettingRowTitleSmall>{t('message.message.style.label')}</SettingRowTitleSmall>
<Selector
@ -402,20 +224,24 @@ const SettingsTab: FC<Props> = (props) => {
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('message.message.multi_model_style.label')}</SettingRowTitleSmall>
<Selector
value={multiModelMessageStyle}
onChange={(value) => dispatch(setMultiModelMessageStyle(value))}
options={[
{ value: 'fold', label: t('message.message.multi_model_style.fold.label') },
{ value: 'vertical', label: t('message.message.multi_model_style.vertical') },
{ value: 'horizontal', label: t('message.message.multi_model_style.horizontal') },
{ value: 'grid', label: t('message.message.multi_model_style.grid') }
]}
/>
</SettingRow>
<SettingDivider />
{isTopicSettings && (
<>
<SettingRow>
<SettingRowTitleSmall>{t('message.message.multi_model_style.label')}</SettingRowTitleSmall>
<Selector
value={multiModelMessageStyle}
onChange={(value) => dispatch(setMultiModelMessageStyle(value))}
options={[
{ value: 'fold', label: t('message.message.multi_model_style.fold.label') },
{ value: 'vertical', label: t('message.message.multi_model_style.vertical') },
{ value: 'horizontal', label: t('message.message.multi_model_style.horizontal') },
{ value: 'grid', label: t('message.message.multi_model_style.grid') }
]}
/>
</SettingRow>
<SettingDivider />
</>
)}
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.navigation.label')}</SettingRowTitleSmall>
<Selector
@ -627,15 +453,19 @@ const SettingsTab: FC<Props> = (props) => {
</CollapsibleSettingGroup>
<CollapsibleSettingGroup title={t('settings.messages.input.title')} defaultExpanded={false}>
<SettingGroup>
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.input.show_estimated_tokens')}</SettingRowTitleSmall>
<Switch
size="small"
checked={showInputEstimatedTokens}
onChange={(checked) => dispatch(setShowInputEstimatedTokens(checked))}
/>
</SettingRow>
<SettingDivider />
{isTopicSettings && (
<>
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.input.show_estimated_tokens')}</SettingRowTitleSmall>
<Switch
size="small"
checked={showInputEstimatedTokens}
onChange={(checked) => dispatch(setShowInputEstimatedTokens(checked))}
/>
</SettingRow>
<SettingDivider />
</>
)}
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.input.paste_long_text_as_file')}</SettingRowTitleSmall>
<Switch

View File

@ -1,5 +1,4 @@
import { DeleteIcon, EditIcon } from '@renderer/components/Icons'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useSettings } from '@renderer/hooks/useSettings'
import AgentSettingsPopup from '@renderer/pages/settings/AgentSettings/AgentSettingsPopup'
import { AgentLabel } from '@renderer/pages/settings/AgentSettings/shared'
@ -8,7 +7,7 @@ import type { AgentEntity } from '@renderer/types'
import { cn } from '@renderer/utils'
import type { MenuProps } from 'antd'
import { Dropdown, Tooltip } from 'antd'
import { Bot } from 'lucide-react'
import { Bot, MoreVertical } from 'lucide-react'
import type { FC } from 'react'
import { memo, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -24,8 +23,7 @@ interface AgentItemProps {
const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete, onPress }) => {
const { t } = useTranslation()
const { sessions } = useSessions(agent.id)
const { clickAssistantToShowTopic, topicPosition } = useSettings()
const { clickAssistantToShowTopic, topicPosition, assistantIconType } = useSettings()
const handlePress = useCallback(() => {
// Show session sidebar if setting is enabled (reusing the assistant setting for consistency)
@ -37,6 +35,14 @@ const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete, onPress }) =
onPress()
}, [clickAssistantToShowTopic, topicPosition, onPress])
const handleMoreClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
agent.id && AgentSettingsPopup.show({ agentId: agent.id })
},
[agent.id]
)
const menuItems: MenuProps['items'] = useMemo(
() => [
{
@ -72,14 +78,14 @@ const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete, onPress }) =
<Container onClick={handlePress} isActive={isActive}>
<AssistantNameRow className="name" title={agent.name ?? agent.id}>
<AgentNameWrapper>
<AgentLabel agent={agent} />
<AgentLabel agent={agent} hideIcon={assistantIconType === 'none'} />
</AgentNameWrapper>
{isActive && (
<MenuButton>
<SessionCount>{sessions.length}</SessionCount>
<MenuButton onClick={handleMoreClick}>
<MoreVertical size={14} className="text-[var(--color-text-secondary)]" />
</MenuButton>
)}
{!isActive && <BotIcon />}
{!isActive && assistantIconType !== 'none' && <BotIcon />}
</AssistantNameRow>
</Container>
</Dropdown>
@ -116,7 +122,7 @@ export const AgentNameWrapper: React.FC<React.HTMLAttributes<HTMLDivElement>> =
export const MenuButton: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
className={cn(
'flex h-5 min-h-5 w-5 flex-row items-center justify-center rounded-full border border-[var(--color-border)] bg-[var(--color-background)]',
'flex h-[22px] min-h-[22px] min-w-[22px] flex-row items-center justify-center rounded-[11px] border-[0.5px] border-[var(--color-border)] bg-[var(--color-background)] px-[5px]',
className
)}
{...props}

View File

@ -22,6 +22,7 @@ import {
ArrowUpAZ,
BrushCleaning,
Check,
MoreVertical,
Plus,
Save,
Settings2,
@ -150,6 +151,14 @@ const AssistantItem: FC<AssistantItemProps> = ({
[assistant.emoji, assistantName]
)
const handleMoreClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
AssistantSettingsPopup.show({ assistant })
},
[assistant]
)
return (
<Dropdown
menu={{ items: menuItems }}
@ -174,8 +183,8 @@ const AssistantItem: FC<AssistantItemProps> = ({
<AssistantName className="text-nowrap">{assistantName}</AssistantName>
</AssistantNameRow>
{isActive && (
<MenuButton onClick={() => EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)}>
<TopicCount className="topics-count">{assistant.topics.length}</TopicCount>
<MenuButton onClick={handleMoreClick}>
<MoreVertical size={14} className="text-[var(--color-text-secondary)]" />
</MenuButton>
)}
</Container>
@ -447,19 +456,4 @@ const MenuButton = ({
</div>
)
const TopicCount = ({
children,
className,
...props
}: PropsWithChildren<{} & React.HTMLAttributes<HTMLDivElement>>) => (
<div
{...props}
className={cn(
'flex flex-row items-center justify-center rounded-[10px] text-[10px] text-[var(--color-text)]',
className
)}>
{children}
</div>
)
export default memo(AssistantItem)

View File

@ -232,12 +232,11 @@ const SessionListItem = styled.div`
}
&.singlealone {
border-radius: 0 !important;
&:hover {
background-color: var(--color-background-soft);
}
&.active {
border-left: 2px solid var(--color-primary);
background-color: var(--color-background-mute);
box-shadow: none;
}
}

View File

@ -19,8 +19,6 @@ import styled from 'styled-components'
import AddButton from './AddButton'
import SessionItem from './SessionItem'
// const logger = loggerService.withContext('SessionsTab')
interface SessionsProps {
agentId: string
}

View File

@ -499,7 +499,7 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
className="topics-tab"
list={sortedTopics}
onUpdate={updateTopics}
style={{ height: '100%', padding: '11px 0 10px 10px' }}
style={{ height: '100%', padding: '9px 0 10px 10px' }}
itemContainerStyle={{ paddingBottom: '8px' }}
header={
<>
@ -632,12 +632,11 @@ const TopicListItem = styled.div`
}
}
&.singlealone {
border-radius: 0 !important;
&:hover {
background-color: var(--color-background-soft);
}
&.active {
border-left: 2px solid var(--color-primary);
background-color: var(--color-background-mute);
box-shadow: none;
}
}

View File

@ -1,8 +1,5 @@
import AddAssistantPopup from '@renderer/components/Popups/AddAssistantPopup'
import { useActiveSession } from '@renderer/hooks/agents/useActiveSession'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
import { useShowTopics } from '@renderer/hooks/useStore'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
@ -10,16 +7,13 @@ import { useAppDispatch } from '@renderer/store'
import { setActiveAgentId, setActiveTopicOrSessionAction } from '@renderer/store/runtime'
import type { Assistant, Topic } from '@renderer/types'
import type { Tab } from '@renderer/types/chat'
import { classNames, getErrorMessage, uuid } from '@renderer/utils'
import { Alert, Skeleton } from 'antd'
import { classNames, uuid } from '@renderer/utils'
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import Assistants from './AssistantsTab'
import SessionSettingsTab from './SessionSettingsTab'
import Settings from './SettingsTab'
import Topics from './TopicsTab'
interface Props {
@ -49,15 +43,8 @@ const HomeTabs: FC<Props> = ({
const { toggleShowTopics } = useShowTopics()
const { isLeftNavbar } = useNavbarPosition()
const { t } = useTranslation()
const { chat } = useRuntime()
const { activeTopicOrSession, activeAgentId } = chat
const { session, isLoading: isSessionLoading, error: sessionError } = useActiveSession()
const { updateSession } = useUpdateSession(activeAgentId)
const dispatch = useAppDispatch()
const isSessionView = activeTopicOrSession === 'session'
const isTopicView = activeTopicOrSession === 'topic'
const [tab, setTab] = useState<Tab>(position === 'left' ? _tab || 'assistants' : 'topic')
const borderStyle = '0.5px solid var(--color-border)'
const border =
@ -96,9 +83,6 @@ const HomeTabs: FC<Props> = ({
EventEmitter.on(EVENT_NAMES.SHOW_TOPIC_SIDEBAR, (): any => {
showTab && setTab('topic')
}),
EventEmitter.on(EVENT_NAMES.SHOW_CHAT_SETTINGS, (): any => {
showTab && setTab('settings')
}),
EventEmitter.on(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR, () => {
showTab && setTab('topic')
if (position === 'left' && topicPosition === 'right') {
@ -113,7 +97,7 @@ const HomeTabs: FC<Props> = ({
if (position === 'right' && topicPosition === 'right' && tab === 'assistants') {
setTab('topic')
}
if (position === 'left' && topicPosition === 'right' && (tab === 'topic' || tab === 'settings')) {
if (position === 'left' && topicPosition === 'right' && tab === 'topic') {
setTab('assistants')
}
}, [position, tab, topicPosition, forceToSeeAllTab])
@ -130,20 +114,6 @@ const HomeTabs: FC<Props> = ({
<TabItem active={tab === 'topic'} onClick={() => setTab('topic')}>
{t('common.topics')}
</TabItem>
<TabItem active={tab === 'settings'} onClick={() => setTab('settings')}>
{t('settings.title')}
</TabItem>
</CustomTabs>
)}
{position === 'right' && topicPosition === 'right' && (
<CustomTabs>
<TabItem active={tab === 'topic'} onClick={() => setTab('topic')}>
{t('common.topics')}
</TabItem>
<TabItem active={tab === 'settings'} onClick={() => setTab('settings')}>
{t('settings.title')}
</TabItem>
</CustomTabs>
)}
@ -164,22 +134,6 @@ const HomeTabs: FC<Props> = ({
position={position}
/>
)}
{tab === 'settings' && isTopicView && <Settings assistant={activeAssistant} />}
{tab === 'settings' && isSessionView && !sessionError && (
<Skeleton loading={isSessionLoading} active style={{ height: '100%', padding: '16px' }}>
<SessionSettingsTab session={session} update={updateSession} />
</Skeleton>
)}
{tab === 'settings' && isSessionView && sessionError && (
<div className="w-[var(--assistants-width)] p-2 px-3 pt-4">
<Alert
type="error"
message={t('agent.session.get.error.failed')}
description={getErrorMessage(sessionError)}
style={{ padding: '10px 15px' }}
/>
</div>
)}
</TabContent>
</Container>
)

View File

@ -1,14 +1,17 @@
import EmojiIcon from '@renderer/components/EmojiIcon'
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
import { useActiveAgent } from '@renderer/hooks/agents/useActiveAgent'
import { useActiveSession } from '@renderer/hooks/agents/useActiveSession'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { useRuntime } from '@renderer/hooks/useRuntime'
import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
import type { AgentEntity, AgentSessionEntity, ApiModel, Assistant } from '@renderer/types'
import { getLeadingEmoji } from '@renderer/utils'
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { t } from 'i18next'
import { ChevronRight, Folder } from 'lucide-react'
import type { FC, ReactNode } from 'react'
import { useCallback } from 'react'
import { useCallback, useMemo } from 'react'
import { twMerge } from 'tailwind-merge'
import { AgentSettingsPopup, SessionSettingsPopup } from '../../settings/AgentSettings'
@ -29,6 +32,8 @@ const ChatNavbarContent: FC<Props> = ({ assistant }) => {
const { session: activeSession } = useActiveSession()
const { updateModel } = useUpdateSession(activeAgent?.id ?? null)
const assistantName = useMemo(() => assistant.name || t('chat.default.name'), [assistant.name])
const handleUpdateModel = useCallback(
async (model: ApiModel) => {
if (!activeAgent || !activeSession) return
@ -39,7 +44,25 @@ const ChatNavbarContent: FC<Props> = ({ assistant }) => {
return (
<>
{activeTopicOrSession === 'topic' && <SelectModelButton assistant={assistant} />}
{activeTopicOrSession === 'topic' && (
<HorizontalScrollContainer className="ml-2 flex-initial">
<div className="flex flex-nowrap items-center gap-2">
{/* Assistant Label */}
<div
className="flex h-full cursor-pointer items-center gap-1.5"
onClick={() => AssistantSettingsPopup.show({ assistant })}>
<EmojiIcon emoji={assistant.emoji || getLeadingEmoji(assistantName)} size={24} />
<span className="max-w-40 truncate text-xs">{assistantName}</span>
</div>
{/* Separator */}
<ChevronRight className="h-4 w-4 text-gray-400" />
{/* Model Button */}
<SelectModelButton assistant={assistant} />
</div>
</HorizontalScrollContainer>
)}
{activeTopicOrSession === 'session' && activeAgent && (
<HorizontalScrollContainer className="ml-2 flex-initial">
<div className="flex flex-nowrap items-center gap-2">

View File

@ -0,0 +1,38 @@
import type { Assistant } from '@renderer/types'
import { Drawer, Tooltip } from 'antd'
import { t } from 'i18next'
import { Settings2 } from 'lucide-react'
import type { FC } from 'react'
import { useState } from 'react'
import { NavbarIcon } from '../ChatNavbar'
import HomeSettings from '../Tabs/SettingsTab'
interface Props {
assistant: Assistant
}
const SettingsButton: FC<Props> = ({ assistant }) => {
const [settingsOpen, setSettingsOpen] = useState(false)
return (
<>
<Tooltip title={t('settings.title')} mouseEnterDelay={0.8}>
<NavbarIcon onClick={() => setSettingsOpen(true)}>
<Settings2 size={18} />
</NavbarIcon>
</Tooltip>
<Drawer
placement="right"
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
width="var(--assistants-width)"
closable={false}
styles={{ body: { padding: 0, paddingTop: 'var(--navbar-height)' } }}>
<HomeSettings assistant={assistant} />
</Drawer>
</>
)
}
export default SettingsButton

View File

@ -119,7 +119,7 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
onOk={onOk}
onCancel={onCancel}
afterClose={afterClose}
maskClosable={false}
maskClosable={menu !== 'prompt'}
footer={null}
title={<AgentLabel agent={agent} />}
transitionName="animation-move-down"

View File

@ -29,14 +29,15 @@ export type AgentLabelProps = {
avatar?: string
name?: string
}
hideIcon?: boolean
}
export const AgentLabel: React.FC<AgentLabelProps> = ({ agent, classNames }) => {
export const AgentLabel: React.FC<AgentLabelProps> = ({ agent, classNames, hideIcon }) => {
const emoji = agent?.configuration?.avatar
return (
<div className={cn('flex w-full items-center gap-2 truncate', classNames?.container)}>
<EmojiIcon emoji={emoji || '⭐️'} className={classNames?.avatar} />
{!hideIcon && <EmojiIcon emoji={emoji || '⭐️'} className={classNames?.avatar} size={24} />}
<span className={cn('truncate', 'text-[var(--color-text)]', classNames?.name)}>
{agent?.name ?? (agent?.type ? getAgentTypeLabel(agent.type) : '')}
</span>

View File

@ -37,7 +37,7 @@ interface Props extends AssistantSettingPopupShowParams {
const AssistantSettingPopupContainer: React.FC<Props> = ({ resolve, tab, ...props }) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const [menu, setMenu] = useState<AssistantSettingPopupTab>(tab || 'prompt')
const [menu, setMenu] = useState<AssistantSettingPopupTab>(tab || 'model')
const _useAssistant = useAssistant(props.assistant.id)
const _useAgent = useAssistantPreset(props.assistant.id)
@ -64,14 +64,14 @@ const AssistantSettingPopupContainer: React.FC<Props> = ({ resolve, tab, ...prop
}
const items = [
{
key: 'prompt',
label: t('assistants.settings.prompt')
},
{
key: 'model',
label: t('assistants.settings.model')
},
{
key: 'prompt',
label: t('assistants.settings.prompt')
},
showKnowledgeIcon && {
key: 'knowledge_base',
label: t('assistants.settings.knowledge_base.label')
@ -96,7 +96,7 @@ const AssistantSettingPopupContainer: React.FC<Props> = ({ resolve, tab, ...prop
onOk={onOk}
onCancel={onCancel}
afterClose={afterClose}
maskClosable={false}
maskClosable={menu !== 'prompt'}
footer={null}
title={assistant.name}
transitionName="animation-move-down"
@ -116,22 +116,22 @@ const AssistantSettingPopupContainer: React.FC<Props> = ({ resolve, tab, ...prop
<HStack>
<LeftMenu>
<StyledMenu
defaultSelectedKeys={[tab || 'prompt']}
defaultSelectedKeys={[tab || 'model']}
mode="vertical"
items={items}
onSelect={({ key }) => setMenu(key as AssistantSettingPopupTab)}
/>
</LeftMenu>
<Settings>
{menu === 'prompt' && (
<AssistantPromptSettings
{menu === 'model' && (
<AssistantModelSettings
assistant={assistant}
updateAssistant={updateAssistant}
updateAssistantSettings={updateAssistantSettings}
/>
)}
{menu === 'model' && (
<AssistantModelSettings
{menu === 'prompt' && (
<AssistantPromptSettings
assistant={assistant}
updateAssistant={updateAssistant}
updateAssistantSettings={updateAssistantSettings}

View File

@ -51,7 +51,7 @@ const CherryINSettings: FC<CherryINSettingsProps> = ({ providerId, apiHost, setA
value: option.value,
label: (
<div className="flex flex-col gap-0.5">
<span>{t(option.labelKey)}</span>
<span>{option.labelKey}</span>
<span className="text-[var(--color-text-3)] text-xs">{t(option.description)}</span>
</div>
)

View File

@ -9,8 +9,8 @@ import { DEFAULT_SEARCH_ENGINES } from '../components/SelectionActionSearchModal
const logger = loggerService.withContext('useSettingsActionsList')
const MAX_CUSTOM_ITEMS = 8
const MAX_ENABLED_ITEMS = 6
const MAX_CUSTOM_ITEMS = 10
const MAX_ENABLED_ITEMS = 8
export const useActionItems = (
initialItems: ActionItem[] | undefined,

View File

@ -11,7 +11,7 @@ import type { AssistantPreset } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { Button, Empty, Flex, Input } from 'antd'
import { omit } from 'lodash'
import { Import, Plus, Rss, Search } from 'lucide-react'
import { Import, Plus, Rss, Search, Settings2 } from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -25,6 +25,7 @@ import AssistantPresetCard from './components/AssistantPresetCard'
import { AssistantPresetGroupIcon } from './components/AssistantPresetGroupIcon'
import AssistantsSubscribeUrlSettings from './components/AssistantsSubscribeUrlSettings'
import ImportAssistantPresetPopup from './components/ImportAssistantPresetPopup'
import ManageAssistantPresetsPopup from './components/ManageAssistantPresetsPopup'
const AssistantPresetsPage: FC = () => {
const [search, setSearch] = useState('')
@ -185,6 +186,10 @@ const AssistantPresetsPage: FC = () => {
})
}
const handleManageAgents = () => {
ManageAssistantPresetsPopup.show()
}
return (
<Container>
<Navbar>
@ -290,6 +295,9 @@ const AssistantPresetsPage: FC = () => {
<Button type="text" onClick={handleSubscribeSettings} icon={<Rss size={18} color="var(--color-icon)" />}>
{t('assistants.presets.settings.title')}
</Button>
<Button type="text" onClick={handleManageAgents} icon={<Settings2 size={18} color="var(--color-icon)" />}>
{t('assistants.presets.manage.title')}
</Button>
<Button type="text" onClick={handleAddAgent} icon={<Plus size={18} color="var(--color-icon)" />}>
{t('assistants.presets.add.title')}
</Button>

View File

@ -7,7 +7,8 @@ import type { AssistantPreset } from '@renderer/types'
import { getLeadingEmoji } from '@renderer/utils'
import { Button, Dropdown } from 'antd'
import { t } from 'i18next'
import { ArrowDownAZ, Ellipsis, PlusIcon, SquareArrowOutUpRight } from 'lucide-react'
import { isArray } from 'lodash'
import { Ellipsis, PlusIcon, Settings2, SquareArrowOutUpRight } from 'lucide-react'
import { type FC, memo, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
@ -76,9 +77,9 @@ const AssistantPresetCard: FC<Props> = ({ preset, onClick, activegroup, getLocal
}
},
{
key: 'sort',
label: t('assistants.presets.sorting.title'),
icon: <ArrowDownAZ size={14} />,
key: 'manage',
label: t('assistants.presets.manage.title'),
icon: <Settings2 size={14} />,
onClick: (e: any) => {
e.domEvent.stopPropagation()
ManageAssistantPresetsPopup.show()
@ -142,7 +143,7 @@ const AssistantPresetCard: FC<Props> = ({ preset, onClick, activegroup, getLocal
{getLocalizedGroupName('我的')}
</CustomTag>
)}
{!!preset.group?.length &&
{isArray(preset.group) &&
preset.group.map((group) => (
<CustomTag key={group} color="#A0A0A0" size={11}>
{getLocalizedGroupName(group)}

View File

@ -1,21 +1,23 @@
import { MenuOutlined } from '@ant-design/icons'
import { DraggableList } from '@renderer/components/DraggableList'
import { DeleteIcon } from '@renderer/components/Icons'
import { Box, HStack } from '@renderer/components/Layout'
import { TopView } from '@renderer/components/TopView'
import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets'
import { Empty, Modal } from 'antd'
import { useEffect, useState } from 'react'
import type { AssistantPreset } from '@renderer/types'
import { Button, Checkbox, Empty, Modal, Segmented } from 'antd'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
type Mode = 'sort' | 'delete'
const PopupContainer: React.FC = () => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const { presets, setAssistantPresets } = useAssistantPresets()
const onOk = () => {
setOpen(false)
}
const [mode, setMode] = useState<Mode>(() => (presets.length > 50 ? 'delete' : 'sort'))
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const onCancel = () => {
setOpen(false)
@ -25,17 +27,74 @@ const PopupContainer: React.FC = () => {
ManageAssistantPresetsPopup.hide()
}
useEffect(() => {
if (presets.length === 0) {
setOpen(false)
const handleModeChange = (value: Mode) => {
setMode(value)
setSelectedIds(new Set())
}
const handleSelectAll = () => {
if (selectedIds.size === presets.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(presets.map((p) => p.id)))
}
}, [presets])
}
const handleSelectNext100 = () => {
// Find the last selected preset's index
let startIndex = 0
if (selectedIds.size > 0) {
for (let i = presets.length - 1; i >= 0; i--) {
if (selectedIds.has(presets[i].id)) {
startIndex = i + 1
break
}
}
}
// Select next 100 unselected presets starting from startIndex
const newSelected = new Set(selectedIds)
let count = 0
for (let i = startIndex; i < presets.length && count < 100; i++) {
if (!newSelected.has(presets[i].id)) {
newSelected.add(presets[i].id)
count++
}
}
setSelectedIds(newSelected)
}
const handleSelect = (preset: AssistantPreset) => {
const newSelected = new Set(selectedIds)
if (newSelected.has(preset.id)) {
newSelected.delete(preset.id)
} else {
newSelected.add(preset.id)
}
setSelectedIds(newSelected)
}
const handleBatchDelete = () => {
if (selectedIds.size === 0) return
window.modal.confirm({
centered: true,
content: t('assistants.presets.manage.batch_delete.confirm', { count: selectedIds.size }),
onOk: () => {
const remainingPresets = presets.filter((p) => !selectedIds.has(p.id))
setAssistantPresets(remainingPresets)
setSelectedIds(new Set())
}
})
}
const isAllSelected = presets.length > 0 && selectedIds.size === presets.length
const isIndeterminate = selectedIds.size > 0 && selectedIds.size < presets.length
return (
<Modal
title={t('assistants.presets.manage.title')}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
footer={null}
@ -43,18 +102,78 @@ const PopupContainer: React.FC = () => {
centered>
<Container>
{presets.length > 0 && (
<DraggableList list={presets} onUpdate={setAssistantPresets}>
{(item) => (
<AgentItem>
<Box mr={8}>
{item.emoji} {item.name}
</Box>
<HStack gap="15px">
<MenuOutlined style={{ cursor: 'move' }} />
<>
<ActionBar>
{mode === 'delete' ? (
<HStack alignItems="center">
<Checkbox checked={isAllSelected} indeterminate={isIndeterminate} onChange={handleSelectAll}>
{t('common.select_all')}
</Checkbox>
{presets.length > 100 && selectedIds.size < presets.length && (
<Button type="link" size="small" onClick={handleSelectNext100} style={{ padding: 0 }}>
+100
</Button>
)}
</HStack>
</AgentItem>
) : (
<div />
)}
<HStack gap="8px" alignItems="center">
{mode === 'delete' && (
<Button
danger
type="text"
icon={<DeleteIcon size={14} />}
disabled={selectedIds.size === 0}
onClick={handleBatchDelete}>
{t('assistants.presets.manage.batch_delete.button')} ({selectedIds.size})
</Button>
)}
<Segmented
size="small"
value={mode}
onChange={(value) => handleModeChange(value as Mode)}
options={[
{ label: t('assistants.presets.manage.mode.sort'), value: 'sort' },
{ label: t('assistants.presets.manage.mode.delete'), value: 'delete' }
]}
/>
</HStack>
</ActionBar>
{mode === 'sort' ? (
<AgentList>
<DraggableList list={presets} onUpdate={setAssistantPresets}>
{(item) => (
<AgentItem>
<Box mr={8}>
{item.emoji} {item.name}
</Box>
<HStack gap="15px">
<MenuOutlined style={{ cursor: 'move' }} />
</HStack>
</AgentItem>
)}
</DraggableList>
</AgentList>
) : (
<AgentList>
{presets.map((item) => (
<SelectableAgentItem
key={item.id}
onClick={() => handleSelect(item)}
$selected={selectedIds.has(item.id)}>
<HStack alignItems="center" gap="8px">
<Checkbox checked={selectedIds.has(item.id)} onChange={() => handleSelect(item)} />
<Box>
{item.emoji} {item.name}
</Box>
</HStack>
</SelectableAgentItem>
))}
</AgentList>
)}
</DraggableList>
</>
)}
{presets.length === 0 && <Empty description="" />}
</Container>
@ -65,6 +184,21 @@ const PopupContainer: React.FC = () => {
const Container = styled.div`
padding: 12px 0;
height: 50vh;
display: flex;
flex-direction: column;
`
const ActionBar = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px 12px;
border-bottom: 1px solid var(--color-border);
margin-bottom: 12px;
`
const AgentList = styled.div`
flex: 1;
overflow-y: auto;
&::-webkit-scrollbar {
display: none;
@ -90,6 +224,23 @@ const AgentItem = styled.div`
}
`
const SelectableAgentItem = styled.div<{ $selected: boolean }>`
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 8px;
border-radius: 8px;
user-select: none;
background-color: ${(props) => (props.$selected ? 'var(--color-primary-mute)' : 'var(--color-background-soft)')};
margin-bottom: 8px;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: ${(props) => (props.$selected ? 'var(--color-primary-mute)' : 'var(--color-background-mute)')};
}
`
export default class ManageAssistantPresetsPopup {
static topviewId = 0
static hide() {

View File

@ -4,8 +4,6 @@ export const EventEmitter = new Emittery()
export const EVENT_NAMES = {
SEND_MESSAGE: 'SEND_MESSAGE',
// APPEND_MESSAGE: 'APPEND_MESSAGE',
// RECEIVE_MESSAGE: 'RECEIVE_MESSAGE',
MESSAGE_COMPLETE: 'MESSAGE_COMPLETE',
AI_AUTO_RENAME: 'AI_AUTO_RENAME',
CLEAR_MESSAGES: 'CLEAR_MESSAGES',
@ -15,7 +13,6 @@ export const EVENT_NAMES = {
CHAT_COMPLETION_PAUSED: 'CHAT_COMPLETION_PAUSED',
ESTIMATED_TOKEN_COUNT: 'ESTIMATED_TOKEN_COUNT',
SHOW_ASSISTANTS: 'SHOW_ASSISTANTS',
SHOW_CHAT_SETTINGS: 'SHOW_CHAT_SETTINGS',
SHOW_TOPIC_SIDEBAR: 'SHOW_TOPIC_SIDEBAR',
SWITCH_TOPIC_SIDEBAR: 'SWITCH_TOPIC_SIDEBAR',
NEW_CONTEXT: 'NEW_CONTEXT',

View File

@ -4,6 +4,7 @@ import i18n from '@renderer/i18n'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { NotificationService } from '@renderer/services/NotificationService'
import { estimateMessagesUsage } from '@renderer/services/TokenService'
import { updateOneBlock } from '@renderer/store/messageBlock'
import { selectMessagesForTopic } from '@renderer/store/newMessage'
import { newMessagesActions } from '@renderer/store/newMessage'
import type { Assistant } from '@renderer/types'
@ -104,6 +105,25 @@ export const createBaseCallbacks = (deps: BaseCallbacksDependencies) => {
blockManager.smartBlockUpdate(possibleBlockId, changes, blockManager.lastBlockType!, true)
}
// Fix: 更新所有仍处于 STREAMING 状态的 blocks 为 PAUSED/ERROR
// 这修复了停止回复时思考计时器继续运行的问题
const currentMessage = getState().messages.entities[assistantMsgId]
if (currentMessage) {
const allBlockRefs = findAllBlocks(currentMessage)
const blockState = getState().messageBlocks
for (const blockRef of allBlockRefs) {
const block = blockState.entities[blockRef.id]
if (block && block.status === MessageBlockStatus.STREAMING && block.id !== possibleBlockId) {
dispatch(
updateOneBlock({
id: block.id,
changes: { status: isErrorTypeAbort ? MessageBlockStatus.PAUSED : MessageBlockStatus.ERROR }
})
)
}
}
}
const errorBlock = createErrorBlock(assistantMsgId, serializableError, { status: MessageBlockStatus.SUCCESS })
await blockManager.handleBlockTransition(errorBlock, MessageBlockType.ERROR)
const messageErrorUpdate = {

View File

@ -1,4 +1,4 @@
export type Tab = 'assistants' | 'topic' | 'settings'
export type Tab = 'assistants' | 'topic'
export type InputBarToolType =
| 'new_topic'

View File

@ -58,7 +58,8 @@ export const isSupportStreamOptionsProvider = (provider: Provider) => {
const NOT_SUPPORT_QWEN3_ENABLE_THINKING_PROVIDER = [
'ollama',
'lmstudio',
'nvidia'
'nvidia',
'gpustack'
] as const satisfies SystemProviderId[]
/**

View File

@ -202,6 +202,30 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => {
}
}, [customCss, demo])
/**
* Check if text is a valid URI or file path
*/
const isUriOrFilePath = (text: string): boolean => {
const trimmed = text.trim()
// Must not contain newlines or whitespace
if (/\s/.test(trimmed)) {
return false
}
// URI patterns: http://, https://, ftp://, file://, etc.
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) {
return true
}
// Windows absolute path: C:\, D:\, etc.
if (/^[a-zA-Z]:[/\\]/.test(trimmed)) {
return true
}
// Unix absolute path: /path/to/file
if (/^\/[^/]/.test(trimmed)) {
return true
}
return false
}
// copy selected text to clipboard
const handleCopy = useCallback(async () => {
if (selectedText.current) {
@ -219,6 +243,43 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => {
}
}, [setTimeoutTimer])
const handleSearch = useCallback((action: ActionItem) => {
if (!action.selectedText) return
const selectedText = action.selectedText.trim()
let actionString = ''
if (isUriOrFilePath(selectedText)) {
actionString = selectedText
} else {
if (!action.searchEngine) return
const customUrl = action.searchEngine.split('|')[1]
if (!customUrl) return
actionString = customUrl.replace('{{queryString}}', encodeURIComponent(selectedText))
}
window.api?.openWebsite(actionString)
window.api?.selection.hideToolbar()
}, [])
/**
* Quote the selected text to the inputbar of the main window
*/
const handleQuote = (action: ActionItem) => {
if (action.selectedText) {
window.api?.quoteToMainWindow(action.selectedText)
window.api?.selection.hideToolbar()
}
}
const handleDefaultAction = (action: ActionItem) => {
// [macOS] only macOS has the available isFullscreen mode
window.api?.selection.processAction(action, isFullScreen.current)
window.api?.selection.hideToolbar()
}
const handleAction = useCallback(
(action: ActionItem) => {
if (demo) return
@ -241,36 +302,9 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => {
break
}
},
[demo, handleCopy]
[demo, handleCopy, handleSearch]
)
const handleSearch = (action: ActionItem) => {
if (!action.searchEngine) return
const customUrl = action.searchEngine.split('|')[1]
if (!customUrl) return
const searchUrl = customUrl.replace('{{queryString}}', encodeURIComponent(action.selectedText || ''))
window.api?.openWebsite(searchUrl)
window.api?.selection.hideToolbar()
}
/**
* Quote the selected text to the inputbar of the main window
*/
const handleQuote = (action: ActionItem) => {
if (action.selectedText) {
window.api?.quoteToMainWindow(action.selectedText)
window.api?.selection.hideToolbar()
}
}
const handleDefaultAction = (action: ActionItem) => {
// [macOS] only macOS has the available isFullscreen mode
window.api?.selection.processAction(action, isFullScreen.current)
window.api?.selection.hideToolbar()
}
return (
<Container>
<LogoWrapper $draggable={!demo}>

View File

@ -10187,6 +10187,7 @@ __metadata:
clsx: "npm:^2.1.1"
code-inspector-plugin: "npm:^0.20.14"
color: "npm:^5.0.0"
concurrently: "npm:^9.2.1"
country-flag-emoji-polyfill: "npm:0.1.8"
dayjs: "npm:^1.11.11"
dexie: "npm:^4.0.8"
@ -11503,6 +11504,16 @@ __metadata:
languageName: node
linkType: hard
"chalk@npm:4.1.2, chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.1, chalk@npm:^4.1.2":
version: 4.1.2
resolution: "chalk@npm:4.1.2"
dependencies:
ansi-styles: "npm:^4.1.0"
supports-color: "npm:^7.1.0"
checksum: 10c0/4a3fef5cc34975c898ffe77141450f679721df9dde00f6c304353fa9c8b571929123b26a0e4617bde5018977eb655b31970c297b91b63ee83bb82aeb04666880
languageName: node
linkType: hard
"chalk@npm:^3.0.0":
version: 3.0.0
resolution: "chalk@npm:3.0.0"
@ -11513,16 +11524,6 @@ __metadata:
languageName: node
linkType: hard
"chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.1, chalk@npm:^4.1.2":
version: 4.1.2
resolution: "chalk@npm:4.1.2"
dependencies:
ansi-styles: "npm:^4.1.0"
supports-color: "npm:^7.1.0"
checksum: 10c0/4a3fef5cc34975c898ffe77141450f679721df9dde00f6c304353fa9c8b571929123b26a0e4617bde5018977eb655b31970c297b91b63ee83bb82aeb04666880
languageName: node
linkType: hard
"chalk@npm:^5.4.1":
version: 5.4.1
resolution: "chalk@npm:5.4.1"
@ -12145,6 +12146,23 @@ __metadata:
languageName: node
linkType: hard
"concurrently@npm:^9.2.1":
version: 9.2.1
resolution: "concurrently@npm:9.2.1"
dependencies:
chalk: "npm:4.1.2"
rxjs: "npm:7.8.2"
shell-quote: "npm:1.8.3"
supports-color: "npm:8.1.1"
tree-kill: "npm:1.2.2"
yargs: "npm:17.7.2"
bin:
conc: dist/bin/concurrently.js
concurrently: dist/bin/concurrently.js
checksum: 10c0/da37f239f82eb7ac24f5ddb56259861e5f1d6da2ade7602b6ea7ad3101b13b5ccec02a77b7001402d1028ff2fdc38eed55644b32853ad5abf30e057002a963aa
languageName: node
linkType: hard
"conf@npm:^10.2.0":
version: 10.2.0
resolution: "conf@npm:10.2.0"
@ -23008,6 +23026,15 @@ __metadata:
languageName: node
linkType: hard
"rxjs@npm:7.8.2":
version: 7.8.2
resolution: "rxjs@npm:7.8.2"
dependencies:
tslib: "npm:^2.1.0"
checksum: 10c0/1fcd33d2066ada98ba8f21fcbbcaee9f0b271de1d38dc7f4e256bfbc6ffcdde68c8bfb69093de7eeb46f24b1fb820620bf0223706cff26b4ab99a7ff7b2e2c45
languageName: node
linkType: hard
"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.1, safe-buffer@npm:~5.2.0":
version: 5.2.1
resolution: "safe-buffer@npm:5.2.1"
@ -23340,6 +23367,13 @@ __metadata:
languageName: node
linkType: hard
"shell-quote@npm:1.8.3":
version: 1.8.3
resolution: "shell-quote@npm:1.8.3"
checksum: 10c0/bee87c34e1e986cfb4c30846b8e6327d18874f10b535699866f368ade11ea4ee45433d97bf5eada22c4320c27df79c3a6a7eb1bf3ecfc47f2c997d9e5e2672fd
languageName: node
linkType: hard
"shiki@npm:3.12.0, shiki@npm:^3.12.0":
version: 3.12.0
resolution: "shiki@npm:3.12.0"
@ -24065,6 +24099,15 @@ __metadata:
languageName: node
linkType: hard
"supports-color@npm:8.1.1":
version: 8.1.1
resolution: "supports-color@npm:8.1.1"
dependencies:
has-flag: "npm:^4.0.0"
checksum: 10c0/ea1d3c275dd604c974670f63943ed9bd83623edc102430c05adb8efc56ba492746b6e95386e7831b872ec3807fd89dd8eb43f735195f37b5ec343e4234cc7e89
languageName: node
linkType: hard
"supports-color@npm:^7.1.0":
version: 7.2.0
resolution: "supports-color@npm:7.2.0"
@ -24602,7 +24645,7 @@ __metadata:
languageName: node
linkType: hard
"tree-kill@npm:^1.2.2":
"tree-kill@npm:1.2.2, tree-kill@npm:^1.2.2":
version: 1.2.2
resolution: "tree-kill@npm:1.2.2"
bin:
@ -26271,7 +26314,7 @@ __metadata:
languageName: node
linkType: hard
"yargs@npm:^17.0.1, yargs@npm:^17.5.1, yargs@npm:^17.6.2, yargs@npm:^17.7.2":
"yargs@npm:17.7.2, yargs@npm:^17.0.1, yargs@npm:^17.5.1, yargs@npm:^17.6.2, yargs@npm:^17.7.2":
version: 17.7.2
resolution: "yargs@npm:17.7.2"
dependencies: