diff --git a/desktop/src/renderer/src/App.tsx b/desktop/src/renderer/src/App.tsx index cc268348..9e6ff77a 100644 --- a/desktop/src/renderer/src/App.tsx +++ b/desktop/src/renderer/src/App.tsx @@ -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 = () => { } /> } /> } /> - } /> } /> } /> - } /> + } /> + {/* Legacy /models route now lives as a tab inside settings */} + } /> } /> diff --git a/desktop/src/renderer/src/i18n.ts b/desktop/src/renderer/src/i18n.ts index e2138139..0acc408e 100644 --- a/desktop/src/renderer/src/i18n.ts +++ b/desktop/src/renderer/src/i18n.ts @@ -12,7 +12,7 @@ const translations: Record> = { menu_tasks: '定时', menu_logs: '日志', menu_models: '模型', - menu_knowledge: '知识库', + menu_knowledge: '知识', menu_settings: '设置', nav_expand: '展开侧栏', nav_collapse: '收起侧栏', @@ -57,10 +57,87 @@ const translations: Record> = { 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> = { 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 || '' +} diff --git a/desktop/src/renderer/src/layout/NavRail.tsx b/desktop/src/renderer/src/layout/NavRail.tsx index ed8d8516..dea8dcef 100644 --- a/desktop/src/renderer/src/layout/NavRail.tsx +++ b/desktop/src/renderer/src/layout/NavRail.tsx @@ -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 }, diff --git a/desktop/src/renderer/src/pages/ChatPage.tsx b/desktop/src/renderer/src/pages/ChatPage.tsx index 57d1ba46..136ee5de 100644 --- a/desktop/src/renderer/src/pages/ChatPage.tsx +++ b/desktop/src/renderer/src/pages/ChatPage.tsx @@ -51,19 +51,44 @@ const ChatPage: React.FC = ({ 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[]) => { diff --git a/desktop/src/renderer/src/pages/ConfigPage.tsx b/desktop/src/renderer/src/pages/ConfigPage.tsx deleted file mode 100644 index e481473b..00000000 --- a/desktop/src/renderer/src/pages/ConfigPage.tsx +++ /dev/null @@ -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 = ({ id, value, options, onChange }) => { - const [open, setOpen] = useState(false) - const ref = useRef(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 ( -
setOpen(!open)}> -
- {value || '--'} - -
-
- {options.map((opt) => ( -
{ e.stopPropagation(); onChange(opt); setOpen(false); }} - > - {opt} -
- ))} -
-
- ) -} - -const ConfigPage: React.FC = ({ baseUrl }) => { - const [config, setConfig] = useState(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)[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)[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)[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 ( -
-
Loading...
-
- ) - } - - const providers = config?.providers ? Object.keys(config.providers) : [] - - return ( -
-
-
-
-

{t('config_title')}

-

{t('config_desc')}

-
-
- -
- {/* Model Config Card */} -
-
-
- -
-

{t('config_model')}

-
-
-
- - -
-
- - - {showCustom && ( - 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')} - /> - )} -
-
- -
- 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-..." - /> - -
-
- {apiBase && ( -
- - 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://..." - /> -
- )} -
- {modelStatus} - -
-
-
- - {/* Agent Config Card */} -
-
-
- -
-

{t('config_agent')}

-
-
-
- - 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" /> -
-
- - 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" /> -
-
- - 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" /> -
-
- {agentStatus} - -
-
-
-
-
-
- ) -} - -export default ConfigPage diff --git a/desktop/src/renderer/src/pages/SettingsPage.tsx b/desktop/src/renderer/src/pages/SettingsPage.tsx new file mode 100644 index 00000000..edf509a1 --- /dev/null +++ b/desktop/src/renderer/src/pages/SettingsPage.tsx @@ -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 = ({ 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(initial) + + const tabs: { key: Tab; label: string }[] = [ + { key: 'basic', label: t('settings_tab_basic') }, + { key: 'models', label: t('settings_tab_models') }, + ] + + return ( +
+
+
+

{t('menu_settings')}

+

{t('config_desc')}

+
+ + {/* Tab bar */} +
+ {tabs.map((tb) => ( + + ))} +
+ + {tab === 'basic' ? ( + setTab('models')} /> + ) : ( + + )} +
+
+ ) +} + +export default SettingsPage diff --git a/desktop/src/renderer/src/pages/settings/BasicSettings.tsx b/desktop/src/renderer/src/pages/settings/BasicSettings.tsx new file mode 100644 index 00000000..b2ff37a6 --- /dev/null +++ b/desktop/src/renderer/src/pages/settings/BasicSettings.tsx @@ -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 = ({ baseUrl, onLangChange, onOpenModels }) => { + const [config, setConfig] = useState(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 ( +
+ + {t('skills_loading')} +
+ ) + } + + // 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 ( +
+ {/* Model — provider/model selection only; credentials live in Models tab */} + } title={t('config_model')}> +
+ + + + + + {showCustom && ( + setCustomModel(e.target.value)} + placeholder={t('config_custom_model_hint')} + /> + )} + + + {/* Guide users to the Models tab for API key / base config. + When the selected provider has no credentials, surface a warning. */} + {onOpenModels && ( + + )} + + +
+
+ + {/* Agent */} + } title={t('config_agent')}> +
+ + setMaxTokens(parseInt(e.target.value) || 0)} + /> + + + setMaxTurns(parseInt(e.target.value) || 0)} + /> + + + setMaxSteps(parseInt(e.target.value) || 0)} + /> + +
+
+
{t('config_thinking')}
+
{t('config_thinking_hint')}
+
+ +
+
+
+
{t('config_evolution')}
+
{t('config_evolution_hint')}
+
+ +
+ +
+
+ + {/* Security */} + } title={t('config_security')}> +
+ +
+ { + if (!pwDirty && MASK_RE.test(password)) setPassword('') + }} + onBlur={() => { + if (!pwDirty) setPassword(config?.web_password_masked || '') + }} + onChange={(e) => { + setPassword(e.target.value) + setPwDirty(true) + }} + /> + +
+
+ +
+
+ + {/* Language */} + } title={t('config_language')}> + + changeLanguage(v as Lang)} + /> + + +
+ ) +} + +export default BasicSettings diff --git a/desktop/src/renderer/src/pages/settings/CapabilityCard.tsx b/desktop/src/renderer/src/pages/settings/CapabilityCard.tsx new file mode 100644 index 00000000..a5e0b506 --- /dev/null +++ b/desktop/src/renderer/src/pages/settings/CapabilityCard.tsx @@ -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 = ({ + 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 ( + } title={title} subtitle={subtitle}> +
+ + + + {!isAuto && ( + + + {showCustom && ( + setCustomModel(e.target.value)} + placeholder={t('config_custom_model_hint')} + /> + )} + + )} + {children} +
+ + {status} + + +
+
+
+ ) +} + +export default CapabilityCard diff --git a/desktop/src/renderer/src/pages/settings/ModelsTab.tsx b/desktop/src/renderer/src/pages/settings/ModelsTab.tsx new file mode 100644 index 00000000..f3966373 --- /dev/null +++ b/desktop/src/renderer/src/pages/settings/ModelsTab.tsx @@ -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 = ({ baseUrl }) => { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState('') // capability key currently saving + const [statusMap, setStatusMap] = useState>({}) + + 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[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 ( +
+ + {t('skills_loading')} +
+ ) + } + if (!data) { + return
{t('config_save_error')}
+ } + + const caps = data.capabilities + + return ( +
+ + + {/* Chat */} + run('chat', { action: 'set_capability', capability: 'chat', provider_id: p, model: m })} + /> + + {/* Vision */} + run('vision', { action: 'set_capability', capability: 'vision', provider_id: p, model: m })} + > + + + + {/* Image */} + run('image', { action: 'set_capability', capability: 'image', provider_id: p, model: m })} + > + + + + {/* ASR */} + run('asr', { action: 'set_capability', capability: 'asr', provider_id: p, model: m })} + /> + + {/* TTS — bespoke (voice + reply mode) */} + + 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 */} + run('embedding', { action: 'set_capability', capability: 'embedding', provider_id: p, model: m })} + /> + + {/* Search — bespoke */} + + 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'} + /> +
+ ) +} + +// ============================================================ +// Layer 1 — vendor credentials +// ============================================================ + +interface VendorSectionProps { + data: ModelsData + onChanged: () => Promise + statusMap: Record + flash: (key: string, msg: string) => void +} + +const VendorSection: React.FC = ({ data, onChanged }) => { + // Edit an existing built-in vendor. + const [editing, setEditing] = useState(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(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 ( + } title={t('models_vendors')} subtitle={t('models_vendors_sub')}> + {shown.length === 0 ? ( +
+

{t('models_no_vendor')}

+ +
+ ) : ( +
+ {shown.map((p) => + isCustomCard(p) ? ( + setCustomEditing(p)} /> + ) : ( + setEditing(p)} /> + ) + )} + +
+ )} + + { + setEditing(null) + setAdding(false) + }} + onPickCustom={() => { + setAdding(false) + setCustomEditing('new') + }} + onSaved={onChanged} + /> + setCustomEditing(null)} onSaved={onChanged} /> +
+ ) +} + +const VendorChip: React.FC<{ provider: ModelProvider; onClick: () => void }> = ({ provider, onClick }) => ( + +) + +const CUSTOM_PICK = '__custom_new__' + +const VendorModal: React.FC<{ + provider: ModelProvider | null + addMode: boolean + data: ModelsData + onClose: () => void + onPickCustom: () => void + onSaved: () => Promise +}> = ({ 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 ( + + {!addMode && effective?.configured && ( + + {t('models_clear')} + + )} + + {t('config_cancel')} + + + {saving ? : t('config_save')} + + + } + > + {addMode && ( + + + + )} + +
+ { + if (!keyDirty && MASK_RE.test(apiKey)) setApiKey('') + }} + onBlur={() => { + if (!keyDirty) setApiKey(effective?.api_key_masked || '') + }} + onChange={(e) => { + setApiKey(e.target.value) + setKeyDirty(true) + }} + /> + +
+
+ {hasBase && ( + + setApiBase(e.target.value)} + placeholder={effective?.api_base_placeholder || 'https://...'} + /> + + )} +
+ ) +} + +const CustomProviderModal: React.FC<{ + target: ModelProvider | 'new' | null + onClose: () => void + onSaved: () => Promise +}> = ({ 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 ( + + {editing && ( + + {t('models_delete')} + + )} + + {t('config_cancel')} + + + {saving ? : t('config_save')} + + + } + > + + setName(e.target.value)} placeholder="My Provider" /> + + + setApiBase(e.target.value)} + placeholder="https://...../v1" + /> + + + { + if (!keyDirty && MASK_RE.test(apiKey)) setApiKey('') + }} + onChange={(e) => { + setApiKey(e.target.value) + setKeyDirty(true) + }} + /> + + + ) +} + +// ============================================================ +// 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 ( +

+ {t('models_fallback')}: {label} {state.fallback_model ? `· ${state.fallback_model}` : ''} +

+ ) +} + +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 ( + } title={t('models_cap_tts')} subtitle={t('models_cap_tts_sub')}> +
+ {/* Reply mode — saved immediately */} + + ({ 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 && {modeStatus}} + + + {mode !== 'off' && ( + <> + + + + + + + {voiceOptions.length > 0 && ( + + + + )} +
+ + {status} + + +
+ + )} +
+
+ ) +} + +const EmbeddingCard: React.FC<{ + state: CapabilityState + data: ModelsData + busy: boolean + status?: string + onSave: (provider: string, model: string) => void +}> = ({ state, data, busy, status, onSave }) => ( + + {state.current_dim != null && ( +

+ {t('models_embedding_dim')}: {state.current_dim} · {t('models_embedding_rebuild_hint')} +

+ )} +
+) + +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(state.strategy || 'auto') + const [provider, setProvider] = useState(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 ( + } title={t('models_cap_search')} subtitle={t('models_cap_search_sub')}> +
+ + + + {strategy === 'fixed' && ( + + + + )} +
+ +
+ + {status} + + +
+
+
+ + setBochaOpen(false)} + onSave={(k) => { + onSaveBochaKey(k) + setBochaOpen(false) + }} + /> +
+ ) +} + +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 ( + + + {t('config_cancel')} + + onSave(dirty && !MASK_RE.test(key) ? key : '')}> + {busy ? : t('config_save')} + + + } + > + + { + if (!dirty && MASK_RE.test(key)) setKey('') + }} + onChange={(e) => { + setKey(e.target.value) + setDirty(true) + }} + /> + + + ) +} + +export default ModelsTab diff --git a/desktop/src/renderer/src/pages/settings/modelsHelpers.ts b/desktop/src/renderer/src/pages/settings/modelsHelpers.ts new file mode 100644 index 00000000..24ac31cc --- /dev/null +++ b/desktop/src/renderer/src/pages/settings/modelsHelpers.ts @@ -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:") 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: 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 +): 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> +): ModelOption[] { + const raw = voicesMap?.[provider] + if (!raw) return [] + if (Array.isArray(raw)) return normEntries(raw) + // keyed by model (linkai) + const byModel = raw as Record + return normEntries(byModel[model] || []) +} + +export const CUSTOM_OPTION = '__custom__' diff --git a/desktop/src/renderer/src/pages/settings/primitives.tsx b/desktop/src/renderer/src/pages/settings/primitives.tsx new file mode 100644 index 00000000..cfd9b764 --- /dev/null +++ b/desktop/src/renderer/src/pages/settings/primitives.tsx @@ -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, +}) => ( +
+
+
{icon}
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+
+ {children} +
+) + +export const Field: React.FC<{ label: string; hint?: string; children: React.ReactNode }> = ({ + label, + hint, + children, +}) => ( +
+ + {children} + {hint &&

{hint}

} +
+) + +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(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 ( +
+ + {open && ( +
+ {options.length === 0 && ( +
{t('models_no_options')}
+ )} + {options.map((o) => ( +
{ + 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' + }`} + > +
{o.label}
+ {o.hint &&
{o.hint}
} +
+ ))} +
+ )} +
+ ) +} + +export const Toggle: React.FC<{ checked: boolean; onChange: (v: boolean) => void }> = ({ checked, onChange }) => ( + +) + +export const TextInput: React.FC> = (props) => ( + +) + +export const SaveRow: React.FC<{ status: string; onSave: () => void; label?: string }> = ({ + status, + onSave, + label, +}) => ( +
+ {status} + +
+) + +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 ( +
{ + if (e.target === e.currentTarget) onClose() + }} + > +
+
+

{title}

+ +
+
{children}
+ {footer &&
{footer}
} +
+
+ ) +} + +export const Btn: React.FC< + React.ButtonHTMLAttributes & { 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 ( + + ) +} diff --git a/desktop/src/renderer/src/types.ts b/desktop/src/renderer/src/types.ts index 6966f2ca..0b85e072 100644 --- a/desktop/src/renderer/src/types.ts +++ b/desktop/src/renderer/src/types.ts @@ -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 + // provider_models entries are string | {value,hint} + provider_models?: Record + // tts only: voices keyed by provider; linkai keyed further by model id + provider_voices?: Record> + // 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 + 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 }