feat(desktop): merge settings and models into tabs; improve model config and chat scroll

Merge the "Models" menu item into the "Settings" page with top tabs
(Basic / Models), reducing one nav entry. Basic settings now only handles
provider + model selection; API key/base are consolidated into the Models
tab. Unconfigured providers are marked in the dropdown and guide the user
to configure them.

The Models tab covers all 7 capabilities (chat/vision/image/asr/tts/
embedding/search) with vendor credentials and routing. The vendor section
follows the web client's unified design: built-in and custom providers
share one grid, "set as default" is removed, and credentials are configured
via modals.

- Add settings/ directory: SettingsPage (tab shell), BasicSettings, ModelsTab,
  CapabilityCard, primitives, modelsHelpers; remove the old ConfigPage
- Rewrite ModelEntry/ModelProvider/CapabilityState/SearchCapabilityState in
  types.ts to match the real /api/models shape (mixed string|{value,hint})
- Fix empty chat model dropdown: chat has no provider_models, so fall back
  to the top-level providers[].models
- Fix chat scroll sliding from top on session switch: snap instantly to
  bottom on switch, smooth-scroll only for streaming updates
- Rename the Knowledge menu label and align model/config copy with the web
  client (vendor credentials, main model, etc.)
- Clean up orphaned i18n keys
This commit is contained in:
zhayujie
2026-06-20 11:35:47 +08:00
parent 2ef31d5d33
commit c9c16298ec
12 changed files with 1889 additions and 311 deletions

View File

@@ -11,7 +11,7 @@ import { useUIStore } from './store/uiStore'
import apiClient from './api/client'
import { t } from './i18n'
import ChatPage from './pages/ChatPage'
import ConfigPage from './pages/ConfigPage'
import SettingsPage from './pages/SettingsPage'
import SkillsPage from './pages/SkillsPage'
import MemoryPage from './pages/MemoryPage'
import ChannelsPage from './pages/ChannelsPage'
@@ -68,10 +68,11 @@ const App: React.FC = () => {
<Route path="/knowledge" element={<PlaceholderPage title={t('menu_knowledge')} />} />
<Route path="/memory" element={<MemoryPage baseUrl={backend.baseUrl} />} />
<Route path="/skills" element={<SkillsPage baseUrl={backend.baseUrl} />} />
<Route path="/models" element={<PlaceholderPage title={t('menu_models')} />} />
<Route path="/channels" element={<ChannelsPage baseUrl={backend.baseUrl} />} />
<Route path="/tasks" element={<TasksPage baseUrl={backend.baseUrl} />} />
<Route path="/settings" element={<ConfigPage baseUrl={backend.baseUrl} />} />
<Route path="/settings" element={<SettingsPage baseUrl={backend.baseUrl} onLangChange={handleLangChange} />} />
{/* Legacy /models route now lives as a tab inside settings */}
<Route path="/models" element={<SettingsPage baseUrl={backend.baseUrl} onLangChange={handleLangChange} />} />
<Route path="/logs" element={<LogsPage baseUrl={backend.baseUrl} />} />
</Routes>
</div>

View File

@@ -12,7 +12,7 @@ const translations: Record<string, Record<string, string>> = {
menu_tasks: '定时',
menu_logs: '日志',
menu_models: '模型',
menu_knowledge: '知识',
menu_knowledge: '知识',
menu_settings: '设置',
nav_expand: '展开侧栏',
nav_collapse: '收起侧栏',
@@ -57,10 +57,87 @@ const translations: Record<string, Record<string, string>> = {
config_save: '保存',
config_saved: '已保存',
config_save_error: '保存失败',
config_custom_option: '自定义...',
config_custom_option: '自定义',
config_max_tokens: '最大上下文 Token',
config_max_turns: '最大上下文轮次',
config_max_steps: '最大步数',
config_max_turns: '最大记忆轮次',
config_max_steps: '最大执行步数',
config_max_tokens_hint: '对话中 Agent 能输入的最大 Token 长度,超过后会智能压缩处理',
config_max_turns_hint: '一问一答为一轮,超过后会智能压缩处理',
config_max_steps_hint: '单次对话中 Agent 最多调用工具的次数',
config_thinking: '深度思考',
config_thinking_hint: '是否启用深度思考模式',
config_evolution: '自主进化',
config_evolution_hint: '会话空闲后自动复盘,沉淀记忆、优化技能、处理未完成事项',
config_security: '安全设置',
config_password: '访问密码',
config_password_hint: '留空则不启用密码保护',
config_password_placeholder: '留空表示不设密码',
config_password_saved: '密码已更新,请重新登录',
config_password_cleared: '密码已清除',
config_language: '语言',
config_language_hint: '界面与回复语言',
config_credentials_link: 'API Key 与接口地址请在「模型配置」中设置',
config_goto_models: '前往配置',
config_provider_unconfigured: '未配置',
config_provider_unconfigured_hint: '该厂商尚未配置 API Key请先前往配置',
config_cancel: '取消',
// settings tabs
settings_tab_basic: '基础配置',
settings_tab_models: '模型配置',
// models tab
models_vendors: '厂商凭据',
models_vendors_sub: '一处配置,多个模型能力共享',
models_configured: '已配置',
models_no_vendor: '尚未配置任何厂商',
models_add_vendor: '添加厂商',
models_custom_vendor: '自定义',
models_add_custom: '添加自定义厂商',
models_add_custom_hint: '接口需遵循 OpenAI API 协议',
models_edit_custom: '编辑自定义厂商',
models_custom_name: '名称',
models_custom_base_hint: '接口需遵循 OpenAI API 协议',
models_clear: '清除凭据',
models_delete: '删除',
models_clear_confirm: '确认清除该厂商的 API Key 与 Base URL 吗?相关能力将不再可用。',
models_delete_confirm: '确定删除该自定义厂商吗?此操作无法撤销。',
models_provider: '厂商',
models_model: '模型',
models_voice: '音色',
models_select_provider: '待选择',
models_select_model: '请选择模型',
models_select_voice: '请选择音色',
models_no_options: '暂无可选项',
models_auto: '自动',
models_asr_auto: '自动(跟随主模型)',
models_disabled: '不启用',
models_fallback: '兜底',
models_cap_chat: '主模型',
models_cap_chat_sub: '用于基础对话和 Agent 推理',
models_cap_vision: '图像理解',
models_cap_vision_sub: '识别图片内容,用于图像识别工具',
models_cap_image: '图像生成',
models_cap_image_sub: '生成图片,用于图像生成技能',
models_cap_asr: '语音识别',
models_cap_asr_sub: '语音转文字',
models_cap_tts: '语音合成',
models_cap_tts_sub: '文字转语音',
models_cap_embedding: '向量',
models_cap_embedding_sub: '用于记忆与知识的向量化检索',
models_cap_search: '联网搜索',
models_cap_search_sub: '实时网页检索能力,用于搜索工具',
models_tts_reply_mode: '语音回复模式',
models_tts_reply_mode_hint: '决定何时以语音回复用户',
models_tts_mode_off: '关闭',
models_tts_mode_if_voice: '仅当用户发语音时',
models_tts_mode_always: '始终语音回复',
models_embedding_dim: '维度',
models_embedding_rebuild_hint: '切换向量模型后,已有索引将失效,需要重建',
models_search_strategy: '策略',
models_search_auto: '自动',
models_search_fixed: '指定',
models_search_provider: '搜索厂商',
models_search_bocha_key: '配置博查 API Key',
models_search_bocha_hint: '前往博查开放平台创建 API Key',
skills_title: '技能管理',
skills_desc: '查看、启用或禁用 Agent 技能',
tools_section_title: '内置工具',
@@ -159,9 +236,86 @@ const translations: Record<string, Record<string, string>> = {
config_save: 'Save',
config_saved: 'Saved',
config_save_error: 'Save failed',
config_custom_option: 'Custom...',
config_custom_option: 'Custom',
config_max_tokens_hint: 'Max token length the Agent can take in; longer context is compressed automatically',
config_max_turns_hint: 'One question and answer is a turn; older turns are compressed automatically',
config_max_steps_hint: 'Max tool calls the Agent can make in one turn',
config_thinking: 'Deep Thinking',
config_thinking_hint: 'Whether to enable deep thinking mode',
config_evolution: 'Self-Evolution',
config_evolution_hint: 'Review automatically when idle: consolidate memory, refine skills, finish pending tasks',
config_security: 'Security',
config_password: 'Access Password',
config_password_hint: 'Leave empty to disable password protection',
config_password_placeholder: 'Leave empty for no password',
config_password_saved: 'Password updated, please log in again',
config_password_cleared: 'Password cleared',
config_language: 'Language',
config_language_hint: 'Interface and reply language',
config_credentials_link: 'Set API key and endpoint in "Models"',
config_goto_models: 'Configure',
config_provider_unconfigured: 'Not configured',
config_provider_unconfigured_hint: 'This provider has no API key yet — configure it first',
config_cancel: 'Cancel',
// settings tabs
settings_tab_basic: 'Basic',
settings_tab_models: 'Models',
// models tab
models_vendors: 'Provider Credentials',
models_vendors_sub: 'Configured once, shared by multiple model capabilities',
models_configured: 'configured',
models_no_vendor: 'No provider configured yet',
models_add_vendor: 'Add Provider',
models_custom_vendor: 'Custom',
models_add_custom: 'Add custom provider',
models_add_custom_hint: 'Endpoint must follow the OpenAI API protocol',
models_edit_custom: 'Edit custom provider',
models_custom_name: 'Name',
models_custom_base_hint: 'Endpoint must follow the OpenAI API protocol',
models_clear: 'Clear credentials',
models_delete: 'Delete',
models_clear_confirm: 'Clear the API key and base URL for this provider? Related capabilities will stop working.',
models_delete_confirm: 'Delete this custom provider? This cannot be undone.',
models_provider: 'Provider',
models_model: 'Model',
models_voice: 'Voice',
models_select_provider: 'Select',
models_select_model: 'Select a model',
models_select_voice: 'Select a voice',
models_no_options: 'No options',
models_auto: 'Auto',
models_asr_auto: 'Auto (follow main model)',
models_disabled: 'Disabled',
models_fallback: 'Fallback',
models_cap_chat: 'Main Model',
models_cap_chat_sub: 'Used for basic chat and agent reasoning',
models_cap_vision: 'Image Understanding',
models_cap_vision_sub: 'Recognizes image content, used by image recognition tools',
models_cap_image: 'Image Generation',
models_cap_image_sub: 'Generates images, used by image generation skills',
models_cap_asr: 'Speech Recognition',
models_cap_asr_sub: 'Voice to text',
models_cap_tts: 'Speech Synthesis',
models_cap_tts_sub: 'Text to voice',
models_cap_embedding: 'Embedding',
models_cap_embedding_sub: 'Used for vectorized retrieval of memory and knowledge',
models_cap_search: 'Web Search',
models_cap_search_sub: 'Real-time web retrieval, used by search tools',
models_tts_reply_mode: 'Voice Reply Mode',
models_tts_reply_mode_hint: 'When to reply with voice',
models_tts_mode_off: 'Off',
models_tts_mode_if_voice: 'Only when user sends voice',
models_tts_mode_always: 'Always reply with voice',
models_embedding_dim: 'Dimension',
models_embedding_rebuild_hint: 'Existing index becomes invalid and must be rebuilt after changing the model',
models_search_strategy: 'Strategy',
models_search_auto: 'Auto',
models_search_fixed: 'Pinned',
models_search_provider: 'Search provider',
models_search_bocha_key: 'Configure Bocha API Key',
models_search_bocha_hint: 'Create a key at the Bocha open platform',
config_max_tokens: 'Max Context Tokens',
config_max_turns: 'Max Context Turns',
config_max_turns: 'Max Memory Turns',
config_max_steps: 'Max Steps',
skills_title: 'Skills',
skills_desc: 'View, enable, or disable agent skills',
@@ -221,3 +375,10 @@ export function setLang(lang: Lang) {
currentLang = lang
localStorage.setItem('cow_lang', lang)
}
/** Resolve a possibly-localized label ({zh,en} or plain string) for the current language. */
export function localizedLabel(label: string | { zh: string; en: string } | undefined): string {
if (!label) return ''
if (typeof label === 'string') return label
return label[currentLang] || label.en || label.zh || ''
}

View File

@@ -7,7 +7,6 @@ import {
Zap,
Radio,
Clock,
Cpu,
Settings,
PanelLeftClose,
PanelLeftOpen,
@@ -30,7 +29,6 @@ const NAV_ITEMS: NavItem[] = [
{ path: '/knowledge', labelKey: 'menu_knowledge', icon: BookOpen },
{ path: '/memory', labelKey: 'menu_memory', icon: Brain },
{ path: '/skills', labelKey: 'menu_skills', icon: Zap },
{ path: '/models', labelKey: 'menu_models', icon: Cpu },
{ path: '/channels', labelKey: 'menu_channels', icon: Radio },
{ path: '/tasks', labelKey: 'menu_tasks', icon: Clock },
{ path: '/settings', labelKey: 'menu_settings', icon: Settings },

View File

@@ -51,19 +51,44 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
}, [activeId, ensureSession, loadHistory])
const scrollToBottom = useCallback((smooth = true) => {
bottomRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' })
const el = scrollRef.current
if (!el) return
// Jump straight to the bottom; instant for session switches, smooth for streaming.
if (smooth) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
} else {
el.scrollTop = el.scrollHeight
}
}, [])
// Auto-scroll on new content while near the bottom.
// Snap to the bottom instantly when switching sessions (no top-to-bottom animation).
// History may load a frame later, so keep snapping instantly until content arrives.
const lastSessionRef = useRef('')
const lastLenRef = useRef(0)
const pendingSnapRef = useRef(false)
useEffect(() => {
const el = scrollRef.current
if (!el) return
if (lastSessionRef.current !== activeId) {
lastSessionRef.current = activeId
lastLenRef.current = messages.length
pendingSnapRef.current = true
}
if (pendingSnapRef.current) {
// Instant snap on switch and on the first content that lands afterwards.
lastLenRef.current = messages.length
requestAnimationFrame(() => scrollToBottom(false))
if (messages.length > 0) pendingSnapRef.current = false
return
}
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 160
const grew = messages.length !== lastLenRef.current
lastLenRef.current = messages.length
if (nearBottom || grew) scrollToBottom(true)
}, [messages, scrollToBottom])
}, [messages, activeId, scrollToBottom])
const handleSend = useCallback(
async (text: string, attachments: Attachment[]) => {

View File

@@ -1,288 +0,0 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { t } from '../i18n'
import apiClient from '../api/client'
import type { ConfigData } from '../types'
interface ConfigPageProps {
baseUrl: string
}
interface DropdownProps {
id: string
value: string
options: string[]
onChange: (val: string) => void
}
const Dropdown: React.FC<DropdownProps> = ({ id, value, options, onChange }) => {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
return (
<div ref={ref} id={id} className={`cfg-dropdown ${open ? 'open' : ''}`} onClick={() => setOpen(!open)}>
<div className="cfg-dropdown-selected">
<span className="cfg-dropdown-text truncate">{value || '--'}</span>
<i className="fas fa-chevron-down text-xs text-slate-400" />
</div>
<div className="cfg-dropdown-menu">
{options.map((opt) => (
<div
key={opt}
className={`cfg-dropdown-item ${opt === value ? 'active' : ''}`}
onClick={(e) => { e.stopPropagation(); onChange(opt); setOpen(false); }}
>
{opt}
</div>
))}
</div>
</div>
)
}
const ConfigPage: React.FC<ConfigPageProps> = ({ baseUrl }) => {
const [config, setConfig] = useState<ConfigData | null>(null)
const [loading, setLoading] = useState(true)
const [provider, setProvider] = useState('')
const [model, setModel] = useState('')
const [customModel, setCustomModel] = useState('')
const [showCustom, setShowCustom] = useState(false)
const [apiKey, setApiKey] = useState('')
const [apiBase, setApiBase] = useState('')
const [keyMasked, setKeyMasked] = useState(true)
const [maxTokens, setMaxTokens] = useState(50000)
const [maxTurns, setMaxTurns] = useState(20)
const [maxSteps, setMaxSteps] = useState(15)
const [modelStatus, setModelStatus] = useState('')
const [agentStatus, setAgentStatus] = useState('')
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
loadConfig()
}, [baseUrl])
const loadConfig = async () => {
try {
setLoading(true)
const data = await apiClient.getConfig()
setConfig(data)
setModel(data.model || '')
setMaxTokens(data.agent_max_context_tokens || 50000)
setMaxTurns(data.agent_max_context_turns || 20)
setMaxSteps(data.agent_max_steps || 15)
if (data.providers) {
const providerNames = Object.keys(data.providers)
if (providerNames.length > 0) {
const currentProvider = data.bot_type || providerNames[0]
setProvider(currentProvider)
const pData = (data.providers as Record<string, any>)[currentProvider]
if (pData) {
setApiKey(pData.api_key || '')
setApiBase(pData.api_base || '')
}
}
}
} catch (err) {
console.error('Failed to load config:', err)
} finally {
setLoading(false)
}
}
const getModels = useCallback((): string[] => {
if (!config?.providers || !provider) return []
const pData = (config.providers as Record<string, any>)[provider]
const models = pData?.models || []
return [...models, t('config_custom_option')]
}, [config, provider])
const handleProviderChange = (val: string) => {
setProvider(val)
if (config?.providers) {
const pData = (config.providers as Record<string, any>)[val]
if (pData) {
setApiKey(pData.api_key || '')
setApiBase(pData.api_base || '')
const models = pData.models || []
if (models.length > 0) setModel(models[0])
}
}
setShowCustom(false)
setCustomModel('')
}
const handleModelChange = (val: string) => {
if (val === t('config_custom_option')) {
setShowCustom(true)
setModel('')
} else {
setShowCustom(false)
setModel(val)
setCustomModel('')
}
}
const saveModelConfig = async () => {
try {
const finalModel = showCustom ? customModel : model
await apiClient.updateConfig({
model: finalModel,
bot_type: provider,
api_keys: { [provider]: apiKey },
api_bases: { [provider]: apiBase },
} as any)
setModelStatus(t('config_saved'))
setTimeout(() => setModelStatus(''), 2000)
} catch {
setModelStatus(t('config_save_error'))
setTimeout(() => setModelStatus(''), 2000)
}
}
const saveAgentConfig = async () => {
try {
await apiClient.updateConfig({
agent_max_context_tokens: maxTokens,
agent_max_context_turns: maxTurns,
agent_max_steps: maxSteps,
} as any)
setAgentStatus(t('config_saved'))
setTimeout(() => setAgentStatus(''), 2000)
} catch {
setAgentStatus(t('config_save_error'))
setTimeout(() => setAgentStatus(''), 2000)
}
}
if (loading) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-slate-400"><i className="fas fa-spinner fa-spin mr-2" />Loading...</div>
</div>
)
}
const providers = config?.providers ? Object.keys(config.providers) : []
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-100">{t('config_title')}</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{t('config_desc')}</p>
</div>
</div>
<div className="grid gap-6">
{/* Model Config Card */}
<div className="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6">
<div className="flex items-center gap-3 mb-5">
<div className="w-9 h-9 rounded-lg bg-primary-50 dark:bg-primary-900/30 flex items-center justify-center">
<i className="fas fa-microchip text-primary-500 text-sm" />
</div>
<h3 className="font-semibold text-slate-800 dark:text-slate-100">{t('config_model')}</h3>
</div>
<div className="space-y-5">
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">{t('config_provider')}</label>
<Dropdown id="cfg-provider" value={provider} options={providers} onChange={handleProviderChange} />
</div>
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">{t('config_model_name')}</label>
<Dropdown id="cfg-model" value={showCustom ? t('config_custom_option') : model} options={getModels()} onChange={handleModelChange} />
{showCustom && (
<input
type="text" value={customModel} onChange={(e) => setCustomModel(e.target.value)}
className="mt-2 w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 font-mono transition-colors"
placeholder={t('config_custom_model_hint')}
/>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Key</label>
<div className="relative">
<input
type="text" value={apiKey} onChange={(e) => setApiKey(e.target.value)}
className={`w-full px-3 py-2 pr-10 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 font-mono transition-colors ${keyMasked ? 'cfg-key-masked' : ''}`}
placeholder="sk-..."
/>
<button
type="button" onClick={() => setKeyMasked(!keyMasked)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 cursor-pointer transition-colors p-1"
>
<i className={`fas ${keyMasked ? 'fa-eye' : 'fa-eye-slash'} text-xs`} />
</button>
</div>
</div>
{apiBase && (
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Base</label>
<input
type="text" value={apiBase} onChange={(e) => setApiBase(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 font-mono transition-colors"
placeholder="https://..."
/>
</div>
)}
<div className="flex items-center justify-end gap-3 pt-1">
<span className={`text-xs text-primary-500 transition-opacity duration-300 ${modelStatus ? 'opacity-100' : 'opacity-0'}`}>{modelStatus}</span>
<button onClick={saveModelConfig}
className="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium cursor-pointer transition-colors duration-150">
{t('config_save')}
</button>
</div>
</div>
</div>
{/* Agent Config Card */}
<div className="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6">
<div className="flex items-center gap-3 mb-5">
<div className="w-9 h-9 rounded-lg bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center">
<i className="fas fa-robot text-emerald-500 text-sm" />
</div>
<h3 className="font-semibold text-slate-800 dark:text-slate-100">{t('config_agent')}</h3>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">{t('config_max_tokens')}</label>
<input type="number" min={1000} max={200000} step={1000} value={maxTokens}
onChange={(e) => setMaxTokens(parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 font-mono transition-colors" />
</div>
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">{t('config_max_turns')}</label>
<input type="number" min={1} max={100} step={1} value={maxTurns}
onChange={(e) => setMaxTurns(parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 font-mono transition-colors" />
</div>
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">{t('config_max_steps')}</label>
<input type="number" min={1} max={50} step={1} value={maxSteps}
onChange={(e) => setMaxSteps(parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 font-mono transition-colors" />
</div>
<div className="flex items-center justify-end gap-3 pt-1">
<span className={`text-xs text-primary-500 transition-opacity duration-300 ${agentStatus ? 'opacity-100' : 'opacity-0'}`}>{agentStatus}</span>
<button onClick={saveAgentConfig}
className="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium cursor-pointer transition-colors duration-150">
{t('config_save')}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export default ConfigPage

View File

@@ -0,0 +1,60 @@
import React, { useState } from 'react'
import { useLocation } from 'react-router-dom'
import { t } from '../i18n'
import BasicSettings from './settings/BasicSettings'
import ModelsTab from './settings/ModelsTab'
interface SettingsPageProps {
baseUrl: string
onLangChange?: () => void
}
type Tab = 'basic' | 'models'
const SettingsPage: React.FC<SettingsPageProps> = ({ baseUrl, onLangChange }) => {
const location = useLocation()
// Allow deep-linking to the models tab via /settings?tab=models.
const initial: Tab = new URLSearchParams(location.search).get('tab') === 'models' ? 'models' : 'basic'
const [tab, setTab] = useState<Tab>(initial)
const tabs: { key: Tab; label: string }[] = [
{ key: 'basic', label: t('settings_tab_basic') },
{ key: 'models', label: t('settings_tab_models') },
]
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-3xl mx-auto">
<div className="mb-5">
<h2 className="text-xl font-bold text-content">{t('menu_settings')}</h2>
<p className="text-sm text-content-tertiary mt-1">{t('config_desc')}</p>
</div>
{/* Tab bar */}
<div className="flex items-center gap-1 mb-6 border-b border-default">
{tabs.map((tb) => (
<button
key={tb.key}
onClick={() => setTab(tb.key)}
className={`relative px-4 py-2.5 text-sm font-medium cursor-pointer transition-colors -mb-px border-b-2 ${
tab === tb.key
? 'text-accent border-accent'
: 'text-content-tertiary border-transparent hover:text-content-secondary'
}`}
>
{tb.label}
</button>
))}
</div>
{tab === 'basic' ? (
<BasicSettings baseUrl={baseUrl} onLangChange={onLangChange} onOpenModels={() => setTab('models')} />
) : (
<ModelsTab baseUrl={baseUrl} />
)}
</div>
</div>
)
}
export default SettingsPage

View File

@@ -0,0 +1,331 @@
import React, { useState, useEffect } from 'react'
import { Cpu, Bot, ShieldCheck, Languages, Eye, EyeOff, ArrowRight, Loader2 } from 'lucide-react'
import { t, getLang, setLang, localizedLabel, type Lang } from '../../i18n'
import apiClient from '../../api/client'
import type { ConfigData, ProviderMeta } from '../../types'
import { Card, Field, Dropdown, Toggle, TextInput, SaveRow, MASK_RE } from './primitives'
interface BasicSettingsProps {
baseUrl: string
onLangChange?: () => void
onOpenModels?: () => void
}
const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, onOpenModels }) => {
const [config, setConfig] = useState<ConfigData | null>(null)
const [loading, setLoading] = useState(true)
// model card — credentials (key/base) now live in the Models tab
const [provider, setProvider] = useState('')
const [model, setModel] = useState('')
const [customModel, setCustomModel] = useState('')
const [showCustom, setShowCustom] = useState(false)
const [modelStatus, setModelStatus] = useState('')
// agent card
const [maxTokens, setMaxTokens] = useState(100000)
const [maxTurns, setMaxTurns] = useState(20)
const [maxSteps, setMaxSteps] = useState(20)
const [thinking, setThinking] = useState(false)
const [evolution, setEvolution] = useState(false)
const [agentStatus, setAgentStatus] = useState('')
// security card
const [password, setPassword] = useState('')
const [pwDirty, setPwDirty] = useState(false)
const [pwVisible, setPwVisible] = useState(false)
const [pwStatus, setPwStatus] = useState('')
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
loadConfig()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [baseUrl])
const providerMeta = (id: string): ProviderMeta | undefined => config?.providers?.[id] as ProviderMeta | undefined
const loadConfig = async () => {
try {
setLoading(true)
const data = await apiClient.getConfig()
setConfig(data)
setModel(data.model || '')
setMaxTokens(data.agent_max_context_tokens ?? 100000)
setMaxTurns(data.agent_max_context_turns ?? 20)
setMaxSteps(data.agent_max_steps ?? 20)
setThinking(!!data.enable_thinking)
setEvolution(!!data.self_evolution_enabled)
setPassword(data.web_password_masked || '')
setPwDirty(false)
const ids = data.providers ? Object.keys(data.providers) : []
const current = data.use_linkai ? 'linkai' : data.bot_type || ids[0] || ''
setProvider(current)
const meta = data.providers?.[current] as ProviderMeta | undefined
const presets = meta?.models || []
if (data.model && presets.length && !presets.includes(data.model)) {
setShowCustom(true)
setCustomModel(data.model)
}
} catch (err) {
console.error('Failed to load config:', err)
} finally {
setLoading(false)
}
}
const handleProviderChange = (id: string) => {
setProvider(id)
setShowCustom(false)
setCustomModel('')
if (config) {
const meta = config.providers?.[id] as ProviderMeta | undefined
const models = meta?.models || []
setModel(models[0] || '')
}
}
const handleModelChange = (val: string) => {
if (val === '__custom__') {
setShowCustom(true)
setModel('')
} else {
setShowCustom(false)
setModel(val)
setCustomModel('')
}
}
const saveModelConfig = async () => {
const finalModel = showCustom ? customModel.trim() : model
const isLinkai = provider === 'linkai'
try {
await apiClient.updateConfig({
model: finalModel,
use_linkai: isLinkai,
bot_type: isLinkai ? '' : provider,
})
setModelStatus(t('config_saved'))
const fresh = await apiClient.getConfig()
setConfig(fresh)
} catch {
setModelStatus(t('config_save_error'))
}
setTimeout(() => setModelStatus(''), 2000)
}
const saveAgentConfig = async () => {
try {
await apiClient.updateConfig({
agent_max_context_tokens: maxTokens,
agent_max_context_turns: maxTurns,
agent_max_steps: maxSteps,
enable_thinking: thinking,
self_evolution_enabled: evolution,
})
setAgentStatus(t('config_saved'))
} catch {
setAgentStatus(t('config_save_error'))
}
setTimeout(() => setAgentStatus(''), 2000)
}
const savePassword = async () => {
if (!pwDirty || MASK_RE.test(password)) return
try {
await apiClient.updateConfig({ web_password: password })
setPwStatus(password ? t('config_password_saved') : t('config_password_cleared'))
setPwDirty(false)
} catch {
setPwStatus(t('config_save_error'))
}
setTimeout(() => setPwStatus(''), 3000)
}
const changeLanguage = async (lang: Lang) => {
setLang(lang)
onLangChange?.()
try {
await apiClient.updateConfig({ cow_lang: lang })
} catch {
/* non-blocking */
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-content-tertiary">
<Loader2 size={18} className="animate-spin mr-2" />
{t('skills_loading')}
</div>
)
}
// A provider counts as configured when its key field holds a value.
// Custom providers (no key field) carry their own credential, so treat as configured.
const isConfigured = (id: string): boolean => {
const meta = providerMeta(id)
const f = meta?.api_key_field
if (!f) return true
return !!config?.api_keys?.[f]
}
const providerIds = config?.providers ? Object.keys(config.providers) : []
const providerOptions = providerIds.map((id) => ({
value: id,
label: localizedLabel(providerMeta(id)?.label) || id,
hint: isConfigured(id) ? undefined : t('config_provider_unconfigured'),
}))
const currentMeta = providerMeta(provider)
const currentUnconfigured = !!provider && !isConfigured(provider)
const modelOptions = [
...(currentMeta?.models || []).map((m) => ({ value: m, label: m })),
{ value: '__custom__', label: t('config_custom_option') },
]
return (
<div className="grid gap-5">
{/* Model — provider/model selection only; credentials live in Models tab */}
<Card icon={<Cpu size={16} />} title={t('config_model')}>
<div className="space-y-4">
<Field label={t('config_provider')}>
<Dropdown value={provider} options={providerOptions} onChange={handleProviderChange} />
</Field>
<Field label={t('config_model_name')}>
<Dropdown value={showCustom ? '__custom__' : model} options={modelOptions} onChange={handleModelChange} />
{showCustom && (
<TextInput
className="mt-2 font-mono"
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
placeholder={t('config_custom_model_hint')}
/>
)}
</Field>
{/* Guide users to the Models tab for API key / base config.
When the selected provider has no credentials, surface a warning. */}
{onOpenModels && (
<button
onClick={onOpenModels}
className={`w-full flex items-center justify-between gap-2 rounded-btn border px-3 py-2.5 cursor-pointer transition-colors text-left ${
currentUnconfigured
? 'border-danger-border bg-danger-soft hover:border-danger'
: 'border-default bg-inset hover:border-accent'
}`}
>
<span className={`text-xs ${currentUnconfigured ? 'text-danger' : 'text-content-tertiary'}`}>
{currentUnconfigured ? t('config_provider_unconfigured_hint') : t('config_credentials_link')}
</span>
<span
className={`flex-shrink-0 inline-flex items-center gap-1 text-xs ${
currentUnconfigured ? 'text-danger font-medium' : 'text-accent'
}`}
>
{t('config_goto_models')}
<ArrowRight size={13} />
</span>
</button>
)}
<SaveRow status={modelStatus} onSave={saveModelConfig} />
</div>
</Card>
{/* Agent */}
<Card icon={<Bot size={16} />} title={t('config_agent')}>
<div className="space-y-4">
<Field label={t('config_max_tokens')} hint={t('config_max_tokens_hint')}>
<TextInput
type="number"
className="font-mono"
value={maxTokens}
onChange={(e) => setMaxTokens(parseInt(e.target.value) || 0)}
/>
</Field>
<Field label={t('config_max_turns')} hint={t('config_max_turns_hint')}>
<TextInput
type="number"
className="font-mono"
value={maxTurns}
onChange={(e) => setMaxTurns(parseInt(e.target.value) || 0)}
/>
</Field>
<Field label={t('config_max_steps')} hint={t('config_max_steps_hint')}>
<TextInput
type="number"
className="font-mono"
value={maxSteps}
onChange={(e) => setMaxSteps(parseInt(e.target.value) || 0)}
/>
</Field>
<div className="flex items-center justify-between py-1">
<div>
<div className="text-sm font-medium text-content">{t('config_thinking')}</div>
<div className="text-xs text-content-tertiary mt-0.5">{t('config_thinking_hint')}</div>
</div>
<Toggle checked={thinking} onChange={setThinking} />
</div>
<div className="flex items-center justify-between py-1">
<div>
<div className="text-sm font-medium text-content">{t('config_evolution')}</div>
<div className="text-xs text-content-tertiary mt-0.5">{t('config_evolution_hint')}</div>
</div>
<Toggle checked={evolution} onChange={setEvolution} />
</div>
<SaveRow status={agentStatus} onSave={saveAgentConfig} />
</div>
</Card>
{/* Security */}
<Card icon={<ShieldCheck size={16} />} title={t('config_security')}>
<div className="space-y-4">
<Field label={t('config_password')} hint={t('config_password_hint')}>
<div className="relative">
<TextInput
type={pwVisible ? 'text' : 'password'}
className="pr-10"
value={password}
placeholder={t('config_password_placeholder')}
onFocus={() => {
if (!pwDirty && MASK_RE.test(password)) setPassword('')
}}
onBlur={() => {
if (!pwDirty) setPassword(config?.web_password_masked || '')
}}
onChange={(e) => {
setPassword(e.target.value)
setPwDirty(true)
}}
/>
<button
type="button"
onClick={() => setPwVisible((v) => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-content-tertiary hover:text-content-secondary cursor-pointer p-1"
>
{pwVisible ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</Field>
<SaveRow status={pwStatus} onSave={savePassword} />
</div>
</Card>
{/* Language */}
<Card icon={<Languages size={16} />} title={t('config_language')}>
<Field label={t('config_language')} hint={t('config_language_hint')}>
<Dropdown
value={getLang()}
options={[
{ value: 'zh', label: '简体中文' },
{ value: 'en', label: 'English' },
]}
onChange={(v) => changeLanguage(v as Lang)}
/>
</Field>
</Card>
</div>
)
}
export default BasicSettings

View File

@@ -0,0 +1,139 @@
import React, { useMemo, useState } from 'react'
import type { LucideIcon } from 'lucide-react'
import { Loader2 } from 'lucide-react'
import { t } from '../../i18n'
import type { CapabilityState, ModelsData } from '../../types'
import { Card, Field, Dropdown, TextInput, type DropdownOption } from './primitives'
import { resolveModels, providerLabel, CUSTOM_OPTION } from './modelsHelpers'
// Generic provider+model capability card used by chat/vision/asr/embedding/image.
// tts (voice) and search have bespoke cards.
export interface CapabilityCardProps {
icon: LucideIcon
title: string
subtitle?: string
capKey: string
state: CapabilityState
data: ModelsData | null
// whether picking "no provider" (auto / disabled) is allowed
allowAuto?: boolean
autoLabel?: string
// whether to allow a free-form custom model entry
allowCustomModel?: boolean
busy?: boolean
status?: string
onSave: (providerId: string, model: string) => void
children?: React.ReactNode
}
const CapabilityCard: React.FC<CapabilityCardProps> = ({
icon: Icon,
title,
subtitle,
state,
data,
allowAuto,
autoLabel,
allowCustomModel,
busy,
status,
onSave,
children,
}) => {
const [provider, setProvider] = useState(state.current_provider || '')
const [model, setModel] = useState(state.current_model || '')
const [customModel, setCustomModel] = useState('')
const [showCustom, setShowCustom] = useState(false)
const providerOptions: DropdownOption[] = useMemo(() => {
const opts = (state.providers || []).map((id) => ({ value: id, label: providerLabel(data, id) }))
if (allowAuto) return [{ value: '', label: autoLabel || t('models_auto') }, ...opts]
return opts
}, [state.providers, data, allowAuto, autoLabel])
const modelOptions: DropdownOption[] = useMemo(() => {
const list = resolveModels(data, provider, state.provider_models).map((o) => ({
value: o.value,
label: o.value,
hint: o.hint,
}))
// Keep the currently-saved model selectable even if it's not in the preset list.
if (model && !showCustom && !list.some((o) => o.value === model)) {
list.unshift({ value: model, label: model, hint: undefined })
}
if (allowCustomModel) list.push({ value: CUSTOM_OPTION, label: t('config_custom_option'), hint: undefined })
return list
}, [data, state.provider_models, provider, allowCustomModel, model, showCustom])
const handleProvider = (id: string) => {
setProvider(id)
setShowCustom(false)
setCustomModel('')
const first = resolveModels(data, id, state.provider_models)[0]
setModel(first?.value || '')
}
const handleModel = (val: string) => {
if (val === CUSTOM_OPTION) {
setShowCustom(true)
setModel('')
} else {
setShowCustom(false)
setModel(val)
setCustomModel('')
}
}
const finalModel = showCustom ? customModel.trim() : model
const isAuto = allowAuto && !provider
return (
<Card icon={<Icon size={16} />} title={title} subtitle={subtitle}>
<div className="space-y-4">
<Field label={t('models_provider')}>
<Dropdown
value={provider}
options={providerOptions}
placeholder={t('models_select_provider')}
onChange={handleProvider}
/>
</Field>
{!isAuto && (
<Field label={t('models_model')}>
<Dropdown
value={showCustom ? CUSTOM_OPTION : model}
options={modelOptions}
placeholder={t('models_select_model')}
onChange={handleModel}
/>
{showCustom && (
<TextInput
className="mt-2 font-mono"
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
placeholder={t('config_custom_model_hint')}
/>
)}
</Field>
)}
{children}
<div className="flex items-center justify-end gap-3 pt-1">
<span className={`text-xs text-accent transition-opacity ${status ? 'opacity-100' : 'opacity-0'}`}>
{status}
</span>
<button
disabled={busy}
onClick={() => onSave(provider, finalModel)}
className="px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 inline-flex items-center gap-2"
>
{busy && <Loader2 size={14} className="animate-spin" />}
{t('config_save')}
</button>
</div>
</div>
</Card>
)
}
export default CapabilityCard

View File

@@ -0,0 +1,833 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import {
MessageSquare,
Eye,
Image as ImageIcon,
Mic,
Volume2,
Database,
Search as SearchIcon,
Plus,
Check,
Loader2,
Pencil,
Eye as EyeIcon,
EyeOff,
} from 'lucide-react'
import { t, localizedLabel } from '../../i18n'
import apiClient from '../../api/client'
import type { CapabilityState, ModelsData, ModelProvider, SearchCapabilityState } from '../../types'
import { Card, Field, Dropdown, TextInput, Modal, Btn, MASK_RE } from './primitives'
import CapabilityCard from './CapabilityCard'
import { normEntries, providerLabel, resolveVoices, CUSTOM_OPTION } from './modelsHelpers'
interface ModelsTabProps {
baseUrl: string
}
const REPLY_MODES: { value: 'off' | 'voice_if_voice' | 'always'; key: string }[] = [
{ value: 'off', key: 'models_tts_mode_off' },
{ value: 'voice_if_voice', key: 'models_tts_mode_if_voice' },
{ value: 'always', key: 'models_tts_mode_always' },
]
const ModelsTab: React.FC<ModelsTabProps> = ({ baseUrl }) => {
const [data, setData] = useState<ModelsData | null>(null)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState<string>('') // capability key currently saving
const [statusMap, setStatusMap] = useState<Record<string, string>>({})
const load = useCallback(async () => {
try {
const fresh = await apiClient.getModels()
setData(fresh)
} catch (e) {
console.error('Failed to load models:', e)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
load()
}, [baseUrl, load])
const flash = (key: string, msg: string) => {
setStatusMap((m) => ({ ...m, [key]: msg }))
setTimeout(() => setStatusMap((m) => ({ ...m, [key]: '' })), 2000)
}
// Run a models action, then refresh and flash a status for the given key.
const run = async (key: string, action: Parameters<typeof apiClient.modelsAction>[0]) => {
setBusy(key)
try {
const res = await apiClient.modelsAction(action)
if (res.status === 'success') {
await load()
flash(key, t('config_saved'))
} else {
flash(key, (res.message as string) || t('config_save_error'))
}
} catch {
flash(key, t('config_save_error'))
} finally {
setBusy('')
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-content-tertiary">
<Loader2 size={18} className="animate-spin mr-2" />
{t('skills_loading')}
</div>
)
}
if (!data) {
return <div className="text-center py-20 text-content-tertiary">{t('config_save_error')}</div>
}
const caps = data.capabilities
return (
<div className="grid gap-5">
<VendorSection data={data} onChanged={load} statusMap={statusMap} flash={flash} />
{/* Chat */}
<CapabilityCard
icon={MessageSquare}
title={t('models_cap_chat')}
subtitle={t('models_cap_chat_sub')}
capKey="chat"
state={caps.chat}
data={data}
allowCustomModel
busy={busy === 'chat'}
status={statusMap.chat}
onSave={(p, m) => run('chat', { action: 'set_capability', capability: 'chat', provider_id: p, model: m })}
/>
{/* Vision */}
<CapabilityCard
icon={Eye}
title={t('models_cap_vision')}
subtitle={t('models_cap_vision_sub')}
capKey="vision"
state={caps.vision}
data={data}
allowAuto
autoLabel={t('models_auto')}
busy={busy === 'vision'}
status={statusMap.vision}
onSave={(p, m) => run('vision', { action: 'set_capability', capability: 'vision', provider_id: p, model: m })}
>
<FallbackHint state={caps.vision} data={data} />
</CapabilityCard>
{/* Image */}
<CapabilityCard
icon={ImageIcon}
title={t('models_cap_image')}
subtitle={t('models_cap_image_sub')}
capKey="image"
state={caps.image}
data={data}
allowAuto
autoLabel={t('models_auto')}
busy={busy === 'image'}
status={statusMap.image}
onSave={(p, m) => run('image', { action: 'set_capability', capability: 'image', provider_id: p, model: m })}
>
<FallbackHint state={caps.image} data={data} />
</CapabilityCard>
{/* ASR */}
<CapabilityCard
icon={Mic}
title={t('models_cap_asr')}
subtitle={t('models_cap_asr_sub')}
capKey="asr"
state={caps.asr}
data={data}
allowAuto
autoLabel={t('models_asr_auto')}
busy={busy === 'asr'}
status={statusMap.asr}
onSave={(p, m) => run('asr', { action: 'set_capability', capability: 'asr', provider_id: p, model: m })}
/>
{/* TTS — bespoke (voice + reply mode) */}
<TtsCard
state={caps.tts}
data={data}
busy={busy === 'tts'}
status={statusMap.tts}
onSaveVoice={(p, m, v) =>
run('tts', { action: 'set_capability', capability: 'tts', provider_id: p, model: m, voice: v })
}
onSaveMode={(mode) => run('tts_mode', { action: 'set_voice_reply_mode', mode })}
modeStatus={statusMap.tts_mode}
modeBusy={busy === 'tts_mode'}
/>
{/* Embedding */}
<EmbeddingCard
state={caps.embedding}
data={data}
busy={busy === 'embedding'}
status={statusMap.embedding}
onSave={(p, m) => run('embedding', { action: 'set_capability', capability: 'embedding', provider_id: p, model: m })}
/>
{/* Search — bespoke */}
<SearchCard
state={caps.search}
busy={busy === 'search'}
status={statusMap.search}
onSaveStrategy={(strategy, provider) =>
run('search', { action: 'set_capability', capability: 'search', strategy, provider })
}
onSaveBochaKey={(key) => run('search_key', { action: 'set_search_credential', api_key: key })}
keyStatus={statusMap.search_key}
keyBusy={busy === 'search_key'}
/>
</div>
)
}
// ============================================================
// Layer 1 — vendor credentials
// ============================================================
interface VendorSectionProps {
data: ModelsData
onChanged: () => Promise<void>
statusMap: Record<string, string>
flash: (key: string, msg: string) => void
}
const VendorSection: React.FC<VendorSectionProps> = ({ data, onChanged }) => {
// Edit an existing built-in vendor.
const [editing, setEditing] = useState<ModelProvider | null>(null)
// Add flow: open the vendor modal with a provider picker.
const [adding, setAdding] = useState(false)
// Custom provider modal: 'new' to create, or a provider to edit.
const [customEditing, setCustomEditing] = useState<ModelProvider | 'new' | null>(null)
const isCustomCard = (p: ModelProvider) => p.is_custom && !!p.custom_name
// Unified grid: configured built-ins + all custom provider cards (web parity).
const shown = data.providers.filter((p) => p.configured || isCustomCard(p))
return (
<Card icon={<Database size={16} />} title={t('models_vendors')} subtitle={t('models_vendors_sub')}>
{shown.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 rounded-btn border border-dashed border-default">
<p className="text-sm text-content-tertiary">{t('models_no_vendor')}</p>
<button
onClick={() => setAdding(true)}
className="mt-3 inline-flex items-center gap-1 px-3 py-1.5 rounded-btn text-xs font-medium bg-accent-soft text-accent hover:bg-accent-soft/70 cursor-pointer transition-colors"
>
<Plus size={12} /> {t('models_add_vendor')}
</button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
{shown.map((p) =>
isCustomCard(p) ? (
<VendorChip key={p.id} provider={p} onClick={() => setCustomEditing(p)} />
) : (
<VendorChip key={p.id} provider={p} onClick={() => setEditing(p)} />
)
)}
<button
onClick={() => setAdding(true)}
className="flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-btn border border-dashed border-default text-content-tertiary hover:border-accent hover:text-accent cursor-pointer transition-colors text-sm"
>
<Plus size={14} /> {t('models_add_vendor')}
</button>
</div>
)}
<VendorModal
provider={editing}
addMode={adding}
data={data}
onClose={() => {
setEditing(null)
setAdding(false)
}}
onPickCustom={() => {
setAdding(false)
setCustomEditing('new')
}}
onSaved={onChanged}
/>
<CustomProviderModal target={customEditing} onClose={() => setCustomEditing(null)} onSaved={onChanged} />
</Card>
)
}
const VendorChip: React.FC<{ provider: ModelProvider; onClick: () => void }> = ({ provider, onClick }) => (
<button
onClick={onClick}
className="group flex items-center gap-2.5 px-3 py-2.5 rounded-btn border border-default bg-inset hover:border-accent cursor-pointer transition-colors text-left"
>
<span className="flex-shrink-0 w-7 h-7 rounded-lg bg-surface-2 text-content-secondary flex items-center justify-center text-xs font-bold">
{(localizedLabel(provider.label) || provider.id || '?').slice(0, 1).toUpperCase()}
</span>
<span className="flex-1 min-w-0 text-sm font-medium text-content truncate">{localizedLabel(provider.label)}</span>
<Pencil size={12} className="flex-shrink-0 text-content-tertiary group-hover:text-accent transition-colors" />
</button>
)
const CUSTOM_PICK = '__custom_new__'
const VendorModal: React.FC<{
provider: ModelProvider | null
addMode: boolean
data: ModelsData
onClose: () => void
onPickCustom: () => void
onSaved: () => Promise<void>
}> = ({ provider, addMode, data, onClose, onPickCustom, onSaved }) => {
const open = !!provider || addMode
// In add-mode the user first picks a built-in provider; that selection
// becomes the effective provider whose key/base fields we edit.
const builtins = useMemo(() => data.providers.filter((p) => !(p.is_custom && p.custom_name)), [data.providers])
const firstUnconfigured = builtins.find((p) => !p.configured) || builtins[0]
const [pickId, setPickId] = useState('')
const effective: ModelProvider | undefined = provider || builtins.find((p) => p.id === pickId)
const [apiKey, setApiKey] = useState('')
const [keyDirty, setKeyDirty] = useState(false)
const [keyVisible, setKeyVisible] = useState(false)
const [apiBase, setApiBase] = useState('')
const [saving, setSaving] = useState(false)
// Load fields whenever the effective provider changes.
useEffect(() => {
if (!open) return
const init = provider || (addMode ? firstUnconfigured : undefined)
setPickId(provider ? provider.id : firstUnconfigured?.id || '')
setApiKey(init?.api_key_masked || '')
setApiBase(init?.api_base || '')
setKeyDirty(false)
setKeyVisible(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider, addMode, open])
if (!open) return null
const pickOptions = [
...builtins.map((p) => ({
value: p.id,
label: localizedLabel(p.label),
hint: p.configured ? t('models_configured') : undefined,
})),
{ value: CUSTOM_PICK, label: t('models_custom_vendor'), hint: t('models_add_custom_hint') },
]
const onPick = (val: string) => {
if (val === CUSTOM_PICK) {
onPickCustom()
return
}
setPickId(val)
const p = builtins.find((x) => x.id === val)
setApiKey(p?.api_key_masked || '')
setApiBase(p?.api_base || '')
setKeyDirty(false)
}
const hasBase = !!effective?.api_base_field
const save = async () => {
if (!effective) return
setSaving(true)
try {
const payload: { action: 'set_provider'; provider_id: string; api_key?: string; api_base?: string } = {
action: 'set_provider',
provider_id: effective.id,
}
if (keyDirty && apiKey && !MASK_RE.test(apiKey)) payload.api_key = apiKey
if (hasBase) payload.api_base = apiBase
await apiClient.modelsAction(payload)
await onSaved()
onClose()
} finally {
setSaving(false)
}
}
const clear = async () => {
if (!effective || !confirm(t('models_clear_confirm'))) return
setSaving(true)
try {
await apiClient.modelsAction({ action: 'delete_provider', provider_id: effective.id })
await onSaved()
onClose()
} finally {
setSaving(false)
}
}
return (
<Modal
open={open}
title={addMode ? t('models_add_vendor') : localizedLabel(effective?.label)}
onClose={onClose}
footer={
<>
{!addMode && effective?.configured && (
<Btn variant="danger" onClick={clear} disabled={saving}>
{t('models_clear')}
</Btn>
)}
<Btn variant="ghost" onClick={onClose}>
{t('config_cancel')}
</Btn>
<Btn variant="primary" onClick={save} disabled={saving || !effective}>
{saving ? <Loader2 size={14} className="animate-spin" /> : t('config_save')}
</Btn>
</>
}
>
{addMode && (
<Field label={t('models_provider')}>
<Dropdown value={pickId} options={pickOptions} onChange={onPick} />
</Field>
)}
<Field label="API Key">
<div className="relative">
<TextInput
type={keyVisible ? 'text' : 'password'}
className="pr-10 font-mono"
value={apiKey}
placeholder="sk-..."
onFocus={() => {
if (!keyDirty && MASK_RE.test(apiKey)) setApiKey('')
}}
onBlur={() => {
if (!keyDirty) setApiKey(effective?.api_key_masked || '')
}}
onChange={(e) => {
setApiKey(e.target.value)
setKeyDirty(true)
}}
/>
<button
type="button"
onClick={() => setKeyVisible((v) => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-content-tertiary hover:text-content-secondary cursor-pointer p-1"
>
{keyVisible ? <EyeOff size={14} /> : <EyeIcon size={14} />}
</button>
</div>
</Field>
{hasBase && (
<Field label="API Base">
<TextInput
className="font-mono"
value={apiBase}
onChange={(e) => setApiBase(e.target.value)}
placeholder={effective?.api_base_placeholder || 'https://...'}
/>
</Field>
)}
</Modal>
)
}
const CustomProviderModal: React.FC<{
target: ModelProvider | 'new' | null
onClose: () => void
onSaved: () => Promise<void>
}> = ({ target, onClose, onSaved }) => {
const editing = target && target !== 'new' ? target : null
const [name, setName] = useState('')
const [apiBase, setApiBase] = useState('')
const [apiKey, setApiKey] = useState('')
const [keyDirty, setKeyDirty] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!target) return
if (editing) {
setName(editing.custom_name || localizedLabel(editing.label))
setApiBase(editing.api_base || '')
setApiKey(editing.api_key_masked || '')
} else {
setName('')
setApiBase('')
setApiKey('')
}
setKeyDirty(false)
}, [target, editing])
if (!target) return null
const save = async () => {
if (!name.trim()) return
setSaving(true)
try {
const payload: {
action: 'set_custom_provider'
name: string
id?: string
api_base: string
api_key?: string
} = {
action: 'set_custom_provider',
name: name.trim(),
api_base: apiBase.trim(),
}
if (editing) payload.id = editing.custom_id
if (keyDirty && apiKey && !MASK_RE.test(apiKey)) payload.api_key = apiKey
await apiClient.modelsAction(payload)
await onSaved()
onClose()
} finally {
setSaving(false)
}
}
const remove = async () => {
if (!editing || !confirm(t('models_delete_confirm'))) return
setSaving(true)
try {
await apiClient.modelsAction({ action: 'delete_custom_provider', id: editing.custom_id || '' })
await onSaved()
onClose()
} finally {
setSaving(false)
}
}
return (
<Modal
open={!!target}
title={editing ? t('models_edit_custom') : t('models_add_custom')}
onClose={onClose}
footer={
<>
{editing && (
<Btn variant="danger" onClick={remove} disabled={saving}>
{t('models_delete')}
</Btn>
)}
<Btn variant="ghost" onClick={onClose}>
{t('config_cancel')}
</Btn>
<Btn variant="primary" onClick={save} disabled={saving || !name.trim() || (!editing && !apiBase.trim())}>
{saving ? <Loader2 size={14} className="animate-spin" /> : t('config_save')}
</Btn>
</>
}
>
<Field label={t('models_custom_name')}>
<TextInput value={name} onChange={(e) => setName(e.target.value)} placeholder="My Provider" />
</Field>
<Field label="API Base" hint={t('models_custom_base_hint')}>
<TextInput
className="font-mono"
value={apiBase}
onChange={(e) => setApiBase(e.target.value)}
placeholder="https://...../v1"
/>
</Field>
<Field label="API Key">
<TextInput
type="text"
className="font-mono"
value={apiKey}
placeholder="sk-..."
onFocus={() => {
if (!keyDirty && MASK_RE.test(apiKey)) setApiKey('')
}}
onChange={(e) => {
setApiKey(e.target.value)
setKeyDirty(true)
}}
/>
</Field>
</Modal>
)
}
// ============================================================
// Bespoke capability cards
// ============================================================
const FallbackHint: React.FC<{ state: CapabilityState; data: ModelsData }> = ({ state, data }) => {
if (!state.fallback_provider && !state.fallback_model) return null
const label = providerLabel(data, state.fallback_provider || '')
return (
<p className="text-xs text-content-tertiary">
{t('models_fallback')}: {label} {state.fallback_model ? `· ${state.fallback_model}` : ''}
</p>
)
}
const TtsCard: React.FC<{
state: CapabilityState
data: ModelsData
busy: boolean
status?: string
onSaveVoice: (provider: string, model: string, voice: string) => void
onSaveMode: (mode: 'off' | 'voice_if_voice' | 'always') => void
modeStatus?: string
modeBusy: boolean
}> = ({ state, data, busy, status, onSaveVoice, onSaveMode, modeStatus, modeBusy }) => {
const [provider, setProvider] = useState(state.current_provider || '')
const [model, setModel] = useState(state.current_model || '')
const [voice, setVoice] = useState(state.current_voice || '')
const [mode, setMode] = useState<'off' | 'voice_if_voice' | 'always'>(state.reply_mode || 'off')
const providerOptions = (state.providers || []).map((id) => ({ value: id, label: providerLabel(data, id) }))
const modelOptions = normEntries(state.provider_models?.[provider]).map((o) => ({
value: o.value,
label: o.value,
hint: o.hint,
}))
const voiceOptions = resolveVoices(provider, model, state.provider_voices).map((o) => ({
value: o.value,
label: o.value,
hint: o.hint,
}))
const handleProvider = (id: string) => {
setProvider(id)
const first = normEntries(state.provider_models?.[id])[0]
const fm = first?.value || ''
setModel(fm)
setVoice(resolveVoices(id, fm, state.provider_voices)[0]?.value || '')
}
const handleModel = (m: string) => {
setModel(m)
setVoice(resolveVoices(provider, m, state.provider_voices)[0]?.value || '')
}
return (
<Card icon={<Volume2 size={16} />} title={t('models_cap_tts')} subtitle={t('models_cap_tts_sub')}>
<div className="space-y-4">
{/* Reply mode — saved immediately */}
<Field label={t('models_tts_reply_mode')} hint={t('models_tts_reply_mode_hint')}>
<Dropdown
value={mode}
options={REPLY_MODES.map((m) => ({ value: m.value, label: t(m.key) }))}
onChange={(v) => {
const next = v as 'off' | 'voice_if_voice' | 'always'
setMode(next)
onSaveMode(next)
}}
disabled={modeBusy}
/>
{modeStatus && <span className="text-xs text-accent">{modeStatus}</span>}
</Field>
{mode !== 'off' && (
<>
<Field label={t('models_provider')}>
<Dropdown
value={provider}
options={providerOptions}
placeholder={t('models_select_provider')}
onChange={handleProvider}
/>
</Field>
<Field label={t('models_model')}>
<Dropdown
value={model}
options={modelOptions}
placeholder={t('models_select_model')}
onChange={handleModel}
/>
</Field>
{voiceOptions.length > 0 && (
<Field label={t('models_voice')}>
<Dropdown value={voice} options={voiceOptions} placeholder={t('models_select_voice')} onChange={setVoice} />
</Field>
)}
<div className="flex items-center justify-end gap-3 pt-1">
<span className={`text-xs text-accent transition-opacity ${status ? 'opacity-100' : 'opacity-0'}`}>
{status}
</span>
<button
disabled={busy}
onClick={() => onSaveVoice(provider, model, voice)}
className="px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 inline-flex items-center gap-2"
>
{busy && <Loader2 size={14} className="animate-spin" />}
{t('config_save')}
</button>
</div>
</>
)}
</div>
</Card>
)
}
const EmbeddingCard: React.FC<{
state: CapabilityState
data: ModelsData
busy: boolean
status?: string
onSave: (provider: string, model: string) => void
}> = ({ state, data, busy, status, onSave }) => (
<CapabilityCard
icon={Database}
title={t('models_cap_embedding')}
subtitle={t('models_cap_embedding_sub')}
capKey="embedding"
state={state}
data={data}
allowAuto
autoLabel={t('models_disabled')}
busy={busy}
status={status}
onSave={onSave}
>
{state.current_dim != null && (
<p className="text-xs text-content-tertiary">
{t('models_embedding_dim')}: {state.current_dim} · {t('models_embedding_rebuild_hint')}
</p>
)}
</CapabilityCard>
)
const SearchCard: React.FC<{
state: SearchCapabilityState
busy: boolean
status?: string
onSaveStrategy: (strategy: string, provider: string) => void
onSaveBochaKey: (key: string) => void
keyStatus?: string
keyBusy: boolean
}> = ({ state, busy, status, onSaveStrategy, onSaveBochaKey, keyStatus, keyBusy }) => {
const [strategy, setStrategy] = useState<string>(state.strategy || 'auto')
const [provider, setProvider] = useState<string>(state.fixed_provider || state.current_provider || '')
const [bochaOpen, setBochaOpen] = useState(false)
const providerOptions = useMemo(
() => state.providers.map((p) => ({ value: p.id, label: localizedLabel(p.label) })),
[state.providers]
)
const bocha = state.providers.find((p) => p.id === 'bocha')
return (
<Card icon={<SearchIcon size={16} />} title={t('models_cap_search')} subtitle={t('models_cap_search_sub')}>
<div className="space-y-4">
<Field label={t('models_search_strategy')}>
<Dropdown
value={strategy}
options={[
{ value: 'auto', label: t('models_search_auto') },
{ value: 'fixed', label: t('models_search_fixed') },
]}
onChange={setStrategy}
/>
</Field>
{strategy === 'fixed' && (
<Field label={t('models_search_provider')}>
<Dropdown
value={provider}
options={providerOptions}
placeholder={t('models_select_provider')}
onChange={setProvider}
/>
</Field>
)}
<div className="flex items-center justify-between">
<button
onClick={() => setBochaOpen(true)}
className="text-xs text-accent hover:text-accent-hover cursor-pointer inline-flex items-center gap-1"
>
{t('models_search_bocha_key')}
{bocha?.configured && <Check size={12} />}
</button>
<div className="flex items-center gap-3">
<span className={`text-xs text-accent transition-opacity ${status ? 'opacity-100' : 'opacity-0'}`}>
{status}
</span>
<button
disabled={busy || (strategy === 'fixed' && !provider)}
onClick={() => onSaveStrategy(strategy, provider)}
className="px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 inline-flex items-center gap-2"
>
{busy && <Loader2 size={14} className="animate-spin" />}
{t('config_save')}
</button>
</div>
</div>
</div>
<BochaKeyModal
open={bochaOpen}
masked={bocha?.api_key_masked || ''}
busy={keyBusy}
status={keyStatus}
onClose={() => setBochaOpen(false)}
onSave={(k) => {
onSaveBochaKey(k)
setBochaOpen(false)
}}
/>
</Card>
)
}
const BochaKeyModal: React.FC<{
open: boolean
masked: string
busy: boolean
status?: string
onClose: () => void
onSave: (key: string) => void
}> = ({ open, masked, busy, onClose, onSave }) => {
const [key, setKey] = useState('')
const [dirty, setDirty] = useState(false)
useEffect(() => {
if (open) {
setKey(masked)
setDirty(false)
}
}, [open, masked])
return (
<Modal
open={open}
title={t('models_search_bocha_key')}
onClose={onClose}
footer={
<>
<Btn variant="ghost" onClick={onClose}>
{t('config_cancel')}
</Btn>
<Btn variant="primary" disabled={busy} onClick={() => onSave(dirty && !MASK_RE.test(key) ? key : '')}>
{busy ? <Loader2 size={14} className="animate-spin" /> : t('config_save')}
</Btn>
</>
}
>
<Field label="Bocha API Key" hint={t('models_search_bocha_hint')}>
<TextInput
className="font-mono"
value={key}
placeholder="sk-..."
onFocus={() => {
if (!dirty && MASK_RE.test(key)) setKey('')
}}
onChange={(e) => {
setKey(e.target.value)
setDirty(true)
}}
/>
</Field>
</Modal>
)
}
export default ModelsTab

View File

@@ -0,0 +1,57 @@
import type { ModelEntry, ModelOption, ModelProvider, ModelsData } from '../../types'
import { localizedLabel } from '../../i18n'
// Normalize a string|{value,hint} entry into a uniform option shape.
export function normEntry(e: ModelEntry): ModelOption {
return typeof e === 'string' ? { value: e } : e
}
export function normEntries(arr?: ModelEntry[]): ModelOption[] {
return (arr || []).map(normEntry)
}
// Resolve a human label for a provider id, falling back to the id itself.
// Handles expanded custom ids ("custom:<id>") via the providers overview.
export function providerLabel(data: ModelsData | null, id: string): string {
if (!id) return ''
const p = data?.providers?.find((x) => x.id === id)
if (p) return localizedLabel(p.label) || id
return id
}
export function findProvider(data: ModelsData | null, id: string): ModelProvider | undefined {
return data?.providers?.find((x) => x.id === id)
}
// Resolve the model list for a capability+provider, mirroring the web console:
// 1. capability-scoped provider_models[id] (vision/image/asr/tts/embedding)
// 2. provider_models['custom'] for expanded custom:<id> providers
// 3. fall back to the vendor's generic models[] (chat has no provider_models)
export function resolveModels(
data: ModelsData | null,
providerId: string,
providerModels?: Record<string, ModelEntry[]>
): ModelOption[] {
if (!providerId) return []
if (providerModels?.[providerId]) return normEntries(providerModels[providerId])
if (providerId.startsWith('custom:') && providerModels?.['custom']) {
return normEntries(providerModels['custom'])
}
return normEntries(findProvider(data, providerId)?.models)
}
// Voices for a tts provider may be a flat list or, for linkai, keyed by model.
export function resolveVoices(
provider: string,
model: string,
voicesMap?: Record<string, ModelEntry[] | Record<string, ModelEntry[]>>
): ModelOption[] {
const raw = voicesMap?.[provider]
if (!raw) return []
if (Array.isArray(raw)) return normEntries(raw)
// keyed by model (linkai)
const byModel = raw as Record<string, ModelEntry[]>
return normEntries(byModel[model] || [])
}
export const CUSTOM_OPTION = '__custom__'

View File

@@ -0,0 +1,196 @@
import React, { useState, useEffect, useRef } from 'react'
import { ChevronDown } from 'lucide-react'
import { t } from '../../i18n'
// Shared presentational building blocks for the settings tabs.
export const Card: React.FC<{ icon: React.ReactNode; title: string; subtitle?: string; children: React.ReactNode }> = ({
icon,
title,
subtitle,
children,
}) => (
<div className="rounded-card border border-default bg-surface p-5">
<div className="flex items-center gap-2.5 mb-4">
<div className="w-8 h-8 rounded-lg bg-accent-soft text-accent flex items-center justify-center">{icon}</div>
<div>
<h3 className="font-semibold text-content leading-tight">{title}</h3>
{subtitle && <p className="text-xs text-content-tertiary mt-0.5">{subtitle}</p>}
</div>
</div>
{children}
</div>
)
export const Field: React.FC<{ label: string; hint?: string; children: React.ReactNode }> = ({
label,
hint,
children,
}) => (
<div>
<label className="block text-sm font-medium text-content-secondary mb-1.5">{label}</label>
{children}
{hint && <p className="text-xs text-content-tertiary mt-1">{hint}</p>}
</div>
)
export interface DropdownOption {
value: string
label: string
hint?: string
}
export const Dropdown: React.FC<{
value: string
display?: string
placeholder?: string
options: DropdownOption[]
disabled?: boolean
onChange: (val: string) => void
}> = ({ value, display, placeholder, options, disabled, onChange }) => {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const h = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', h)
return () => document.removeEventListener('mousedown', h)
}, [])
const current = display ?? options.find((o) => o.value === value)?.label ?? ''
return (
<div ref={ref} className="relative">
<button
type="button"
disabled={disabled}
onClick={() => !disabled && setOpen((v) => !v)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-btn border bg-inset text-sm transition-colors ${
disabled
? 'border-default text-content-tertiary cursor-not-allowed opacity-70'
: 'border-strong text-content cursor-pointer hover:border-accent'
}`}
>
<span className={`truncate ${current ? '' : 'text-content-tertiary'}`}>{current || placeholder || '--'}</span>
<ChevronDown size={15} className={`text-content-tertiary transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute z-30 mt-1 w-full max-h-64 overflow-y-auto rounded-btn border border-default bg-elevated shadow-lg py-1">
{options.length === 0 && (
<div className="px-3 py-2 text-sm text-content-tertiary">{t('models_no_options')}</div>
)}
{options.map((o) => (
<div
key={o.value}
onClick={() => {
onChange(o.value)
setOpen(false)
}}
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
o.value === value ? 'bg-accent-soft text-accent' : 'text-content-secondary hover:bg-surface-2'
}`}
>
<div className="truncate">{o.label}</div>
{o.hint && <div className="text-xs text-content-tertiary mt-0.5 truncate">{o.hint}</div>}
</div>
))}
</div>
)}
</div>
)
}
export const Toggle: React.FC<{ checked: boolean; onChange: (v: boolean) => void }> = ({ checked, onChange }) => (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition-colors cursor-pointer ${
checked ? 'bg-accent' : 'bg-surface-2 border border-strong'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${
checked ? 'translate-x-[18px]' : 'translate-x-[3px]'
}`}
/>
</button>
)
export const TextInput: React.FC<React.InputHTMLAttributes<HTMLInputElement>> = (props) => (
<input
{...props}
className={`w-full px-3 py-2 rounded-btn border border-strong bg-inset text-sm text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent transition-colors ${
props.className || ''
}`}
/>
)
export const SaveRow: React.FC<{ status: string; onSave: () => void; label?: string }> = ({
status,
onSave,
label,
}) => (
<div className="flex items-center justify-end gap-3 pt-1">
<span className={`text-xs text-accent transition-opacity ${status ? 'opacity-100' : 'opacity-0'}`}>{status}</span>
<button
onClick={onSave}
className="px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors"
>
{label ?? t('config_save')}
</button>
</div>
)
export const MASK_RE = /[*•]/
export const Modal: React.FC<{
open: boolean
title: string
onClose: () => void
children: React.ReactNode
footer?: React.ReactNode
}> = ({ open, title, onClose, children, footer }) => {
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<div className="w-full max-w-md rounded-card border border-default bg-elevated shadow-xl">
<div className="flex items-center justify-between px-5 py-4 border-b border-default">
<h3 className="font-semibold text-content">{title}</h3>
<button
onClick={onClose}
className="text-content-tertiary hover:text-content cursor-pointer text-lg leading-none px-1"
>
×
</button>
</div>
<div className="px-5 py-4 space-y-4 max-h-[60vh] overflow-y-auto">{children}</div>
{footer && <div className="flex items-center justify-end gap-2 px-5 py-3.5 border-t border-default">{footer}</div>}
</div>
</div>
)
}
export const Btn: React.FC<
React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'primary' | 'ghost' | 'danger' }
> = ({ variant = 'ghost', className, children, ...props }) => {
const styles =
variant === 'primary'
? 'bg-accent text-accent-contrast hover:bg-accent-hover'
: variant === 'danger'
? 'bg-danger-soft text-danger hover:bg-danger/15 border border-danger-border'
: 'border border-strong text-content-secondary hover:bg-surface-2'
return (
<button
{...props}
className={`px-4 py-2 rounded-btn text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${styles} ${className || ''}`}
>
{children}
</button>
)
}

View File

@@ -175,12 +175,16 @@ export interface HistoryPage {
// Config
// ============================================================
/** A label that may be localized (some providers/channels return {zh,en}). */
export type LocalizedLabel = string | { zh: string; en: string }
export interface ProviderMeta {
label: string
label: LocalizedLabel
models: string[]
api_base_key?: string
api_base_default?: string
api_key_field?: string
api_base_key?: string | null
api_base_default?: string | null
api_base_placeholder?: string
api_key_field?: string | null
[k: string]: unknown
}
@@ -206,34 +210,95 @@ export interface ConfigData {
// Models console (/api/models)
// ============================================================
// A model/voice entry can be a bare id or an annotated {value, hint} object.
export interface ModelOption {
value: string
hint?: string
}
export type ModelEntry = string | ModelOption
export interface ModelProvider {
id: string
label: string
label: LocalizedLabel
configured: boolean
is_custom: boolean
custom_id?: string
custom_name?: string
active?: boolean
api_key_field?: string | null
api_base_field?: string | null
api_key_masked?: string
api_base?: string
models: string[]
api_base_default?: string
api_base_placeholder?: string
models: ModelEntry[]
}
export type CapabilityKey = 'chat' | 'vision' | 'asr' | 'tts' | 'embedding' | 'image' | 'search'
// Search providers are described as objects (unlike other capabilities which
// list provider ids only).
export interface SearchProviderMeta {
id: string
label: LocalizedLabel
configured: boolean
needs_dedicated_key: boolean
api_key_masked?: string
}
export interface CapabilityState {
editable?: boolean
current_provider?: string
current_model?: string
current_voice?: string
current_dim?: number | null
suggested_provider?: string
providers?: string[]
provider_models?: Record<string, string[]>
// provider_models entries are string | {value,hint}
provider_models?: Record<string, ModelEntry[]>
// tts only: voices keyed by provider; linkai keyed further by model id
provider_voices?: Record<string, ModelEntry[] | Record<string, ModelEntry[]>>
// vision/image
strategy?: string
user_specified_model?: string
fallback_provider?: string
fallback_model?: string
// tts
reply_mode?: 'off' | 'voice_if_voice' | 'always'
use_linkai?: boolean
// image
runtime_active?: boolean
note?: string
// search
fixed_provider?: string
configured_providers?: string[]
available?: boolean
[k: string]: unknown
}
export interface SearchCapabilityState {
editable?: boolean
providers: SearchProviderMeta[]
strategy?: 'auto' | 'fixed' | string
current_provider?: string
fixed_provider?: string
configured_providers?: string[]
available?: boolean
}
export interface ModelsData {
status?: string
providers: ModelProvider[]
capabilities: Record<CapabilityKey, CapabilityState>
capabilities: {
chat: CapabilityState
vision: CapabilityState
asr: CapabilityState
tts: CapabilityState
embedding: CapabilityState
image: CapabilityState
// search has a richer providers[] shape
search: SearchCapabilityState
}
}
export type ModelsAction =
@@ -242,7 +307,7 @@ export type ModelsAction =
| { action: 'set_custom_provider'; name: string; id?: string; api_base: string; api_key?: string; model?: string; make_active?: boolean }
| { action: 'delete_custom_provider'; id: string }
| { action: 'set_active_custom_provider'; id: string }
| { action: 'set_capability'; capability: CapabilityKey; provider_id: string; model: string; voice?: string; strategy?: string; provider?: string }
| { action: 'set_capability'; capability: CapabilityKey; provider_id?: string; model?: string; voice?: string; strategy?: string; provider?: string }
| { action: 'set_voice_reply_mode'; mode: 'off' | 'voice_if_voice' | 'always' }
| { action: 'set_search_credential'; api_key: string }