同步完整源码至远程: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

This commit is contained in:
Logan.Li committed 2026-10-16 03:47:51 +08:00
1 parent b43f1574a9
commit fcd244637c
13 files changed
+350 -124

No files matched your search

+1 -1
View File
@@ -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">
+176 -108
View File
@@ -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}
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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": "プロバイダーが見つかりません",
+7 -1
View File
@@ -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": "제공자를 찾을 수 없음",
+7 -1
View File
@@ -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": "Провайдер не найден",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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": "供应商不存在",
+7 -1
View File
@@ -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
View File
@@ -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);
}
}
+6 -3
View File
@@ -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')
})
})