mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 10:40:07 +08:00
Merge remote-tracking branch 'origin/main' into feat/access-key
This commit is contained in:
commit
e21c6ce2fc
293
.github/workflows/sync-to-gitcode.yml
vendored
Normal file
293
.github/workflows/sync-to-gitcode.yml
vendored
Normal 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/
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -31,7 +31,8 @@ export enum ConfigKeys {
|
||||
DisableHardwareAcceleration = 'disableHardwareAcceleration',
|
||||
Proxy = 'proxy',
|
||||
EnableDeveloperMode = 'enableDeveloperMode',
|
||||
ClientId = 'clientId'
|
||||
ClientId = 'clientId',
|
||||
GitBashPath = 'gitBashPath'
|
||||
}
|
||||
|
||||
export class ConfigManager {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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[] = []
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -754,7 +754,8 @@ describe('reasoning utils', () => {
|
||||
const result = getGeminiReasoningParams(assistant, model)
|
||||
expect(result).toEqual({
|
||||
thinkingConfig: {
|
||||
includeThoughts: true
|
||||
includeThoughts: true,
|
||||
thinkingBudget: -1
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -589,6 +589,7 @@ export function getGeminiReasoningParams(
|
||||
if (effortRatio > 1) {
|
||||
return {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: -1,
|
||||
includeThoughts: true
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}} 条消息",
|
||||
|
||||
@ -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}} 條訊息",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}} μηνύματα",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 l’installer d’abord.",
|
||||
"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",
|
||||
|
||||
@ -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}}件のメッセージを選択しました",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}} сообщений",
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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">
|
||||
|
||||
38
src/renderer/src/pages/home/components/SettingsButton.tsx
Normal file
38
src/renderer/src/pages/home/components/SettingsButton.tsx
Normal 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
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export type Tab = 'assistants' | 'topic' | 'settings'
|
||||
export type Tab = 'assistants' | 'topic'
|
||||
|
||||
export type InputBarToolType =
|
||||
| 'new_topic'
|
||||
|
||||
@ -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[]
|
||||
|
||||
/**
|
||||
|
||||
@ -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}>
|
||||
|
||||
67
yarn.lock
67
yarn.lock
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user