feat:webui支持更加优化的模型配置,优化多处UI体验,支持设置视觉和cache价格,修复多重表达不生效的问题,修复表情包路径错误

This commit is contained in:
SengokuCola
2026-05-04 22:52:41 +08:00
parent 14b7bc78a2
commit eea95c1961
38 changed files with 1188 additions and 454 deletions

View File

@@ -24,10 +24,11 @@ import { getBotConfig, getBotConfigRaw, getBotConfigSchema, updateBotConfig, upd
import { fieldHooks } from '@/lib/field-hooks'
import { RestartProvider, useRestart } from '@/lib/restart-context'
import { Code2, Info, Layout, Power, Save } from 'lucide-react'
import { ChevronDown, ChevronUp, Code2, Info, Layout, Power, RefreshCw, Save } from 'lucide-react'
import type { ConfigSchema } from '@/types/config-schema'
import {
ChatPromptsHook,
ChatTalkValueRulesHook,
ExpressionGroupsHook,
ExpressionLearningListHook,
@@ -50,13 +51,27 @@ const TAB_ORDER = [
'personality',
'chat',
'expression',
'visual',
'a_memorix',
'message_receive',
'emoji',
'voice',
'response_post_process',
'webui',
'plugin_runtime',
'log',
]
/** 默认展示的主配置栏目 */
const DEFAULT_VISIBLE_TAB_IDS = new Set([
'bot',
'personality',
'chat',
'expression',
'visual',
'a_memorix',
])
// ==================== Tab 分组类型与构建 ====================
interface TabGroup {
id: string
@@ -143,6 +158,9 @@ function BotConfigPageContent() {
const [sourceCode, setSourceCode] = useState<string>('')
const [hasTomlError, setHasTomlError] = useState(false)
const [tomlErrorMessage, setTomlErrorMessage] = useState<string>('')
const [restartNoticeVisible, setRestartNoticeVisible] = useState(
() => localStorage.getItem('bot-config-restart-notice-dismissed') !== 'true'
)
const { toast } = useToast()
const { triggerRestart, isRestarting } = useRestart()
@@ -160,6 +178,7 @@ function BotConfigPageContent() {
const [responsePostProcessConfig, setResponsePostProcessConfig] = useState<ConfigSectionData | null>(null)
const [chineseTypoConfig, setChineseTypoConfig] = useState<ConfigSectionData | null>(null)
const [responseSplitterConfig, setResponseSplitterConfig] = useState<ConfigSectionData | null>(null)
const [logConfig, setLogConfig] = useState<ConfigSectionData | null>(null)
const [debugConfig, setDebugConfig] = useState<ConfigSectionData | null>(null)
const [maimMessageConfig, setMaimMessageConfig] = useState<ConfigSectionData | null>(null)
const [telemetryConfig, setTelemetryConfig] = useState<ConfigSectionData | null>(null)
@@ -174,6 +193,7 @@ function BotConfigPageContent() {
// 用于标记初始加载和配置缓存
const initialLoadRef = useRef(true)
const suppressAutoSaveRef = useRef(false)
const configRef = useRef<Record<string, unknown>>({})
// ==================== 辅助函数 ====================
@@ -240,6 +260,7 @@ function BotConfigPageContent() {
* 抽取自 loadConfig 和 handleModeChange 中的重复逻辑
*/
const parseAndSetConfig = useCallback((config: Record<string, unknown>) => {
suppressAutoSaveRef.current = true
configRef.current = config
setBotConfig((config.bot ?? {}) as ConfigSectionData)
@@ -255,6 +276,7 @@ function BotConfigPageContent() {
setResponsePostProcessConfig((config.response_post_process ?? {}) as ConfigSectionData)
setChineseTypoConfig((config.chinese_typo ?? {}) as ConfigSectionData)
setResponseSplitterConfig((config.response_splitter ?? {}) as ConfigSectionData)
setLogConfig((config.log ?? {}) as ConfigSectionData)
setDebugConfig((config.debug ?? {}) as ConfigSectionData)
setMaimMessageConfig((config.maim_message ?? {}) as ConfigSectionData)
setTelemetryConfig((config.telemetry ?? {}) as ConfigSectionData)
@@ -263,6 +285,10 @@ function BotConfigPageContent() {
setMcpConfig((config.mcp ?? {}) as ConfigSectionData)
setPluginRuntimeConfig((config.plugin_runtime ?? {}) as ConfigSectionData)
setAMemorixConfig((config.a_memorix ?? {}) as ConfigSectionData)
window.setTimeout(() => {
suppressAutoSaveRef.current = false
}, 0)
}, [])
/**
@@ -285,6 +311,7 @@ function BotConfigPageContent() {
response_post_process: responsePostProcessConfig,
chinese_typo: chineseTypoConfig,
response_splitter: responseSplitterConfig,
log: logConfig,
debug: debugConfig,
maim_message: maimMessageConfig,
telemetry: telemetryConfig,
@@ -308,6 +335,7 @@ function BotConfigPageContent() {
responsePostProcessConfig,
chineseTypoConfig,
responseSplitterConfig,
logConfig,
debugConfig,
maimMessageConfig,
telemetryConfig,
@@ -394,6 +422,7 @@ function BotConfigPageContent() {
useEffect(() => {
const hookEntries = [
['chat.chat_prompts', ChatPromptsHook],
['chat.talk_value_rules', ChatTalkValueRulesHook],
['expression.expression_groups', ExpressionGroupsHook],
['expression.learning_list', ExpressionLearningListHook],
@@ -421,30 +450,41 @@ function BotConfigPageContent() {
setHasUnsavedChanges
)
const triggerConfigAutoSave = useCallback(
(sectionName: Parameters<typeof triggerAutoSave>[0], data: unknown) => {
if (suppressAutoSaveRef.current) {
return
}
triggerAutoSave(sectionName, data)
},
[triggerAutoSave]
)
// 使用 useConfigAutoSave hook 简化配置变化监听
// 注意: useConfigAutoSave 是一个 hook不能在条件语句或循环中调用
// 因此我们仍然需要逐个调用,但代码更简洁
useConfigAutoSave(botConfig, 'bot', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(personalityConfig, 'personality', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(chatConfig, 'chat', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(expressionConfig, 'expression', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(emojiConfig, 'emoji', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(memoryConfig, 'memory', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(visualConfig, 'visual', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(voiceConfig, 'voice', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(messageReceiveConfig, 'message_receive', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(keywordReactionConfig, 'keyword_reaction', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(responsePostProcessConfig, 'response_post_process', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(chineseTypoConfig, 'chinese_typo', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(responseSplitterConfig, 'response_splitter', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(debugConfig, 'debug', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(maimMessageConfig, 'maim_message', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(telemetryConfig, 'telemetry', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(webuiConfig, 'webui', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(databaseConfig, 'database', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(mcpConfig, 'mcp', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(pluginRuntimeConfig, 'plugin_runtime', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(aMemorixConfig, 'a_memorix', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(botConfig, 'bot', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(personalityConfig, 'personality', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(chatConfig, 'chat', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(expressionConfig, 'expression', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(emojiConfig, 'emoji', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(memoryConfig, 'memory', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(visualConfig, 'visual', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(voiceConfig, 'voice', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(messageReceiveConfig, 'message_receive', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(keywordReactionConfig, 'keyword_reaction', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(responsePostProcessConfig, 'response_post_process', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(chineseTypoConfig, 'chinese_typo', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(responseSplitterConfig, 'response_splitter', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(logConfig, 'log', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(debugConfig, 'debug', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(maimMessageConfig, 'maim_message', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(telemetryConfig, 'telemetry', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(webuiConfig, 'webui', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(databaseConfig, 'database', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(mcpConfig, 'mcp', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(pluginRuntimeConfig, 'plugin_runtime', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(aMemorixConfig, 'a_memorix', initialLoadRef.current, triggerConfigAutoSave)
// 保存源代码
const saveSourceCode = async () => {
@@ -592,6 +632,21 @@ function BotConfigPageContent() {
await triggerRestart()
}
const dismissRestartNotice = () => {
localStorage.setItem('bot-config-restart-notice-dismissed', 'true')
setRestartNoticeVisible(false)
}
const handleReloadFromFile = async () => {
cancelPendingAutoSave()
await loadConfig()
setHasUnsavedChanges(false)
toast({
title: '已刷新',
description: '已从 bot_config.toml 重新读取配置',
})
}
// 保存并重启
const handleSaveAndRestart = async () => {
try {
@@ -650,6 +705,7 @@ function BotConfigPageContent() {
response_post_process: responsePostProcessConfig,
chinese_typo: chineseTypoConfig,
response_splitter: responseSplitterConfig,
log: logConfig,
debug: debugConfig,
maim_message: maimMessageConfig,
telemetry: telemetryConfig,
@@ -673,6 +729,7 @@ function BotConfigPageContent() {
responsePostProcessConfig,
chineseTypoConfig,
responseSplitterConfig,
logConfig,
debugConfig,
maimMessageConfig,
telemetryConfig,
@@ -699,6 +756,7 @@ function BotConfigPageContent() {
response_post_process: setResponsePostProcessConfig,
chinese_typo: setChineseTypoConfig,
response_splitter: setResponseSplitterConfig,
log: setLogConfig,
debug: setDebugConfig,
maim_message: setMaimMessageConfig,
telemetry: setTelemetryConfig,
@@ -736,6 +794,16 @@ function BotConfigPageContent() {
</div>
{/* 按钮组 - 桌面端靠右 */}
<div className="flex gap-2 flex-shrink-0">
<Button
onClick={handleReloadFromFile}
disabled={saving || autoSaving || isRestarting}
size="sm"
variant="outline"
className="w-20 sm:w-24"
>
<RefreshCw className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
</Button>
<Button
onClick={editMode === 'visual' ? saveConfig : saveSourceCode}
disabled={saving || autoSaving || !hasUnsavedChanges || isRestarting}
@@ -804,12 +872,19 @@ function BotConfigPageContent() {
</div>
{/* 重启提示 */}
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<strong></strong>"保存并重启"
</AlertDescription>
</Alert>
{restartNoticeVisible && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span>
<strong></strong>"保存并重启"
</span>
<Button type="button" variant="outline" size="sm" onClick={dismissRestartNotice}>
</Button>
</AlertDescription>
</Alert>
)}
{/* 源代码模式 */}
{editMode === 'source' && (
@@ -903,11 +978,34 @@ interface DynamicConfigTabsProps {
function DynamicConfigTabs(props: DynamicConfigTabsProps) {
const { configSchema, sectionValues, setHasUnsavedChanges, setSectionValue, tabGroups } = props
const [expanded, setExpanded] = useState(false)
const [activeTab, setActiveTab] = useState(tabGroups[0]?.id ?? '')
useEffect(() => {
if (!tabGroups.some((tab) => tab.id === activeTab)) {
setActiveTab(tabGroups[0]?.id ?? '')
}
}, [activeTab, tabGroups])
if (tabGroups.length === 0 || !configSchema?.nested) {
return null
}
const visibleTabGroups = expanded
? tabGroups
: tabGroups.filter((tab) => DEFAULT_VISIBLE_TAB_IDS.has(tab.id))
const hasCollapsibleTabs = tabGroups.some((tab) => !DEFAULT_VISIBLE_TAB_IDS.has(tab.id))
const toggleExpanded = () => {
setExpanded((current) => {
if (current && !DEFAULT_VISIBLE_TAB_IDS.has(activeTab)) {
const firstDefaultTab = tabGroups.find((tab) => DEFAULT_VISIBLE_TAB_IDS.has(tab.id))
setActiveTab(firstDefaultTab?.id ?? tabGroups[0]?.id ?? '')
}
return !current
})
}
const renderTabContent = (tab: TabGroup) => {
const tabNestedEntries = tab.sections
.map((sectionName) => [sectionName, configSchema.nested?.[sectionName]] as const)
@@ -953,9 +1051,9 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
}
return (
<Tabs defaultValue={tabGroups[0].id} className="w-full">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="flex flex-wrap h-auto gap-1 p-1">
{tabGroups.map((tab) => (
{visibleTabGroups.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
@@ -964,6 +1062,22 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
{tab.label}
</TabsTrigger>
))}
{hasCollapsibleTabs && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs sm:h-9 sm:px-3"
onClick={toggleExpanded}
>
{expanded ? (
<ChevronUp className="mr-1 h-3.5 w-3.5" />
) : (
<ChevronDown className="mr-1 h-3.5 w-3.5" />
)}
{expanded ? '收起' : '更多'}
</Button>
)}
</TabsList>
{tabGroups.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="space-y-4">

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react'
import { useCallback, useMemo, type CSSProperties } from 'react'
import * as LucideIcons from 'lucide-react'
import { Plus, Trash2 } from 'lucide-react'
@@ -31,6 +31,12 @@ export interface ListItemEditorOptions {
emptyText?: string
/** 顶部图标(覆盖 schema 自带的 x-icon */
iconName?: string
/** 紧凑布局:把指定字段放在同一行展示 */
fieldRows?: string[][]
/** Hook-local field UI metadata overrides */
fieldSchemaOverrides?: Record<string, Partial<FieldSchema>>
/** 添加按钮位置 */
addButtonPlacement?: 'top' | 'bottom'
}
function resolveLabel(schema?: ConfigSchema | FieldSchema, fieldPath?: string): string {
@@ -190,9 +196,105 @@ export function createListItemEditorHook(
[items, onChange],
)
const renderItemEditor = (item: Record<string, unknown>, index: number) => {
if (!nestedSchema) {
return null
}
if (!options.fieldRows?.length) {
return (
<DynamicConfigForm
schema={nestedSchema}
values={item}
onChange={(field, fieldValue) =>
handleItemFieldChange(index, field, fieldValue)
}
basePath=""
level={1}
/>
)
}
const applyFieldOverride = (field: FieldSchema): FieldSchema => ({
...field,
...(options.fieldSchemaOverrides?.[field.name] ?? {}),
})
const fieldMap = new Map(
nestedSchema.fields.map((field) => [field.name, applyFieldOverride(field)]),
)
const rowFieldNames = new Set(options.fieldRows.flat())
const remainingFields = nestedSchema.fields
.filter((field) => !rowFieldNames.has(field.name))
.map(applyFieldOverride)
const buildRowSchema = (fields: FieldSchema[]): ConfigSchema => ({
...nestedSchema,
fields,
nested: undefined,
})
return (
<div className="space-y-3">
{options.fieldRows.map((row, rowIndex) => {
const fields = row
.map((fieldName) => fieldMap.get(fieldName))
.filter((field): field is FieldSchema => Boolean(field))
if (fields.length === 0) {
return null
}
return (
<div
key={rowIndex}
className="grid gap-3 md:grid-cols-[repeat(var(--field-count),minmax(0,1fr))]"
style={{ '--field-count': fields.length } as CSSProperties}
>
{fields.map((field) => (
<DynamicConfigForm
key={field.name}
schema={buildRowSchema([field])}
values={item}
onChange={(fieldName, fieldValue) =>
handleItemFieldChange(index, fieldName, fieldValue)
}
basePath=""
level={1}
/>
))}
</div>
)
})}
{remainingFields.length > 0 && (
<DynamicConfigForm
schema={buildRowSchema(remainingFields)}
values={item}
onChange={(field, fieldValue) =>
handleItemFieldChange(index, field, fieldValue)
}
basePath=""
level={1}
/>
)}
</div>
)
}
const label = resolveLabel(schema, fieldPath)
const description = resolveDescription(schema)
const iconName = resolveIconName(options.iconName, schema, nestedSchema)
const addButtonPlacement = options.addButtonPlacement ?? 'bottom'
const addButton = (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAdd}
className="w-full"
>
<Plus className="mr-1 h-4 w-4" />
{options.addLabel ?? '添加一项'}
</Button>
)
if (!nestedSchema) {
return (
@@ -220,6 +322,7 @@ export function createListItemEditorHook(
)}
</CardHeader>
<CardContent className="space-y-3">
{addButtonPlacement === 'top' && addButton}
{items.length === 0 ? (
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-6 text-center text-sm text-muted-foreground">
{options.emptyText ?? '尚未添加任何条目,点击下方按钮新增。'}
@@ -251,29 +354,12 @@ export function createListItemEditorHook(
</Button>
</div>
<DynamicConfigForm
schema={nestedSchema}
values={item}
onChange={(field, fieldValue) =>
handleItemFieldChange(index, field, fieldValue)
}
basePath=""
level={1}
/>
{renderItemEditor(item, index)}
</div>
)
})
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAdd}
className="w-full"
>
<Plus className="mr-1 h-4 w-4" />
{options.addLabel ?? '添加一项'}
</Button>
{addButtonPlacement === 'bottom' && addButton}
</CardContent>
</Card>
)

View File

@@ -30,8 +30,13 @@ const collectStringList = (value: unknown): string[] => {
export const ChatTalkValueRulesHook = createListItemEditorHook({
addLabel: '添加发言频率规则',
addButtonPlacement: 'top',
helperText: '可按平台/聊天流/时段分别配置发言频率,留空表示全局。',
emptyText: '尚未配置任何规则,将使用全局默认频率。',
fieldRows: [
['platform', 'item_id', 'rule_type'],
['time', 'value'],
],
itemTitle: (item) => {
const time =
typeof item.time === 'string' && item.time.trim()
@@ -43,10 +48,45 @@ export const ChatTalkValueRulesHook = createListItemEditorHook({
},
})
export const ChatPromptsHook = createListItemEditorHook({
addLabel: '添加额外 Prompt',
helperText: '为指定平台和聊天流添加额外提示。platform、item_id 和 prompt 同时留空时表示空条目;填写任意一项后这三项都需要填写。',
emptyText: '尚未配置任何聊天额外 Prompt。',
addButtonPlacement: 'top',
fieldRows: [['platform', 'item_id', 'rule_type']],
fieldSchemaOverrides: {
item_id: {
'x-input-width': '8rem',
'x-layout': 'inline-right',
},
platform: {
'x-input-width': '8rem',
'x-layout': 'inline-right',
},
prompt: {
'x-textarea-min-height': 38,
'x-textarea-rows': 1,
},
rule_type: {
'x-input-width': '8rem',
'x-layout': 'inline-right',
},
},
iconName: 'file-text',
itemTitle: (item) => {
const prompt = typeof item.prompt === 'string' ? item.prompt.trim() : ''
return `${platformLabel(item)} · ${ruleTypeLabel(item.rule_type)} · ${prompt ? truncate(prompt) : '未填写 Prompt'}`
},
})
export const ExpressionLearningListHook = createListItemEditorHook({
addLabel: '添加表达学习规则',
helperText: '为不同聊天流单独配置是否启用表达/jargon 学习。',
emptyText: '尚未配置任何学习规则。',
fieldRows: [
['platform', 'item_id', 'rule_type'],
['use_expression', 'enable_learning', 'enable_jargon_learning'],
],
itemTitle: (item) => {
const flags: string[] = []
if (item.use_expression) flags.push('表达')

View File

@@ -11,6 +11,7 @@ export type {
UseAutoSaveReturnGeneric,
} from './useAutoSave'
export {
ChatPromptsHook,
ChatTalkValueRulesHook,
ExpressionGroupsHook,
ExpressionLearningListHook,

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useCallback, useRef, type MouseEvent } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -106,7 +106,14 @@ function ModelConfigPageContent() {
const [jumpToPage, setJumpToPage] = useState('')
const [advancedTemperatureMode, setAdvancedTemperatureMode] = useState(false)
const [advancedModelSettingsVisible, setAdvancedModelSettingsVisible] = useState(false)
const [advancedTaskSettingsVisible, setAdvancedTaskSettingsVisible] = useState(false)
const [restartNoticeVisible, setRestartNoticeVisible] = useState(
() => localStorage.getItem('model-config-restart-notice-dismissed') !== 'true'
)
const [tourEntryVisible, setTourEntryVisible] = useState(
() => localStorage.getItem('model-assignment-tour-entry-dismissed') !== 'true'
)
// 模型 Combobox 状态
const [modelComboboxOpen, setModelComboboxOpen] = useState(false)
@@ -130,11 +137,6 @@ function ModelConfigPageContent() {
const { toast } = useToast()
const { triggerRestart, isRestarting } = useRestart()
// Tour 引导 (使用 hook 封装的逻辑)
const { startTour: handleStartTour, isRunning: tourIsRunning } = useModelTour({
onCloseEditDialog: () => setEditDialogOpen(false),
})
// 自动保存 (使用 hook 封装的逻辑)
const { clearTimers: clearAutoSaveTimers, initialLoadRef } = useModelAutoSave({
models,
@@ -251,6 +253,17 @@ function ModelConfigPageContent() {
const handleRestart = async () => {
await triggerRestart()
}
const dismissRestartNotice = () => {
localStorage.setItem('model-config-restart-notice-dismissed', 'true')
setRestartNoticeVisible(false)
}
const dismissTourEntry = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
localStorage.setItem('model-assignment-tour-entry-dismissed', 'true')
setTourEntryVisible(false)
}
// 一键删除所有无效模型引用
const handleRemoveInvalidRefs = useCallback(() => {
@@ -285,6 +298,9 @@ function ModelConfigPageContent() {
api_provider: model.api_provider,
price_in: model.price_in ?? 0,
price_out: model.price_out ?? 0,
cache: model.cache ?? false,
cache_price_in: model.cache_price_in ?? 0,
visual: model.visual ?? false,
force_stream_mode: model.force_stream_mode ?? false,
extra_params: model.extra_params ?? {},
}
@@ -406,16 +422,26 @@ function ModelConfigPageContent() {
api_provider: providers[0] || '',
price_in: 0,
price_out: 0,
cache: false,
cache_price_in: 0,
temperature: null,
max_tokens: null,
visual: false,
force_stream_mode: false,
extra_params: {},
}
)
setAdvancedModelSettingsVisible(false)
setEditingIndex(index)
setEditDialogOpen(true)
}
// Tour 引导 (使用 hook 封装的逻辑)
const { startTour: handleStartTour, isRunning: tourIsRunning } = useModelTour({
onOpenEditDialog: () => openEditDialog(null, null),
onCloseEditDialog: () => setEditDialogOpen(false),
})
// 保存编辑
const handleSaveEdit = () => {
if (!editingModel) return
@@ -459,6 +485,9 @@ function ModelConfigPageContent() {
api_provider: editingModel.api_provider,
price_in: editingModel.price_in ?? 0,
price_out: editingModel.price_out ?? 0,
cache: editingModel.cache ?? false,
cache_price_in: editingModel.cache_price_in ?? 0,
visual: editingModel.visual ?? false,
force_stream_mode: editingModel.force_stream_mode ?? false,
extra_params: editingModel.extra_params ?? {},
}
@@ -792,12 +821,19 @@ function ModelConfigPageContent() {
</div>
{/* 重启提示 */}
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<strong></strong>"保存并重启"
</AlertDescription>
</Alert>
{restartNoticeVisible && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span>
<strong></strong>"保存并重启"
</span>
<Button type="button" variant="outline" size="sm" onClick={dismissRestartNotice}>
</Button>
</AlertDescription>
</Alert>
)}
{/* 无效模型引用警告 */}
{invalidModelRefs.length > 0 && (
@@ -841,23 +877,30 @@ function ModelConfigPageContent() {
{/* 新手引导入口 - 仅在桌面端显示,移动端隐藏 */}
{tourEntryVisible && (
<Alert className="hidden lg:flex border-primary/30 bg-primary/5 cursor-pointer hover:bg-primary/10 transition-colors" onClick={handleStartTour}>
<GraduationCap className="h-4 w-4 text-primary" />
<AlertDescription className="flex items-center justify-between">
<span>
<strong className="text-primary"></strong>
</span>
<Button variant="outline" size="sm" className="ml-4 shrink-0">
<div className="ml-4 flex shrink-0 items-center gap-2">
<Button variant="outline" size="sm">
</Button>
<Button type="button" variant="ghost" size="sm" onClick={dismissTourEntry}>
</Button>
</div>
</AlertDescription>
</Alert>
)}
{/* 标签页 */}
<Tabs defaultValue="models" className="w-full">
<TabsList className="grid w-full max-w-full sm:max-w-md grid-cols-2">
<TabsTrigger value="models"></TabsTrigger>
<TabsTrigger value="tasks" data-tour="tasks-tab-trigger"></TabsTrigger>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="models" className="w-full"></TabsTrigger>
<TabsTrigger value="tasks" className="w-full" data-tour="tasks-tab-trigger"></TabsTrigger>
</TabsList>
{/* 模型配置标签页 */}
<TabsContent value="models" className="space-y-4 mt-0">
@@ -976,6 +1019,7 @@ function ModelConfigPageContent() {
modelNames={modelNames}
onChange={(f, value) => updateTaskConfig(field.name, f, value)}
advanced={field.advanced}
showAdvancedSettings={advancedTaskSettingsVisible}
{...(index === 0 ? { dataTour: 'task-model-select' } : {})}
/>
)
@@ -997,64 +1041,89 @@ function ModelConfigPageContent() {
<DialogTitle>
{editingIndex !== null ? '编辑模型' : '添加模型'}
</DialogTitle>
<DialogDescription></DialogDescription>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<DialogDescription></DialogDescription>
<Button
type="button"
variant={advancedModelSettingsVisible ? 'default' : 'outline'}
size="sm"
onClick={() => setAdvancedModelSettingsVisible((current) => !current)}
className="self-start sm:self-auto"
>
</Button>
</div>
</DialogHeader>
<DialogBody>
<div className="grid gap-4 py-4">
<div className="grid gap-2" data-tour="model-name-input">
<Label htmlFor="model_name" className={formErrors.name ? 'text-destructive' : ''}> *</Label>
<Input
id="model_name"
value={editingModel?.name || ''}
onChange={(e) => {
setEditingModel((prev) =>
prev ? { ...prev, name: e.target.value } : null
)
if (formErrors.name) {
setFormErrors((prev) => ({ ...prev, name: undefined }))
}
}}
placeholder="例如: qwen3-30b"
className={formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''}
/>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Label
htmlFor="model_name"
className={`sm:w-28 sm:flex-shrink-0 ${formErrors.name ? 'text-destructive' : ''}`}
>
*
</Label>
<Input
id="model_name"
value={editingModel?.name || ''}
onChange={(e) => {
setEditingModel((prev) =>
prev ? { ...prev, name: e.target.value } : null
)
if (formErrors.name) {
setFormErrors((prev) => ({ ...prev, name: undefined }))
}
}}
placeholder="例如: qwen3-30b"
className={`sm:flex-1 ${formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''}`}
/>
</div>
{formErrors.name ? (
<p className="text-xs text-destructive">{formErrors.name}</p>
<p className="text-xs text-destructive sm:pl-28">{formErrors.name}</p>
) : (
<p className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground sm:pl-28">
</p>
)}
</div>
<div className="grid gap-2" data-tour="model-provider-select">
<Label htmlFor="api_provider" className={formErrors.api_provider ? 'text-destructive' : ''}>API *</Label>
<Select
value={editingModel?.api_provider || ''}
onValueChange={(value) => {
setEditingModel((prev) =>
prev ? { ...prev, api_provider: value } : null
)
// 清空模型列表和错误状态,等待 useEffect 重新获取
clearModels()
if (formErrors.api_provider) {
setFormErrors((prev) => ({ ...prev, api_provider: undefined }))
}
}}
>
<SelectTrigger id="api_provider" className={formErrors.api_provider ? 'border-destructive focus-visible:ring-destructive' : ''}>
<SelectValue placeholder="选择提供商" />
</SelectTrigger>
<SelectContent>
{providers.map((provider) => (
<SelectItem key={provider} value={provider}>
{provider}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Label
htmlFor="api_provider"
className={`sm:w-28 sm:flex-shrink-0 ${formErrors.api_provider ? 'text-destructive' : ''}`}
>
API *
</Label>
<Select
value={editingModel?.api_provider || ''}
onValueChange={(value) => {
setEditingModel((prev) =>
prev ? { ...prev, api_provider: value } : null
)
// 清空模型列表和错误状态,等待 useEffect 重新获取
clearModels()
if (formErrors.api_provider) {
setFormErrors((prev) => ({ ...prev, api_provider: undefined }))
}
}}
>
<SelectTrigger id="api_provider" className={`sm:flex-1 ${formErrors.api_provider ? 'border-destructive focus-visible:ring-destructive' : ''}`}>
<SelectValue placeholder="选择提供商" />
</SelectTrigger>
<SelectContent>
{providers.map((provider) => (
<SelectItem key={provider} value={provider}>
{provider}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{formErrors.api_provider && (
<p className="text-xs text-destructive">{formErrors.api_provider}</p>
<p className="text-xs text-destructive sm:pl-28">{formErrors.api_provider}</p>
)}
</div>
@@ -1277,6 +1346,50 @@ function ModelConfigPageContent() {
</div>
</div>
{advancedModelSettingsVisible && (
<div className="rounded-lg border border-amber-200 bg-amber-50/50 p-4 space-y-4 dark:border-amber-500/40 dark:bg-amber-500/10">
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="model_cache" className="cursor-pointer"></Label>
<p className="text-xs text-muted-foreground">
token
</p>
</div>
<Switch
id="model_cache"
checked={editingModel?.cache || false}
onCheckedChange={(checked) =>
setEditingModel((prev) =>
prev ? { ...prev, cache: checked } : null
)
}
/>
</div>
{editingModel?.cache && (
<div className="grid gap-2 border-t pt-4">
<Label htmlFor="cache_price_in"> (¥/M token)</Label>
<Input
id="cache_price_in"
type="number"
step="0.1"
min="0"
value={editingModel?.cache_price_in ?? ''}
onChange={(e) => {
const val = e.target.value === '' ? null : parseFloat(e.target.value)
setEditingModel((prev) =>
prev
? { ...prev, cache_price_in: val }
: null
)
}}
placeholder="默认: 0"
/>
</div>
)}
</div>
)}
{/* 模型级别温度 */}
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
@@ -1459,6 +1572,21 @@ function ModelConfigPageContent() {
)}
</div>
<div className="flex items-center space-x-2">
<Switch
id="model_visual"
checked={editingModel?.visual || false}
onCheckedChange={(checked) =>
setEditingModel((prev) =>
prev ? { ...prev, visual: checked } : null
)
}
/>
<Label htmlFor="model_visual" className="cursor-pointer">
</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
id="force_stream_mode"

View File

@@ -55,6 +55,11 @@ export const ModelCardList = React.memo(function ModelCardList({
>
{used ? '已使用' : '未使用'}
</Badge>
{model.visual && (
<Badge variant="outline" className="border-blue-500 text-blue-600">
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground break-all" title={model.model_identifier}>
{model.model_identifier}

View File

@@ -67,6 +67,7 @@ export const ModelTable = React.memo(function ModelTable({
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
@@ -76,7 +77,7 @@ export const ModelTable = React.memo(function ModelTable({
<TableBody>
{paginatedModels.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center text-muted-foreground py-8">
<TableCell colSpan={10} className="text-center text-muted-foreground py-8">
{searchQuery ? '未找到匹配的模型' : '暂无模型配置'}
</TableCell>
</TableRow>
@@ -105,6 +106,15 @@ export const ModelTable = React.memo(function ModelTable({
{model.model_identifier}
</TableCell>
<TableCell>{model.api_provider}</TableCell>
<TableCell className="text-center">
{model.visual ? (
<Badge variant="outline" className="border-blue-500 text-blue-600">
</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-center">
{model.temperature != null ? model.temperature : <span className="text-muted-foreground">-</span>}
</TableCell>
@@ -139,4 +149,4 @@ export const ModelTable = React.memo(function ModelTable({
</div>
</div>
)
})
})

View File

@@ -13,6 +13,12 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type { TaskConfig } from '../types'
@@ -25,9 +31,28 @@ interface TaskConfigCardProps {
hideTemperature?: boolean
hideMaxTokens?: boolean
advanced?: boolean
showAdvancedSettings?: boolean
dataTour?: string
}
const selectionStrategyOptions = [
{
value: 'balance',
label: '负载均衡balance',
description: '优先选择当前使用次数较少的模型,适合多个同类模型共同承担请求。',
},
{
value: 'random',
label: '随机选择random',
description: '每次请求从模型列表中随机选择一个模型,适合简单分散请求。',
},
{
value: 'sequential',
label: '按顺序优先sequential',
description: '优先使用模型列表中靠前的模型,前面的模型不可用时再尝试后面的模型。',
},
]
export const TaskConfigCard = React.memo(function TaskConfigCard({
title,
description,
@@ -37,6 +62,7 @@ export const TaskConfigCard = React.memo(function TaskConfigCard({
hideTemperature = false,
hideMaxTokens = false,
advanced = false,
showAdvancedSettings = false,
dataTour,
}: TaskConfigCardProps) {
const handleModelChange = (values: string[]) => {
@@ -68,8 +94,8 @@ export const TaskConfigCard = React.memo(function TaskConfigCard({
/>
</div>
{/* 温度和最大 Token */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* 推理参数 */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
{!hideTemperature && (
<div className="grid gap-3">
<div className="flex items-center justify-between">
@@ -112,51 +138,66 @@ export const TaskConfigCard = React.memo(function TaskConfigCard({
/>
</div>
)}
</div>
{/* 慢请求阈值 */}
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label> ()</Label>
<span className="text-xs text-muted-foreground"></span>
{/* 模型选择策略 */}
<div className="grid gap-2">
<Label></Label>
<Select
value={taskConfig.selection_strategy ?? 'balance'}
onValueChange={(value) => onChange('selection_strategy', value)}
>
<SelectTrigger>
<SelectValue placeholder="选择模型选择策略" />
</SelectTrigger>
<SelectContent>
<TooltipProvider delayDuration={150}>
{selectionStrategyOptions.map((option) => (
<Tooltip key={option.value}>
<TooltipTrigger asChild>
<SelectItem value={option.value} title={option.description}>
{option.label}
</SelectItem>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
className="max-w-72 bg-background text-foreground border shadow-lg"
>
{option.description}
</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</SelectContent>
</Select>
</div>
<Input
type="number"
step="1"
min="1"
value={taskConfig.slow_threshold ?? 15}
onChange={(e) => {
const value = parseInt(e.target.value)
if (!isNaN(value) && value >= 1) {
onChange('slow_threshold', value)
}
}}
placeholder="15"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 模型选择策略 */}
<div className="grid gap-2">
<Label></Label>
<Select
value={taskConfig.selection_strategy ?? 'balance'}
onValueChange={(value) => onChange('selection_strategy', value)}
>
<SelectTrigger>
<SelectValue placeholder="选择模型选择策略" />
</SelectTrigger>
<SelectContent>
<SelectItem value="balance">balance</SelectItem>
<SelectItem value="random">random</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
使
</p>
</div>
{showAdvancedSettings && (
<div className="grid gap-2 rounded-md border border-amber-200 bg-amber-50/50 p-3 dark:border-amber-500/40 dark:bg-amber-500/10">
<div className="flex items-center justify-between">
<Label> ()</Label>
<span className="text-xs text-muted-foreground"></span>
</div>
<Input
type="number"
step="1"
min="1"
value={taskConfig.slow_threshold ?? 15}
onChange={(e) => {
const value = parseInt(e.target.value)
if (!isNaN(value) && value >= 1) {
onChange('slow_threshold', value)
}
}}
placeholder="15"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
)}
</div>
</div>
)

View File

@@ -18,6 +18,16 @@ export const modelListCache = new Map<string, { models: ModelListItem[], timesta
* 任务配置信息
*/
export const TASK_CONFIGS = [
{
key: 'replyer' as const,
title: '回复模型 (replyer)',
description: '用于表达器和表达方式学习',
},
{
key: 'planner' as const,
title: '规划模型 (planner)',
description: '负责决定麦麦该什么时候回复',
},
{
key: 'utils' as const,
title: '组件模型 (utils)',
@@ -33,16 +43,6 @@ export const TASK_CONFIGS = [
title: '工具调用模型 (tool_use)',
description: '需要使用支持工具调用的模型',
},
{
key: 'replyer' as const,
title: '首要回复模型 (replyer)',
description: '用于表达器和表达方式学习',
},
{
key: 'planner' as const,
title: '决策模型 (planner)',
description: '负责决定麦麦该什么时候回复',
},
{
key: 'vlm' as const,
title: '图像识别模型 (vlm)',
@@ -55,6 +55,7 @@ export const TASK_CONFIGS = [
description: '语音转文字',
hideTemperature: true,
hideMaxTokens: true,
advanced: true,
},
{
key: 'embedding' as const,
@@ -95,8 +96,11 @@ export const DEFAULT_MODEL_INFO = {
api_provider: '',
price_in: 0,
price_out: 0,
cache: false,
cache_price_in: 0,
temperature: null,
max_tokens: null,
visual: false,
force_stream_mode: false,
extra_params: {},
} as const

View File

@@ -66,6 +66,9 @@ export function useModelAutoSave(
api_provider: model.api_provider,
price_in: model.price_in ?? 0,
price_out: model.price_out ?? 0,
cache: model.cache ?? false,
cache_price_in: model.cache_price_in ?? 0,
visual: model.visual ?? false,
force_stream_mode: model.force_stream_mode ?? false,
extra_params: model.extra_params ?? {},
}

View File

@@ -7,6 +7,8 @@ import { useTour } from '@/components/tour'
import { MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps, STEP_ROUTE_MAP } from '@/components/tour/tours/model-assignment-tour'
interface UseModelTourOptions {
/** 打开模型编辑对话框回调 */
onOpenEditDialog?: () => void
/** 关闭编辑对话框回调 */
onCloseEditDialog?: () => void
}
@@ -24,13 +26,33 @@ interface UseModelTourReturn {
* Model 配置页面 Tour 引导 Hook
*/
export function useModelTour(options: UseModelTourOptions = {}): UseModelTourReturn {
const { onCloseEditDialog } = options
const { onOpenEditDialog, onCloseEditDialog } = options
const navigate = useNavigate()
const { registerTour, startTour: startTourFn, state: tourState, goToStep } = useTour()
// 用于追踪前一个步骤
const prevTourStepRef = useRef(tourState.stepIndex)
const didClickTourTarget = useCallback((event: MouseEvent, selector: string) => {
const target = event.target instanceof Element ? event.target : null
if (target?.closest(selector)) {
return true
}
const element = document.querySelector(selector)
if (!element) {
return false
}
const rect = element.getBoundingClientRect()
return (
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom
)
}, [])
// 注册 Tour
useEffect(() => {
registerTour(MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps)
@@ -67,34 +89,59 @@ export function useModelTour(options: UseModelTourOptions = {}): UseModelTourRet
if (tourState.activeTourId !== MODEL_ASSIGNMENT_TOUR_ID || !tourState.isRunning) return
const handleTourClick = (e: MouseEvent) => {
const target = e.target as HTMLElement
const currentStep = tourState.stepIndex
// Step 3 (index 2): 点击添加提供商按钮
if (currentStep === 2 && target.closest('[data-tour="add-provider-button"]')) {
if (currentStep === 2 && didClickTourTarget(e, '[data-tour="add-provider-button"]')) {
setTimeout(() => goToStep(3), 300)
}
// Step 10 (index 9): 点击取消按钮(关闭提供商弹窗)
else if (currentStep === 9 && target.closest('[data-tour="provider-cancel-button"]')) {
else if (currentStep === 9 && didClickTourTarget(e, '[data-tour="provider-cancel-button"]')) {
setTimeout(() => goToStep(10), 300)
}
// Step 12 (index 11): 点击添加模型按钮
else if (currentStep === 11 && target.closest('[data-tour="add-model-button"]')) {
else if (currentStep === 11 && didClickTourTarget(e, '[data-tour="add-model-button"]')) {
onOpenEditDialog?.()
setTimeout(() => goToStep(12), 300)
}
// Step 18 (index 17): 点击取消按钮(关闭模型弹窗)
else if (currentStep === 17 && target.closest('[data-tour="model-cancel-button"]')) {
else if (currentStep === 17 && didClickTourTarget(e, '[data-tour="model-cancel-button"]')) {
setTimeout(() => goToStep(18), 300)
}
// Step 19 (index 18): 点击为模型分配功能标签页
else if (currentStep === 18 && target.closest('[data-tour="tasks-tab-trigger"]')) {
else if (currentStep === 18 && didClickTourTarget(e, '[data-tour="tasks-tab-trigger"]')) {
setTimeout(() => goToStep(19), 300)
}
}
document.addEventListener('click', handleTourClick, true)
return () => document.removeEventListener('click', handleTourClick, true)
}, [tourState, goToStep])
}, [tourState, goToStep, onOpenEditDialog, didClickTourTarget])
// Step 12 的 spotlight 点击在部分浏览器/布局下会被 Joyride 遮罩截获。
// 这里直接给目标按钮补一个原生监听,确保点中按钮时能打开模型弹窗。
useEffect(() => {
if (
tourState.activeTourId !== MODEL_ASSIGNMENT_TOUR_ID ||
!tourState.isRunning ||
tourState.stepIndex !== 11
) {
return
}
const addModelButton = document.querySelector('[data-tour="add-model-button"]')
if (!addModelButton) {
return
}
const handleAddModelButtonClick = () => {
onOpenEditDialog?.()
setTimeout(() => goToStep(12), 300)
}
addModelButton.addEventListener('click', handleAddModelButtonClick, true)
return () => addModelButton.removeEventListener('click', handleAddModelButtonClick, true)
}, [tourState.activeTourId, tourState.isRunning, tourState.stepIndex, goToStep, onOpenEditDialog])
// 开始引导
const handleStartTour = useCallback(() => {

View File

@@ -11,8 +11,11 @@ export interface ModelInfo {
api_provider: string
price_in: number | null
price_out: number | null
cache?: boolean
cache_price_in?: number | null
temperature?: number | null // 模型级别温度,覆盖任务配置中的温度
max_tokens?: number | null // 模型级别最大token数覆盖任务配置中的max_tokens
visual?: boolean
force_stream_mode?: boolean
extra_params?: Record<string, unknown>
}

View File

@@ -67,6 +67,9 @@ function ModelProviderConfigPageContent() {
})
const [testingProviders, setTestingProviders] = useState<Set<string>>(new Set())
const [testResults, setTestResults] = useState<Map<string, TestConnectionResult>>(new Map())
const [restartNoticeVisible, setRestartNoticeVisible] = useState(
() => localStorage.getItem('model-provider-restart-notice-dismissed') !== 'true'
)
const { toast } = useToast()
const navigate = useNavigate()
@@ -172,6 +175,11 @@ function ModelProviderConfigPageContent() {
await triggerRestart()
}
const dismissRestartNotice = () => {
localStorage.setItem('model-provider-restart-notice-dismissed', 'true')
setRestartNoticeVisible(false)
}
const handleSaveAndRestart = async () => {
try {
setSaving(true)
@@ -796,12 +804,19 @@ function ModelProviderConfigPageContent() {
</div>
{/* 重启提示 */}
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<strong></strong>"保存并重启"
</AlertDescription>
</Alert>
{restartNoticeVisible && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span>
<strong></strong>"保存并重启"
</span>
<Button type="button" variant="outline" size="sm" onClick={dismissRestartNotice}>
</Button>
</AlertDescription>
</Alert>
)}
<ScrollArea className="h-[calc(100vh-260px)]">
<ProviderList

View File

@@ -440,6 +440,9 @@ function MCPSettingsPageContent() {
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const [mcpConfig, setMcpConfig] = useState<ConfigSectionData>({})
const [mcpSchema, setMcpSchema] = useState<ConfigSchema | null>(null)
const [restartNoticeVisible, setRestartNoticeVisible] = useState(
() => localStorage.getItem('mcp-settings-restart-notice-dismissed') !== 'true',
)
const { toast } = useToast()
const { triggerRestart, isRestarting } = useRestart()
@@ -547,6 +550,11 @@ function MCPSettingsPageContent() {
await triggerRestart({ delay: 500 })
}, [saveConfig, triggerRestart])
const dismissRestartNotice = useCallback(() => {
localStorage.setItem('mcp-settings-restart-notice-dismissed', 'true')
setRestartNoticeVisible(false)
}, [])
const formSchema: ConfigSchema | null = mcpSchema
? {
className: 'MCPSettings',
@@ -600,12 +608,17 @@ function MCPSettingsPageContent() {
</div>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
MCP MCP 使
</AlertDescription>
</Alert>
{restartNoticeVisible && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span>MCP MCP 使</span>
<Button type="button" variant="outline" size="sm" onClick={dismissRestartNotice}>
</Button>
</AlertDescription>
</Alert>
)}
{loading && (
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">

View File

@@ -280,7 +280,7 @@ export function EmojiManagementPage() {
{/* 页面标题 */}
<div className="mb-4 sm:mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold"></h1>
<h1 className="text-2xl sm:text-3xl font-bold"></h1>
<p className="text-sm text-muted-foreground mt-1">
</p>

View File

@@ -60,8 +60,8 @@ export function ExpressionDetailDialog({
return new Date(timestamp * 1000).toLocaleString('zh-CN')
}
const getChatName = (chatId: string): string => {
return chatNameMap.get(chatId) || chatId
const getChatName = (): string => {
return expression.chat_name || chatNameMap.get(expression.chat_id) || expression.chat_id
}
return (
@@ -81,7 +81,7 @@ export function ExpressionDetailDialog({
<InfoItem label="风格" value={expression.style} />
<InfoItem
label="聊天"
value={getChatName(expression.chat_id)}
value={getChatName()}
/>
<InfoItem icon={Hash} label="记录ID" value={expression.id.toString()} mono />
</div>

View File

@@ -51,8 +51,8 @@ export function ExpressionList({
}) {
const { toast } = useToast()
const getChatName = (chatId: string): string => {
return chatNameMap.get(chatId) || chatId
const getChatName = (expression: Expression): string => {
return expression.chat_name || chatNameMap.get(expression.chat_id) || expression.chat_id
}
const totalPages = Math.ceil(total / pageSize)
@@ -117,11 +117,11 @@ export function ExpressionList({
<TableCell className="max-w-xs truncate">{expression.style}</TableCell>
<TableCell
className="max-w-[200px] truncate"
title={getChatName(expression.chat_id)}
title={getChatName(expression)}
style={{ wordBreak: 'keep-all' }}
>
<span className="whitespace-nowrap overflow-hidden text-ellipsis block">
{getChatName(expression.chat_id)}
{getChatName(expression)}
</span>
</TableCell>
<TableCell className="text-right">
@@ -201,10 +201,10 @@ export function ExpressionList({
<div className="text-xs text-muted-foreground mb-1"></div>
<p
className="text-sm truncate"
title={getChatName(expression.chat_id)}
title={getChatName(expression)}
style={{ wordBreak: 'keep-all' }}
>
{getChatName(expression.chat_id)}
{getChatName(expression)}
</p>
</div>

View File

@@ -267,7 +267,7 @@ export function ExpressionManagementPage() {
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2">
<MessageSquare className="h-8 w-8" strokeWidth={2} />
</h1>
<p className="text-muted-foreground mt-1 text-sm sm:text-base">
@@ -316,28 +316,21 @@ export function ExpressionManagementPage() {
{/* 搜索和批量操作 */}
<div className="rounded-lg border bg-card p-4">
<Label htmlFor="search"></Label>
<div className="flex flex-col sm:flex-row gap-2 mt-1.5">
<div className="flex-1 relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
id="search"
placeholder="搜索情境、风格或上下文..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex-1">
<Label htmlFor="search"></Label>
<div className="relative mt-1.5">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
id="search"
placeholder="搜索情境、风格或上下文..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
</div>
{/* 批量操作工具栏 */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mt-4 pt-4 border-t">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{selectedIds.size > 0 && (
<span> {selectedIds.size} </span>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 sm:pb-0.5">
<Label htmlFor="page-size" className="text-sm whitespace-nowrap"></Label>
<Select
value={pageSize.toString()}
@@ -357,6 +350,17 @@ export function ExpressionManagementPage() {
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 批量操作工具栏 */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mt-4 pt-4 border-t">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{selectedIds.size > 0 && (
<span> {selectedIds.size} </span>
)}
</div>
<div className="flex items-center gap-2">
{selectedIds.size > 0 && (
<>
<Button

View File

@@ -250,7 +250,7 @@ export function JargonManagementPage() {
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2">
<MessageCircle className="h-8 w-8" strokeWidth={2} />
</h1>
<p className="text-muted-foreground mt-1 text-sm sm:text-base">