mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
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:
@@ -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>
|
||||
|
||||
@@ -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 || ''
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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
|
||||
60
desktop/src/renderer/src/pages/SettingsPage.tsx
Normal file
60
desktop/src/renderer/src/pages/SettingsPage.tsx
Normal 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
|
||||
331
desktop/src/renderer/src/pages/settings/BasicSettings.tsx
Normal file
331
desktop/src/renderer/src/pages/settings/BasicSettings.tsx
Normal 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
|
||||
139
desktop/src/renderer/src/pages/settings/CapabilityCard.tsx
Normal file
139
desktop/src/renderer/src/pages/settings/CapabilityCard.tsx
Normal 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
|
||||
833
desktop/src/renderer/src/pages/settings/ModelsTab.tsx
Normal file
833
desktop/src/renderer/src/pages/settings/ModelsTab.tsx
Normal 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
|
||||
57
desktop/src/renderer/src/pages/settings/modelsHelpers.ts
Normal file
57
desktop/src/renderer/src/pages/settings/modelsHelpers.ts
Normal 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__'
|
||||
196
desktop/src/renderer/src/pages/settings/primitives.tsx
Normal file
196
desktop/src/renderer/src/pages/settings/primitives.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user