同步完整源码至远程:i18n 多语种 settings 文案 + AI 供应商设置与终端工作区进行中的修改(13 文件)
CI / Lint & TypeCheck (push) Canceled after 0s
CI / Test (push) Canceled after 0s
CI / Tauri Foundation Acceptance (push) Canceled after 0s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (push) Canceled after 0s
CI / Build (windows-latest) (push) Canceled after 0s
CI / Lint & TypeCheck (push) Canceled after 0s
CI / Test (push) Canceled after 0s
CI / Tauri Foundation Acceptance (push) Canceled after 0s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (push) Canceled after 0s
CI / Build (windows-latest) (push) Canceled after 0s
This commit is contained in:
1 parent
b43f1574a9
commit
fcd244637c
13 files changed
+350
-124
No files matched your search
@@ -474,7 +474,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
<>
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className="bg-dark-800/95 border-dark-700/40 relative flex h-full flex-col border-r"
|
||||
className="app-sidebar bg-dark-800/95 relative flex h-full flex-col"
|
||||
style={{ width: sidebarWidth }}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Check, CheckCircle2, ChevronDown, Plus, Save, Trash2, Wifi } from 'lucide-react'
|
||||
import { Check, CheckCircle2, ChevronDown, Cpu, Plus, Save, Trash2, Wifi } from 'lucide-react'
|
||||
import type { AiBillingMode, AiProviderConfig } from '../../../shared/types/ai.types'
|
||||
import {
|
||||
AI_PROVIDER_FAMILY_ORDER,
|
||||
@@ -23,7 +23,6 @@ function connectionSnapshot(provider: AiProviderConfig, apiKey: string): string
|
||||
const model = selectedModel(provider)
|
||||
return JSON.stringify({
|
||||
id: provider.id,
|
||||
name: provider.name.trim(),
|
||||
protocol: provider.protocol,
|
||||
authStyle: provider.authStyle,
|
||||
baseUrl: provider.baseUrl.trim(),
|
||||
@@ -77,8 +76,17 @@ export default function AiProviderSettings() {
|
||||
const currentSnapshot = useMemo(() => (draft ? connectionSnapshot(draft, apiKey) : ''), [apiKey, draft])
|
||||
const verified = !!currentSnapshot && verifiedSnapshot === currentSnapshot && testState === 'success'
|
||||
const currentModel = selectedModel(draft)
|
||||
const isStored = !!draft && store.providers.some((provider) => provider.id === draft.id)
|
||||
const canTest = validDraft(draft, apiKey) && testState !== 'testing'
|
||||
const storedProvider = store.providers.find((provider) => provider.id === draft?.id)
|
||||
const isStored = !!storedProvider
|
||||
const dirty =
|
||||
!!draft && (!storedProvider || JSON.stringify(draft) !== JSON.stringify(storedProvider) || !!apiKey.trim())
|
||||
const connectionChanged = !storedProvider || currentSnapshot !== connectionSnapshot(storedProvider, '')
|
||||
const needsTest = connectionChanged && !verified
|
||||
const busy = saving || testState === 'testing'
|
||||
const canTest = validDraft(draft, apiKey) && !busy
|
||||
const canSave = dirty && validDraft(draft, apiKey) && !needsTest && !busy
|
||||
const displayedMessage =
|
||||
verifiedSnapshot && verifiedSnapshot !== currentSnapshot ? (dirty && needsTest ? t('ai.retest') : '') : message
|
||||
const family = draft ? resolveProviderFamily(draft) : null
|
||||
const needsEndpoint = family === 'custom' || family === 'local' || draft?.presetId === 'vllm'
|
||||
|
||||
@@ -100,7 +108,6 @@ export default function AiProviderSettings() {
|
||||
const updateDraft = (changes: Partial<AiProviderConfig>) => {
|
||||
if (!draft) return
|
||||
setDraft({ ...draft, ...changes })
|
||||
if (testState !== 'idle') setMessage(t('ai.retest'))
|
||||
}
|
||||
|
||||
const chooseModel = (modelId: string) => {
|
||||
@@ -172,13 +179,16 @@ export default function AiProviderSettings() {
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!draft || !verified) return
|
||||
if (!draft || !canSave) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await store.saveProvider(draft, apiKey)
|
||||
if (!store.activeProviderId || !isStored) await store.setActiveProvider(draft.id)
|
||||
if (!store.activeProviderId) await store.setActiveProvider(draft.id)
|
||||
setApiKey('')
|
||||
setMessage(t('ai.saved'))
|
||||
setMessage('')
|
||||
setTestAnswer('')
|
||||
setVerifiedSnapshot(null)
|
||||
setTestState('idle')
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error))
|
||||
setTestState('error')
|
||||
@@ -199,38 +209,41 @@ export default function AiProviderSettings() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
aria-label={t('ai.presets')}
|
||||
value={presetId}
|
||||
onChange={(event) => setPresetId(event.target.value)}
|
||||
className="ui-select-pill"
|
||||
>
|
||||
{AI_PROVIDER_FAMILY_ORDER.map((familyId) => (
|
||||
<optgroup key={familyId} label={t(`ai.family.${familyId}`)}>
|
||||
{presetsByFamily(familyId).map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>
|
||||
{preset.id === 'custom' ? t('ai.family.custom') : preset.name}
|
||||
</option>
|
||||
<fieldset disabled={busy} className="ai-provider-settings min-w-0">
|
||||
<div className="ai-provider-layout grid items-start gap-4">
|
||||
<aside className="overflow-hidden rounded-xl border border-border-subtle bg-surface-raised">
|
||||
<div className="flex items-center gap-2 border-b border-border-subtle p-3">
|
||||
<select
|
||||
aria-label={t('ai.presets')}
|
||||
value={presetId}
|
||||
onChange={(event) => setPresetId(event.target.value)}
|
||||
className="ui-select-pill min-w-0 flex-1"
|
||||
>
|
||||
{AI_PROVIDER_FAMILY_ORDER.map((familyId) => (
|
||||
<optgroup key={familyId} label={t(`ai.family.${familyId}`)}>
|
||||
{presetsByFamily(familyId).map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>
|
||||
{preset.id === 'custom' ? t('ai.family.custom') : preset.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
onClick={() => (presetId === 'custom' ? addCustom() : addPreset())}
|
||||
>
|
||||
{t('ai.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
onClick={() => (presetId === 'custom' ? addCustom() : addPreset())}
|
||||
>
|
||||
{t('ai.add')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-[22rem] grid-cols-[12rem_minmax(0,1fr)] gap-5">
|
||||
<aside className="border-dark-700/45 bg-dark-800/50 h-fit overflow-hidden rounded-2xl border py-2">
|
||||
<div className="px-3 pb-1.5 text-[11px] font-medium text-txt-faint">{t('ai.configured')}</div>
|
||||
<div className="flex items-center justify-between px-4 pb-2 pt-4 text-xs font-medium text-txt-muted">
|
||||
<span>{t('ai.configured')}</span>
|
||||
<span className="tabular-nums">{store.providers.length}</span>
|
||||
</div>
|
||||
{store.providers.length === 0 ? (
|
||||
<p className="px-3 py-5 text-xs leading-relaxed text-txt-faint">{t('ai.configuredHint')}</p>
|
||||
) : (
|
||||
@@ -241,17 +254,21 @@ export default function AiProviderSettings() {
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(provider.id)}
|
||||
className={`w-full px-3 py-2 text-left transition-colors ${
|
||||
aria-pressed={selectedId === provider.id}
|
||||
className={`mb-1 w-full border-l-2 px-3 py-3 text-left transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent-blue ${
|
||||
selectedId === provider.id
|
||||
? 'bg-accent-blue/12 text-accent-blue'
|
||||
: 'hover:bg-dark-700/50 text-content-secondary'
|
||||
? 'border-accent-blue bg-accent-blue/10 text-accent-blue'
|
||||
: 'border-transparent text-content-secondary hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{store.activeProviderId === provider.id && <Check className="h-3 w-3 flex-shrink-0" />}
|
||||
<span className="truncate text-sm font-medium">{provider.name}</span>
|
||||
<Cpu className="h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{provider.name}</span>
|
||||
{store.activeProviderId === provider.id && (
|
||||
<Check aria-label={t('ai.active')} className="h-3.5 w-3.5 shrink-0 text-accent-blue" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate pl-4 text-[10px] text-txt-faint">
|
||||
<div className="mt-1 truncate pl-[1.375rem] text-xs text-txt-faint">
|
||||
{model?.label || t('ai.noModel')}
|
||||
</div>
|
||||
</button>
|
||||
@@ -260,14 +277,26 @@ export default function AiProviderSettings() {
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0 max-w-lg">
|
||||
<div className="min-w-0">
|
||||
{!draft ? (
|
||||
<div className="border-dark-600/80 flex h-40 items-center justify-center rounded-2xl border border-dashed text-sm text-txt-faint">
|
||||
<div className="flex min-h-[20rem] items-center justify-center rounded-xl border border-dashed border-border-strong bg-surface-raised p-6 text-sm text-txt-muted">
|
||||
{t('ai.emptyHint')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="border-dark-700/45 bg-dark-800/50 divide-dark-700/35 divide-y overflow-hidden rounded-2xl border">
|
||||
<div className="overflow-hidden rounded-xl border border-border-subtle bg-surface-raised">
|
||||
<div className="flex items-center gap-3 border-b border-border-subtle px-5 py-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent-blue/10 text-accent-blue">
|
||||
<Cpu className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-sm font-semibold text-content-primary">{draft.name || t('ai.name')}</h3>
|
||||
<p className="mt-1 text-xs text-txt-muted">{t('ai.configuration')}</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-md bg-surface-inset px-2 py-1 text-[11px] text-txt-muted">
|
||||
{dirty ? t('ai.unsaved') : store.activeProviderId === draft.id ? t('ai.active') : t('ai.saved')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-4 p-5">
|
||||
<FormRow label={t('ai.name')}>
|
||||
<CompactField>
|
||||
<input
|
||||
@@ -316,7 +345,7 @@ export default function AiProviderSettings() {
|
||||
</div>
|
||||
)}
|
||||
<FormRow label={draft.authStyle === 'none' ? t('ai.apiKeyOptional') : t('ai.apiKey')}>
|
||||
<CompactField wide>
|
||||
<CompactField>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
@@ -336,7 +365,7 @@ export default function AiProviderSettings() {
|
||||
</FormRow>
|
||||
{(needsEndpoint || advancedOpen) && (
|
||||
<FormRow label={t('ai.endpoint')}>
|
||||
<CompactField wide>
|
||||
<CompactField>
|
||||
<input
|
||||
aria-label={t('ai.endpoint')}
|
||||
value={currentModel?.baseUrl || draft.baseUrl}
|
||||
@@ -353,14 +382,16 @@ export default function AiProviderSettings() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
className="flex items-center gap-1 px-0.5 text-xs text-txt-faint hover:text-txt-muted"
|
||||
aria-expanded={advancedOpen}
|
||||
aria-controls="ai-provider-advanced"
|
||||
className="flex w-full items-center gap-2 border-t border-border-subtle px-5 py-3 text-xs font-medium text-txt-muted hover:bg-surface-hover"
|
||||
>
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${advancedOpen ? 'rotate-180' : ''}`} />
|
||||
{t('ai.advanced')}
|
||||
</button>
|
||||
|
||||
{advancedOpen && (
|
||||
<div className="border-dark-700/45 bg-dark-800/50 divide-dark-700/35 divide-y overflow-hidden rounded-2xl border">
|
||||
<div id="ai-provider-advanced" className="space-y-4 border-t border-border-subtle bg-surface-base p-5">
|
||||
<FormRow label={t('ai.protocol')}>
|
||||
<select
|
||||
value={draft.protocol}
|
||||
@@ -396,78 +427,115 @@ export default function AiProviderSettings() {
|
||||
className="h-3.5 w-3.5 accent-blue-500"
|
||||
/>
|
||||
</FormRow>
|
||||
{isStored && !dirty && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!canTest}
|
||||
icon={<Wifi className="h-3.5 w-3.5" />}
|
||||
onClick={() => void testConnection()}
|
||||
>
|
||||
{t('ai.retestConnection')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!canTest}
|
||||
loading={testState === 'testing'}
|
||||
icon={testState === 'testing' ? undefined : <Wifi className="h-3.5 w-3.5" />}
|
||||
onClick={() => void testConnection()}
|
||||
>
|
||||
{testState === 'testing' ? t('ai.testing') : t('ai.test')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!verified || saving}
|
||||
loading={saving}
|
||||
icon={saving ? undefined : <Save className="h-3.5 w-3.5" />}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{t('common:action.save')}
|
||||
</Button>
|
||||
{isStored && store.activeProviderId !== draft.id && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void store.setActiveProvider(draft.id)}
|
||||
>
|
||||
{t('ai.setActive')}
|
||||
</Button>
|
||||
<div className="border-t border-border-subtle bg-surface-base px-5 py-4">
|
||||
{dirty && needsTest && (
|
||||
<p className="mb-3 text-xs leading-relaxed text-txt-muted">{t('ai.testBeforeSave')}</p>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
title={t('ai.deleteTitle')}
|
||||
onClick={() => void remove()}
|
||||
className="rounded p-1.5 text-txt-faint hover:bg-dark-700 hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
title={t('ai.deleteTitle')}
|
||||
aria-label={t('ai.deleteTitle')}
|
||||
onClick={() => void remove()}
|
||||
className="mr-auto rounded-lg p-2 text-txt-muted transition-colors hover:bg-feedback-error-bg hover:text-feedback-error focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent-blue"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
{dirty && isStored && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => chooseDraft(structuredClone(storedProvider))}
|
||||
>
|
||||
{t('ai.cancelChanges')}
|
||||
</Button>
|
||||
)}
|
||||
{dirty && needsTest && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!canTest}
|
||||
loading={testState === 'testing'}
|
||||
icon={testState === 'testing' ? undefined : <Wifi className="h-3.5 w-3.5" />}
|
||||
onClick={() => void testConnection()}
|
||||
>
|
||||
{testState === 'testing' ? t('ai.testing') : t('ai.test')}
|
||||
</Button>
|
||||
)}
|
||||
{dirty && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!canSave}
|
||||
loading={saving}
|
||||
icon={saving ? undefined : <Save className="h-3.5 w-3.5" />}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{t('common:action.save')}
|
||||
</Button>
|
||||
)}
|
||||
{isStored && !dirty && store.activeProviderId !== draft.id && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void store.setActiveProvider(draft.id)}
|
||||
>
|
||||
{t('ai.setActive')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex min-h-5 items-center gap-1.5 text-xs ${verified ? 'text-emerald-400' : testState === 'error' ? 'text-red-300' : 'text-txt-faint'}`}
|
||||
role={testState === 'error' ? 'alert' : 'status'}
|
||||
>
|
||||
{verified && <CheckCircle2 className="h-3.5 w-3.5" />}
|
||||
<span>{message}</span>
|
||||
{displayedMessage && (
|
||||
<div
|
||||
className={`mt-3 flex items-start gap-1.5 text-xs leading-relaxed ${verified ? 'text-feedback-success' : testState === 'error' ? 'text-feedback-error' : 'text-txt-faint'}`}
|
||||
role={testState === 'error' ? 'alert' : 'status'}
|
||||
>
|
||||
{verified && <CheckCircle2 className="h-3.5 w-3.5" />}
|
||||
<span className="min-w-0 break-words">{displayedMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
{verified && testAnswer && (
|
||||
<p className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-border-subtle bg-surface-raised p-3 text-xs text-content-secondary">
|
||||
{testAnswer}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{verified && testAnswer && <p className="text-xs text-content-secondary">{testAnswer}</p>}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
function FormRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 px-4 py-3">
|
||||
<span className="min-w-[6.5rem] text-sm text-content-primary">{label}</span>
|
||||
<div className="ml-auto flex-shrink-0">{children}</div>
|
||||
<label className="ai-provider-field grid min-w-0 items-center gap-2">
|
||||
<span className="text-xs font-medium text-content-secondary">{label}</span>
|
||||
<div className="min-w-0">{children}</div>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactField({ children, wide = false }: { children: React.ReactNode; wide?: boolean }) {
|
||||
return <div className={wide ? 'w-[17.5rem] max-w-full' : 'w-[12.5rem] max-w-full'}>{children}</div>
|
||||
function CompactField({ children }: { children: React.ReactNode }) {
|
||||
return <div className="w-full min-w-0">{children}</div>
|
||||
}
|
||||
@@ -1264,7 +1264,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
</div>
|
||||
|
||||
<div className="app-workspace-body">
|
||||
<div className={`terminal-canvas ${aiPanelOpen ? '' : 'terminal-canvas-edge'}`}>
|
||||
<div className="terminal-canvas">
|
||||
{showSearch && (
|
||||
<TerminalSearchBar
|
||||
searchQuery={searchQuery}
|
||||
|
||||
@@ -144,6 +144,12 @@
|
||||
"wechatDesc": "加入交流群,问题反馈与版本动态"
|
||||
},
|
||||
"ai": {
|
||||
"retestConnection": "Test connection again",
|
||||
"cancelChanges": "Discard changes",
|
||||
"configuration": "Configuration",
|
||||
"active": "Default",
|
||||
"unsaved": "Unsaved",
|
||||
"testBeforeSave": "Test the connection before saving this configuration.",
|
||||
"presets": "Provider presets",
|
||||
"add": "Add",
|
||||
"configured": "Configured",
|
||||
@@ -172,7 +178,7 @@
|
||||
"endpoint": "Endpoint",
|
||||
"advanced": "Advanced",
|
||||
"allowHttp": "Allow HTTP (localhost/LAN)",
|
||||
"setActive": "Set as active",
|
||||
"setActive": "Set as default",
|
||||
"deleteTitle": "Delete",
|
||||
"apiKeySaveFailed": "Failed to save API key",
|
||||
"providerNotFound": "Provider not found",
|
||||
|
||||
@@ -144,6 +144,12 @@
|
||||
"wechatDesc": "加入交流群,问题反馈与版本动态"
|
||||
},
|
||||
"ai": {
|
||||
"retestConnection": "接続を再テスト",
|
||||
"cancelChanges": "変更を破棄",
|
||||
"configuration": "設定の詳細",
|
||||
"active": "デフォルト",
|
||||
"unsaved": "未保存",
|
||||
"testBeforeSave": "接続テストに成功すると設定を保存できます。",
|
||||
"presets": "プロバイダープリセット",
|
||||
"add": "追加",
|
||||
"configured": "設定済み",
|
||||
@@ -172,7 +178,7 @@
|
||||
"endpoint": "エンドポイント",
|
||||
"advanced": "詳細",
|
||||
"allowHttp": "HTTP を許可(localhost / LAN)",
|
||||
"setActive": "現在のプロバイダーに設定",
|
||||
"setActive": "デフォルトに設定",
|
||||
"deleteTitle": "削除",
|
||||
"apiKeySaveFailed": "API キーの保存に失敗しました",
|
||||
"providerNotFound": "プロバイダーが見つかりません",
|
||||
|
||||
@@ -144,6 +144,12 @@
|
||||
"wechatDesc": "加入交流群,问题反馈与版本动态"
|
||||
},
|
||||
"ai": {
|
||||
"retestConnection": "연결 다시 테스트",
|
||||
"cancelChanges": "변경 취소",
|
||||
"configuration": "설정 세부 정보",
|
||||
"active": "기본값",
|
||||
"unsaved": "저장되지 않음",
|
||||
"testBeforeSave": "연결 테스트에 성공하면 설정을 저장할 수 있습니다.",
|
||||
"presets": "제공자 프리셋",
|
||||
"add": "추가",
|
||||
"configured": "구성됨",
|
||||
@@ -172,7 +178,7 @@
|
||||
"endpoint": "엔드포인트",
|
||||
"advanced": "고급",
|
||||
"allowHttp": "HTTP 허용 (localhost/LAN)",
|
||||
"setActive": "활성으로 설정",
|
||||
"setActive": "기본값으로 설정",
|
||||
"deleteTitle": "삭제",
|
||||
"apiKeySaveFailed": "API 키 저장 실패",
|
||||
"providerNotFound": "제공자를 찾을 수 없음",
|
||||
|
||||
@@ -144,6 +144,12 @@
|
||||
"wechatDesc": "加入交流群,问题反馈与版本动态"
|
||||
},
|
||||
"ai": {
|
||||
"retestConnection": "Повторить проверку подключения",
|
||||
"cancelChanges": "Отменить изменения",
|
||||
"configuration": "Настройки подключения",
|
||||
"active": "По умолчанию",
|
||||
"unsaved": "Не сохранено",
|
||||
"testBeforeSave": "Проверьте подключение перед сохранением настроек.",
|
||||
"presets": "Пресеты провайдеров",
|
||||
"add": "Добавить",
|
||||
"configured": "Настроено",
|
||||
@@ -172,7 +178,7 @@
|
||||
"endpoint": "Адрес API",
|
||||
"advanced": "Дополнительно",
|
||||
"allowHttp": "Разрешить HTTP (localhost/LAN)",
|
||||
"setActive": "Сделать активным",
|
||||
"setActive": "Сделать основным",
|
||||
"deleteTitle": "Удалить",
|
||||
"apiKeySaveFailed": "Не удалось сохранить API-ключ",
|
||||
"providerNotFound": "Провайдер не найден",
|
||||
|
||||
@@ -144,6 +144,12 @@
|
||||
"wechatDesc": "加入交流群,问题反馈与版本动态"
|
||||
},
|
||||
"ai": {
|
||||
"retestConnection": "Kiểm tra lại kết nối",
|
||||
"cancelChanges": "Hủy thay đổi",
|
||||
"configuration": "Chi tiết cấu hình",
|
||||
"active": "Mặc định",
|
||||
"unsaved": "Chưa lưu",
|
||||
"testBeforeSave": "Kiểm tra kết nối thành công trước khi lưu cấu hình.",
|
||||
"presets": "Cài sẵn nhà cung cấp",
|
||||
"add": "Thêm",
|
||||
"configured": "Đã cấu hình",
|
||||
@@ -172,7 +178,7 @@
|
||||
"endpoint": "Địa chỉ API",
|
||||
"advanced": "Nâng cao",
|
||||
"allowHttp": "Cho phép HTTP (localhost/LAN)",
|
||||
"setActive": "Đặt làm hoạt động",
|
||||
"setActive": "Đặt làm mặc định",
|
||||
"deleteTitle": "Xóa",
|
||||
"apiKeySaveFailed": "Không thể lưu khóa API",
|
||||
"providerNotFound": "Không tìm thấy nhà cung cấp",
|
||||
|
||||
@@ -144,6 +144,12 @@
|
||||
"wechatDesc": "加入交流群,问题反馈与版本动态"
|
||||
},
|
||||
"ai": {
|
||||
"retestConnection": "重新测试连接",
|
||||
"cancelChanges": "取消修改",
|
||||
"configuration": "配置详情",
|
||||
"active": "默认",
|
||||
"unsaved": "未保存",
|
||||
"testBeforeSave": "先测试连接,通过后即可保存配置。",
|
||||
"presets": "供应商预设",
|
||||
"add": "添加",
|
||||
"configured": "已配置",
|
||||
@@ -172,7 +178,7 @@
|
||||
"endpoint": "接口地址",
|
||||
"advanced": "高级",
|
||||
"allowHttp": "允许 HTTP(本机/内网)",
|
||||
"setActive": "设为当前",
|
||||
"setActive": "设为默认",
|
||||
"deleteTitle": "删除",
|
||||
"apiKeySaveFailed": "API 密钥保存失败",
|
||||
"providerNotFound": "供应商不存在",
|
||||
|
||||
@@ -144,6 +144,12 @@
|
||||
"wechatDesc": "加入交流群,问题反馈与版本动态"
|
||||
},
|
||||
"ai": {
|
||||
"retestConnection": "重新測試連線",
|
||||
"cancelChanges": "取消修改",
|
||||
"configuration": "設定詳情",
|
||||
"active": "預設",
|
||||
"unsaved": "尚未儲存",
|
||||
"testBeforeSave": "先測試連線,通過後即可儲存設定。",
|
||||
"presets": "供應商預設",
|
||||
"add": "新增",
|
||||
"configured": "已設定",
|
||||
@@ -172,7 +178,7 @@
|
||||
"endpoint": "端點",
|
||||
"advanced": "進階",
|
||||
"allowHttp": "允許 HTTP(本機/內網)",
|
||||
"setActive": "設為目前",
|
||||
"setActive": "設為預設",
|
||||
"deleteTitle": "刪除",
|
||||
"apiKeySaveFailed": "API 金鑰儲存失敗",
|
||||
"providerNotFound": "供應商不存在",
|
||||
|
||||
+51
-4
@@ -297,9 +297,14 @@ body {
|
||||
background: var(--color-terminal-bg);
|
||||
}
|
||||
|
||||
/* Close a full-width terminal without turning the former scrollbar gutter into padding. */
|
||||
.terminal-canvas-edge {
|
||||
box-shadow: inset -1px 0 0 var(--hairline);
|
||||
/* The sidebar owns the only divider; collapsed terminals reach the window edge. */
|
||||
.app-sidebar {
|
||||
border-right: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.app-sidebar ::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.app-agent-panel {
|
||||
@@ -405,7 +410,7 @@ body.session-tree-dragging * {
|
||||
* theme. `!important` beats both xterm.css and any later inline default.
|
||||
*/
|
||||
.xterm {
|
||||
padding: 4px;
|
||||
padding: 6px 10px;
|
||||
background-color: var(--xterm-viewport-bg, var(--color-terminal-bg)) !important;
|
||||
}
|
||||
|
||||
@@ -422,6 +427,22 @@ body.session-tree-dragging * {
|
||||
background: var(--xterm-viewport-bg, var(--color-terminal-bg));
|
||||
}
|
||||
|
||||
/* Terminal scrollbars follow the terminal palette, independent of app chrome. */
|
||||
.xterm .xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--xterm-viewport-bg, var(--color-terminal-bg)) 70%, #94a3b8);
|
||||
border: 2px solid var(--xterm-viewport-bg, var(--color-terminal-bg));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--xterm-viewport-bg, var(--color-terminal-bg)) 45%, #94a3b8);
|
||||
}
|
||||
|
||||
/* Animation */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
@@ -851,3 +872,29 @@ button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Adapt to the settings pane, including narrow desktop windows. */
|
||||
.ai-provider-settings {
|
||||
container-type: inline-size;
|
||||
max-width: 64rem;
|
||||
}
|
||||
|
||||
.ai-provider-settings .ui-select-pill,
|
||||
.ai-provider-settings .ui-input-sm {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
min-height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
@container (min-width: 640px) {
|
||||
.ai-provider-layout {
|
||||
grid-template-columns: 15rem minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 850px) {
|
||||
.ai-provider-field {
|
||||
grid-template-columns: 8rem minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,8 @@ function normalizeProvider(raw: AiProviderConfig): AiProviderConfig {
|
||||
.filter((item) => item?.id && item?.model)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
billing: item.billing === 'plan' || item.billing === 'local' || item.billing === 'payg' ? item.billing : 'payg',
|
||||
billing:
|
||||
item.billing === 'plan' || item.billing === 'local' || item.billing === 'payg' ? item.billing : 'payg',
|
||||
}))
|
||||
: []
|
||||
const presetId = raw.presetId === 'zai' || raw.presetId === 'zhipu' ? 'glm' : raw.presetId
|
||||
@@ -121,8 +122,8 @@ export const useAiProviderStore = create<AiProviderState>((set, get) => ({
|
||||
const providers = exists
|
||||
? get().providers.map((item) => (item.id === provider.id ? provider : item))
|
||||
: [...get().providers, provider]
|
||||
set({ providers })
|
||||
await persist(providers, get().activeProviderId)
|
||||
set({ providers })
|
||||
},
|
||||
|
||||
deleteProvider: async (providerId) => {
|
||||
@@ -155,7 +156,9 @@ export const useAiProviderStore = create<AiProviderState>((set, get) => ({
|
||||
const result = await window.opsxApi.aiCatalogFetch()
|
||||
const catalogProviders = normalizeModelsDevCatalog(result.documents || [])
|
||||
if (catalogProviders.length === 0) {
|
||||
throw new Error(translateErrorMessage(result.error || result.errors?.[0]) || i18n.t('settings:ai.catalogUnavailable'))
|
||||
throw new Error(
|
||||
translateErrorMessage(result.error || result.errors?.[0]) || i18n.t('settings:ai.catalogUnavailable')
|
||||
)
|
||||
}
|
||||
set({ catalogProviders, catalogUpdatedAt: Date.now() })
|
||||
return { providers: catalogProviders.length, errors: result.errors || [] }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createProviderFromPreset } from '../../src/ai/providerPresets'
|
||||
import AiProviderSettings from '../../src/components/settings/AiProviderSettings'
|
||||
import { useAiProviderStore } from '../../src/stores/aiProviderStore'
|
||||
import { mockDesktopApi } from '../setup'
|
||||
@@ -39,9 +40,13 @@ describe('AI provider settings', () => {
|
||||
expect(mockDesktopApi.aiProviderSaveKey).not.toHaveBeenCalled()
|
||||
expect(await screen.findByText(/可用/)).toBeVisible()
|
||||
expect(save).toBeEnabled()
|
||||
expect(screen.queryByRole('button', { name: '测试' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(save)
|
||||
await waitFor(() => expect(useAiProviderStore.getState().providers).toHaveLength(1))
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument())
|
||||
expect(screen.queryByRole('button', { name: '测试' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/可用/)).not.toBeInTheDocument()
|
||||
expect(mockDesktopApi.aiProviderSaveKey).toHaveBeenCalledWith(expect.any(String), 'sk-test-only')
|
||||
expect(JSON.stringify(vi.mocked(mockDesktopApi.settingsSet).mock.calls)).not.toContain('sk-test-only')
|
||||
})
|
||||
@@ -59,4 +64,65 @@ describe('AI provider settings', () => {
|
||||
expect(save).toBeDisabled()
|
||||
expect(screen.getByText('请重新测试')).toBeVisible()
|
||||
})
|
||||
|
||||
function loadSavedProvider() {
|
||||
const provider = { ...createProviderFromPreset('deepseek'), hasApiKey: true }
|
||||
useAiProviderStore.setState({ providers: [provider], activeProviderId: '' })
|
||||
return provider
|
||||
}
|
||||
|
||||
it('hides clean actions, saves name changes without testing, and offers a default action', async () => {
|
||||
loadSavedProvider()
|
||||
render(<AiProviderSettings />)
|
||||
expect(screen.queryByRole('button', { name: '测试' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '设为默认' })).toBeVisible()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('名称'), { target: { value: 'Work' } })
|
||||
expect(screen.queryByRole('button', { name: '测试' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '设为默认' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument())
|
||||
expect(useAiProviderStore.getState().providers[0].name).toBe('Work')
|
||||
expect(mockDesktopApi.aiTestProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires validation for token edits and returns to clean state on revert or cancel', () => {
|
||||
const provider = loadSavedProvider()
|
||||
render(<AiProviderSettings />)
|
||||
fireEvent.change(screen.getByLabelText('API 密钥'), { target: { value: 'sk-new' } })
|
||||
expect(screen.getByRole('button', { name: '测试' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '保存' })).toBeDisabled()
|
||||
fireEvent.change(screen.getByLabelText('API 密钥'), { target: { value: '' } })
|
||||
expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '高级' }))
|
||||
fireEvent.change(screen.getByLabelText('接口地址'), { target: { value: 'https://proxy.example.com/v1' } })
|
||||
expect(screen.getByRole('button', { name: '保存' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消修改' }))
|
||||
expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument()
|
||||
expect(useAiProviderStore.getState().providers[0]).toEqual(provider)
|
||||
})
|
||||
|
||||
it('keeps manual connection checks in advanced settings without requiring another save', async () => {
|
||||
loadSavedProvider()
|
||||
render(<AiProviderSettings />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '高级' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新测试连接' }))
|
||||
expect(await screen.findByText(/可用/)).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument()
|
||||
expect(mockDesktopApi.settingsSet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps edits available when saving fails', async () => {
|
||||
loadSavedProvider()
|
||||
vi.mocked(mockDesktopApi.settingsSet).mockRejectedValueOnce(new Error('Write failed'))
|
||||
render(<AiProviderSettings />)
|
||||
fireEvent.change(screen.getByLabelText('名称'), { target: { value: 'Unsaved name' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Write failed')
|
||||
expect(screen.getByRole('button', { name: '保存' })).toBeEnabled()
|
||||
expect(screen.getByLabelText('名称')).toHaveValue('Unsaved name')
|
||||
expect(useAiProviderStore.getState().providers[0].name).toBe('DeepSeek')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user