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

@@ -1,12 +1,12 @@
{
"name": "maibot-dashboard",
"version": "1.0.3",
"version": "1.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "maibot-dashboard",
"version": "1.0.3",
"version": "1.0.5",
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-javascript": "^6.2.4",

View File

@@ -1,7 +1,7 @@
{
"name": "maibot-dashboard",
"private": true,
"version": "1.0.4",
"version": "1.0.5",
"type": "module",
"main": "./out/main/index.js",
"scripts": {

View File

@@ -76,7 +76,6 @@ function DynamicConfigSection({
basePath,
hooks,
level,
mergedChildren = [],
nestedSchema,
onChange,
sectionDescription,
@@ -87,11 +86,6 @@ function DynamicConfigSection({
basePath: string
hooks: FieldHookRegistry
level: number
mergedChildren?: Array<{
key: string
schema: ConfigSchema
values: Record<string, unknown>
}>
nestedSchema: ConfigSchema
onChange: (field: string, value: unknown) => void
sectionDescription?: string
@@ -100,9 +94,7 @@ function DynamicConfigSection({
values: Record<string, unknown>
}) {
const [advancedVisible, setAdvancedVisible] = React.useState(false)
const hasAdvanced =
hasTopLevelAdvancedFields(nestedSchema) ||
mergedChildren.some((child) => hasTopLevelAdvancedFields(child.schema))
const hasAdvanced = hasTopLevelAdvancedFields(nestedSchema)
return (
<Card>
@@ -135,37 +127,6 @@ function DynamicConfigSection({
level={level}
advancedVisible={hasAdvanced ? advancedVisible : undefined}
/>
{mergedChildren.map((child) => {
const childTitle = resolveSectionTitle(child.schema)
const childDescription = resolveSectionDescription(child.schema, childTitle)
const parentPath = basePath.includes('.')
? basePath.replace(/\.[^.]+$/, '')
: ''
const childPath = buildFieldPath(parentPath, child.key)
return (
<div key={child.key} className="mt-5 border-t border-border/50 pt-4">
<div className="mb-3 space-y-1">
<div className="flex items-center gap-2">
<SectionIcon iconName={child.schema.uiIcon} />
<h3 className="text-sm font-medium">{childTitle}</h3>
</div>
{childDescription && (
<p className="text-xs text-muted-foreground">{childDescription}</p>
)}
</div>
<DynamicConfigForm
schema={child.schema}
values={child.values}
onChange={(field, value) => onChange(`${child.key}.${field}`, value)}
basePath={childPath}
hooks={hooks}
level={level}
advancedVisible={hasAdvanced ? advancedVisible : undefined}
/>
</div>
)
})}
</CardContent>
</Card>
)
@@ -197,17 +158,6 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
() => new Map(schema.fields.map((field) => [field.name, field])),
[schema.fields],
)
const mergedChildKeys = React.useMemo(() => {
const keys = new Set<string>()
for (const nestedSchema of Object.values(schema.nested ?? {})) {
for (const childKey of nestedSchema.uiMergeChildren ?? []) {
if (schema.nested?.[childKey]) {
keys.add(childKey)
}
}
}
return keys
}, [schema.nested])
const renderField = (field: FieldSchema) => {
const fieldPath = buildFieldPath(basePath, field.name)
@@ -294,7 +244,6 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
{schema.nested &&
Object.entries(schema.nested)
.filter(([key]) => !mergedChildKeys.has(key))
.map(([key, nestedSchema]) => {
const nestedField = fieldMap.get(key)
const nestedFieldPath = buildFieldPath(basePath, key)
@@ -342,34 +291,11 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
const sectionTitle = resolveSectionTitle(nestedSchema)
const sectionDescription = resolveSectionDescription(nestedSchema, sectionTitle)
const mergedChildren = (nestedSchema.uiMergeChildren ?? [])
.map((childKey) => {
const childSchema = schema.nested?.[childKey]
if (!childSchema) {
return null
}
return {
key: childKey,
schema: childSchema,
values: (values[childKey] as Record<string, unknown>) || {},
}
})
.filter(
(
child,
): child is {
key: string
schema: ConfigSchema
values: Record<string, unknown>
} => Boolean(child),
)
if (level === 0) {
return (
<DynamicConfigSection
key={key}
mergedChildren={mergedChildren}
nestedSchema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
onChange={onChange}

View File

@@ -31,6 +31,23 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
value,
onChange,
}) => {
const parseNumericValue = (rawValue: unknown, fallback: number) => {
if (typeof rawValue === 'number' && Number.isFinite(rawValue)) {
return rawValue
}
if (typeof rawValue === 'string') {
const parsedValue = schema.type === 'integer'
? parseInt(rawValue, 10)
: parseFloat(rawValue)
if (Number.isFinite(parsedValue)) {
return parsedValue
}
}
return fallback
}
const renderPrimitiveArrayEditor = () => {
const itemType = schema.items?.type ?? 'string'
const arrayValue = Array.isArray(value)
@@ -94,6 +111,12 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
return <IconComponent className="h-4 w-4" />
}
const isRuleTypeSelect =
schema.name === 'rule_type' &&
(schema.type === 'select' || schema['x-widget'] === 'select')
const inlineDescription = isRuleTypeSelect ? '' : schema.description
const selectHoverDescription = isRuleTypeSelect ? schema.description : undefined
const renderFieldHeader = () => (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Label
@@ -108,9 +131,9 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
<span className="break-all">{schema.label}</span>
{schema.required && <span className="text-destructive">*</span>}
</Label>
{schema.description && (
{inlineDescription && (
<span className="text-[13px] leading-6 text-muted-foreground whitespace-pre-line">
{schema.description}
{inlineDescription}
</span>
)}
</div>
@@ -129,6 +152,9 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
case 'slider':
return renderSlider()
case 'input':
if (type === 'integer' || type === 'number') {
return renderNumberInput()
}
return renderTextInput()
case 'number':
return renderNumberInput()
@@ -214,7 +240,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
* 渲染 Slider 组件(用于 number 类型 + x-widget: slider
*/
const renderSlider = () => {
const numValue = typeof value === 'number' ? value : (schema.default as number ?? 0)
const numValue = parseNumericValue(value, schema.default as number ?? 0)
const min = schema.minValue ?? 0
const max = schema.maxValue ?? 100
const step = schema.step ?? 1
@@ -241,7 +267,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
* 渲染 Input[type="number"] 组件(用于 number/integer 类型)
*/
const renderNumberInput = () => {
const numValue = typeof value === 'number' ? value : (schema.default as number ?? 0)
const numValue = parseNumericValue(value, schema.default as number ?? 0)
const min = schema.minValue
const max = schema.maxValue
const step = schema.step ?? (schema.type === 'integer' ? 1 : 0.1)
@@ -250,7 +276,12 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
<Input
type="number"
value={numValue}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
onChange={(e) => {
const nextValue = schema.type === 'integer'
? parseInt(e.target.value, 10)
: parseFloat(e.target.value)
onChange(Number.isFinite(nextValue) ? nextValue : 0)
}}
min={min}
max={max}
step={step}
@@ -262,7 +293,12 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
* 渲染 Input[type="text"] 组件(用于 string 类型)
*/
const renderTextInput = (type: 'password' | 'text' = 'text') => {
const strValue = typeof value === 'string' ? value : (schema.default as string ?? '')
const strValue =
typeof value === 'string'
? value
: value === null || value === undefined
? String(schema.default ?? '')
: String(value)
return (
<Input
type={type}
@@ -277,11 +313,19 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
*/
const renderTextarea = () => {
const strValue = typeof value === 'string' ? value : (schema.default as string ?? '')
const minHeight = typeof schema['x-textarea-min-height'] === 'number'
? schema['x-textarea-min-height']
: undefined
const rows = typeof schema['x-textarea-rows'] === 'number'
? schema['x-textarea-rows']
: 4
return (
<Textarea
value={strValue}
onChange={(e) => onChange(e.target.value)}
rows={4}
rows={rows}
minHeight={minHeight}
/>
)
}
@@ -303,7 +347,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
return (
<Select value={strValue} onValueChange={(val) => onChange(val)}>
<SelectTrigger>
<SelectTrigger title={selectHoverDescription}>
<SelectValue placeholder={`Select ${schema.label}`} />
</SelectTrigger>
<SelectContent>

View File

@@ -100,6 +100,42 @@ describe('DynamicField', () => {
expect(screen.getByText('Custom field requires Hook')).toBeInTheDocument()
})
it('renders number Input when x-widget is input but type is integer', () => {
const schema: FieldSchema = {
name: 'test_integer_input_widget',
type: 'integer',
label: 'Test Integer Input Widget',
description: 'A numeric field rendered as input',
required: false,
'x-widget': 'input',
default: 0,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={2} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
expect(input).toHaveValue(2)
})
it('parses string values for numeric input widgets', () => {
const schema: FieldSchema = {
name: 'test_string_number_input_widget',
type: 'integer',
label: 'Test String Number Input Widget',
description: 'A numeric field with legacy string value',
required: false,
'x-widget': 'input',
default: 0,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="2" onChange={onChange} />)
expect(screen.getByRole('spinbutton')).toHaveValue(2)
})
})
describe('type fallback', () => {
@@ -305,6 +341,27 @@ describe('DynamicField', () => {
await user.type(input, '123')
expect(onChange).toHaveBeenCalled()
})
it('triggers numeric onChange for input widget with integer type', async () => {
const schema: FieldSchema = {
name: 'test_integer_input_widget_change',
type: 'integer',
label: 'Test Integer Input Widget Change',
description: 'A numeric field rendered as input',
required: false,
'x-widget': 'input',
default: 0,
}
const onChange = vi.fn()
const user = userEvent.setup()
render(<DynamicField schema={schema} value={0} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
await user.clear(input)
await user.type(input, '5')
expect(onChange).toHaveBeenLastCalledWith(5)
})
})
describe('visual features', () => {
@@ -377,6 +434,25 @@ describe('DynamicField', () => {
expect(screen.getByText('50')).toBeInTheDocument()
expect(screen.getByText('25')).toBeInTheDocument()
})
it('parses string values for slider widgets', () => {
const schema: FieldSchema = {
name: 'test_slider_string_value',
type: 'number',
label: 'Test Slider String Value',
description: 'A slider with legacy string value',
required: false,
'x-widget': 'slider',
minValue: 0,
maxValue: 10,
default: 0,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="2.5" onChange={onChange} />)
expect(screen.getByText('2.5')).toBeInTheDocument()
})
})
describe('select features', () => {

View File

@@ -552,8 +552,8 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
}
// 获取聊天名称
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
}
// 单条审核
@@ -1104,8 +1104,8 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
<div className="flex flex-wrap items-center gap-1 sm:gap-2 text-xs text-muted-foreground">
<span>#{expr.id}</span>
<span>·</span>
<span title={getChatName(expr.chat_id)} className="truncate max-w-24 sm:max-w-32">
{getChatName(expr.chat_id)}
<span title={getChatName(expr)} className="truncate max-w-24 sm:max-w-32">
{getChatName(expr)}
</span>
<span>·</span>
<span>{formatTime(expr.create_date)}</span>
@@ -1585,8 +1585,8 @@ if (isCurrent) {
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<User className="h-3 w-3" />
</div>
<span title={getChatName(expr.chat_id)} className="truncate max-w-[120px] font-medium">
{getChatName(expr.chat_id)}
<span title={getChatName(expr)} className="truncate max-w-[120px] font-medium">
{getChatName(expr)}
</span>
</div>
<span className="font-mono">{formatTime(expr.create_date)}</span>
@@ -1638,8 +1638,8 @@ if (isCurrent) {
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<User className="h-3 w-3" />
</div>
<span title={getChatName(expr.chat_id)} className="truncate max-w-[120px] font-medium">
{getChatName(expr.chat_id)}
<span title={getChatName(expr)} className="truncate max-w-[120px] font-medium">
{getChatName(expr)}
</span>
</div>
<span className="font-mono">{formatTime(expr.create_date)}</span>

View File

@@ -29,9 +29,9 @@
"modelManagement": "模型管理与分配",
"promptManagement": "Prompt 管理",
"adapterConfig": "麦麦适配器配置",
"emojiManagement": "表情包管理",
"expressionManagement": "表达方式管理",
"slangManagement": "黑话管理",
"emojiManagement": "表情包",
"expressionManagement": "表达方式",
"slangManagement": "黑话",
"personInfo": "人物信息管理",
"knowledgeGraph": "长期记忆图谱",
"knowledgeBase": "长期记忆",
@@ -779,13 +779,13 @@
"modelProviderDesc": "配置模型提供商",
"model": "麦麦模型配置",
"modelDesc": "配置模型参数",
"emoji": "表情包管理",
"emoji": "表情包",
"emojiDesc": "管理麦麦的表情包",
"expression": "表达方式管理",
"expression": "表达方式",
"expressionDesc": "管理麦麦的表达方式",
"person": "人物信息管理",
"personDesc": "管理人物信息",
"jargon": "黑话管理",
"jargon": "黑话",
"jargonDesc": "管理麦麦学习到的黑话和俚语",
"statistics": "统计信息",
"statisticsDesc": "查看使用统计",

View File

@@ -13,7 +13,7 @@ const API_BASE = '/api/webui/config'
* 获取麦麦主程序配置架构
*/
export async function getBotConfigSchema(): Promise<ApiResponse<ConfigSchema>> {
const response = await fetchWithAuth(`${API_BASE}/schema/bot`)
const response = await fetchWithAuth(`${API_BASE}/schema/bot`, { cache: 'no-store' })
return parseResponse<ConfigSchema>(response)
}
@@ -21,7 +21,7 @@ export async function getBotConfigSchema(): Promise<ApiResponse<ConfigSchema>> {
* 获取模型配置架构
*/
export async function getModelConfigSchema(): Promise<ApiResponse<ConfigSchema>> {
const response = await fetchWithAuth(`${API_BASE}/schema/model`)
const response = await fetchWithAuth(`${API_BASE}/schema/model`, { cache: 'no-store' })
return parseResponse<ConfigSchema>(response)
}
@@ -29,7 +29,7 @@ export async function getModelConfigSchema(): Promise<ApiResponse<ConfigSchema>>
* 获取指定配置节的架构
*/
export async function getConfigSectionSchema(sectionName: string): Promise<ApiResponse<ConfigSchema>> {
const response = await fetchWithAuth(`${API_BASE}/schema/section/${sectionName}`)
const response = await fetchWithAuth(`${API_BASE}/schema/section/${sectionName}`, { cache: 'no-store' })
return parseResponse<ConfigSchema>(response)
}
@@ -37,7 +37,7 @@ export async function getConfigSectionSchema(sectionName: string): Promise<ApiRe
* 获取麦麦主程序配置数据
*/
export async function getBotConfig(): Promise<ApiResponse<Record<string, unknown>>> {
const response = await fetchWithAuth(`${API_BASE}/bot`)
const response = await fetchWithAuth(`${API_BASE}/bot`, { cache: 'no-store' })
return parseResponse<Record<string, unknown>>(response)
}
@@ -45,7 +45,7 @@ export async function getBotConfig(): Promise<ApiResponse<Record<string, unknown
* 获取模型配置数据
*/
export async function getModelConfig(): Promise<ApiResponse<Record<string, unknown>>> {
const response = await fetchWithAuth(`${API_BASE}/model`)
const response = await fetchWithAuth(`${API_BASE}/model`, { cache: 'no-store' })
return parseResponse<Record<string, unknown>>(response)
}
@@ -66,7 +66,7 @@ export async function updateBotConfig(
* 获取麦麦主程序配置的原始 TOML 内容
*/
export async function getBotConfigRaw(): Promise<ApiResponse<string>> {
const response = await fetchWithAuth(`${API_BASE}/bot/raw`)
const response = await fetchWithAuth(`${API_BASE}/bot/raw`, { cache: 'no-store' })
return parseResponse<string>(response)
}

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">

View File

@@ -40,6 +40,8 @@ export interface FieldSchema {
'x-icon'?: string
'x-layout'?: 'inline-right'
'x-input-width'?: string
'x-textarea-min-height'?: number
'x-textarea-rows'?: number
advanced?: boolean
step?: number
}
@@ -52,7 +54,6 @@ export interface ConfigSchema {
uiParent?: string
uiLabel?: string
uiIcon?: string
uiMergeChildren?: string[]
}
export interface ConfigSchemaResponse {

View File

@@ -11,6 +11,7 @@ export interface Expression {
style: string
last_active_time: number
chat_id: string
chat_name?: string | null
create_date: number | null
checked: boolean
rejected: boolean

View File

@@ -4,6 +4,7 @@ from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Literal, Optional, Tuple
import json
import random
import time
from rich.console import Group, RenderableType
@@ -103,6 +104,24 @@ class BaseMaisakaReplyGenerator:
logger.warning(f"构建 Maisaka 人设提示词失败: {exc}")
return "你的名字是麦麦。\n是人类。"
@staticmethod
def _select_reply_style() -> str:
"""按配置概率选择本次 replyer 使用的表达风格。"""
personality_config = global_config.personality
reply_style = personality_config.reply_style
candidate_styles = [style.strip() for style in personality_config.multiple_reply_style if style.strip()]
if not candidate_styles:
return reply_style
probability = personality_config.multiple_probability
if probability <= 0:
return reply_style
if random.random() > probability:
return reply_style
return random.choice(candidate_styles)
@staticmethod
def _normalize_content(content: str, limit: int = 500) -> str:
normalized = " ".join((content or "").split())
@@ -293,7 +312,7 @@ class BaseMaisakaReplyGenerator:
group_chat_attention_block=self._build_group_chat_attention_block(session_id),
replyer_at_block=self._build_replyer_at_block(),
identity=self._personality_prompt,
reply_style=global_config.personality.reply_style,
reply_style=self._select_reply_style(),
)
except Exception:
system_prompt = "你是一个友好的 AI 助手,请根据聊天记录自然回复。"

View File

@@ -58,7 +58,7 @@ LEGACY_ENV_PATH: Path = (PROJECT_ROOT / ".env").resolve().absolute()
A_MEMORIX_LEGACY_CONFIG_PATH: Path = (CONFIG_DIR / "a_memorix.toml").resolve().absolute()
MMC_VERSION: str = "1.0.0-pre.10"
CONFIG_VERSION: str = "8.10.6"
MODEL_CONFIG_VERSION: str = "1.14.8"
MODEL_CONFIG_VERSION: str = "1.15.3"
logger = get_logger("config")

View File

@@ -135,7 +135,6 @@ class ConfigBase(BaseModel, AttrDocBase):
__ui_parent__: ClassVar[str] = "" # 父配置类在 Config 中的字段名,空表示独立 Tab
__ui_label__: ClassVar[str] = "" # Tab 显示名称(仅做 Tab 主人时使用),空则使用 classDoc
__ui_icon__: ClassVar[str] = "" # Tab 图标名称Lucide 图标名)
__ui_merge_children__: ClassVar[List[str]] = [] # 在 WebUI 中并入当前配置卡片展示的子配置字段名
@classmethod
def from_dict(cls, attribute_data: AttributeData, data: dict[str, Any]):

View File

@@ -402,6 +402,7 @@ class TaskConfig(ConfigBase):
"x-widget": "input",
"x-icon": "alert-circle",
"step": 0.1,
"advanced": True,
},
)
"""慢请求阈值(秒),超过此值会输出警告日志"""
@@ -420,15 +421,6 @@ class TaskConfig(ConfigBase):
class ModelTaskConfig(ConfigBase):
"""模型配置类"""
utils: TaskConfig = Field(
default_factory=TaskConfig,
json_schema_extra={
"x-widget": "custom",
"x-icon": "wrench",
},
)
"""组件使用的模型, 例如表情包模块, 取名模块, 关系模块, 麦麦的情绪变化等,是麦麦必须的模型"""
replyer: TaskConfig = Field(
default_factory=TaskConfig,
json_schema_extra={
@@ -436,17 +428,7 @@ class ModelTaskConfig(ConfigBase):
"x-icon": "message-square",
},
)
"""首要回复模型配置"""
learner: TaskConfig = Field(
default_factory=TaskConfig,
json_schema_extra={
"x-widget": "custom",
"x-icon": "graduation-cap",
"advanced": True,
},
)
"""学习模型配置,用于表达方式学习和黑话学习;留空时自动继用 utils 模型"""
"""回复模型配置"""
planner: TaskConfig = Field(
default_factory=TaskConfig,
@@ -457,6 +439,25 @@ class ModelTaskConfig(ConfigBase):
)
"""规划模型配置"""
utils: TaskConfig = Field(
default_factory=TaskConfig,
json_schema_extra={
"x-widget": "custom",
"x-icon": "wrench",
},
)
"""组件使用的模型, 例如表情包模块, 取名模块, 关系模块, 麦麦的情绪变化等,是麦麦必须的模型"""
learner: TaskConfig = Field(
default_factory=TaskConfig,
json_schema_extra={
"x-widget": "custom",
"x-icon": "graduation-cap",
"advanced": True,
},
)
"""学习模型配置,用于表达方式学习和黑话学习;留空时自动继用 utils 模型"""
vlm: TaskConfig = Field(
default_factory=TaskConfig,
json_schema_extra={
@@ -471,6 +472,7 @@ class ModelTaskConfig(ConfigBase):
json_schema_extra={
"x-widget": "custom",
"x-icon": "volume-2",
"advanced": True,
},
)
"""语音识别模型配置"""

View File

@@ -84,6 +84,8 @@ class PersonalityConfig(ConfigBase):
json_schema_extra={
"x-widget": "textarea",
"x-icon": "user-circle",
"x-textarea-min-height": 40,
"x-textarea-rows": 1,
},
)
"""人格建议200字以内描述人格特质和身份特征可以写完整设定。要求第二人称"""
@@ -93,6 +95,8 @@ class PersonalityConfig(ConfigBase):
json_schema_extra={
"x-widget": "textarea",
"x-icon": "message-square",
"x-textarea-min-height": 40,
"x-textarea-rows": 1,
},
)
"""默认表达风格描述麦麦说话的表达风格表达习惯如要修改可以酌情新增内容建议1-2行"""
@@ -321,7 +325,8 @@ class ChatConfig(ConfigBase):
class MessageReceiveConfig(ConfigBase):
"""消息接收配置类"""
__ui_parent__ = "response_post_process"
__ui_label__ = "消息接收"
__ui_icon__ = "message-square-text"
image_parse_threshold: int = Field(
default=5,
@@ -394,7 +399,7 @@ class TargetItem(ConfigBase):
class MemoryConfig(ConfigBase):
"""记忆配置类"""
__ui_parent__ = "emoji"
__ui_parent__ = "a_memorix"
global_memory: bool = Field(
@@ -1051,7 +1056,7 @@ class LearningItem(ConfigBase):
"x-icon": "message-square",
},
)
"""是否用表达学习"""
"""是否使用表达"""
enable_learning: bool = Field(
default=True,
@@ -1060,7 +1065,7 @@ class LearningItem(ConfigBase):
"x-icon": "graduation-cap",
},
)
"""是否启用表达优化学习"""
"""是否学习表达"""
enable_jargon_learning: bool = Field(
default=False,
@@ -1069,7 +1074,7 @@ class LearningItem(ConfigBase):
"x-icon": "book",
},
)
"""是否启用jargon学习"""
"""是否学习黑话"""
class ExpressionGroup(ConfigBase):
"""表达互通组配置类,若列表为空代表全局共享"""
@@ -1177,7 +1182,8 @@ class ExpressionConfig(ConfigBase):
class VoiceConfig(ConfigBase):
"""语音识别配置类"""
__ui_parent__ = "emoji"
__ui_label__ = "语音"
__ui_icon__ = "mic"
enable_asr: bool = Field(
default=False,
@@ -1192,8 +1198,8 @@ class VoiceConfig(ConfigBase):
class EmojiConfig(ConfigBase):
"""表情包配置类"""
__ui_label__ = "功能"
__ui_icon__ = "puzzle"
__ui_label__ = "表情包"
__ui_icon__ = "smile"
emoji_send_num: int = Field(
default=25,
@@ -1314,7 +1320,7 @@ class KeywordRuleConfig(ConfigBase):
class KeywordReactionConfig(ConfigBase):
"""关键词配置类"""
__ui_parent__ = "response_post_process"
__ui_parent__ = "message_receive"
keyword_rules: list[KeywordRuleConfig] = Field(
default_factory=lambda: [],
@@ -1345,9 +1351,8 @@ class KeywordReactionConfig(ConfigBase):
class ResponsePostProcessConfig(ConfigBase):
"""回复后处理配置类"""
__ui_label__ = "处理"
__ui_label__ = "处理"
__ui_icon__ = "settings"
__ui_merge_children__ = ["chinese_typo", "response_splitter"]
enable_response_post_process: bool = Field(
default=True,

View File

@@ -29,7 +29,7 @@ logger = get_logger("emoji")
install(extra_lines=3)
PROJECT_ROOT = Path(__file__).parent.parent.parent.parent.absolute().resolve()
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DATA_DIR = PROJECT_ROOT / "data"
EmojiRegisterStatus = Literal["registered", "skipped", "failed"]
EMOJI_DIR = DATA_DIR / "emoji" # 表情包存储目录
@@ -215,6 +215,17 @@ def _is_available_emoji_record(record: Images) -> bool:
return record_path.exists() and record_path.is_file()
def _resolve_existing_emoji_path(raw_path: str | Path | None) -> Optional[Path]:
"""将表情包路径归一化;路径为空或不存在时返回 ``None``。"""
if not raw_path:
return None
record_path = Path(raw_path).absolute().resolve()
if not record_path.exists() or not record_path.is_file():
return None
return record_path
def _is_vlm_task_configured() -> bool:
"""判断是否配置了可用于表情包识别和审核的视觉模型任务。"""
@@ -244,6 +255,7 @@ class EmojiManager:
self._emoji_num: int = 0
self.emojis: list[MaiEmoji] = []
self._known_emoji_file_paths: set[Path] = set()
self._maintenance_wakeup_event: asyncio.Event = asyncio.Event()
self._pending_description_tasks: dict[str, asyncio.Task[None]] = {}
self._reload_callback_registered: bool = False
@@ -254,9 +266,9 @@ class EmojiManager:
logger.info("启动表情包管理器")
def reload_runtime_config(self) -> None:
"""响应配置热重载,唤醒维护循环以尽快应用最新配置。"""
"""响应配置热重载,重置维护循环等待时间以应用最新配置。"""
self._maintenance_wakeup_event.set()
logger.info("[配置热重载] Emoji 模块配置已更新,将立即应用到维护循环")
logger.info("[配置热重载] Emoji 模块配置已更新,将按新的检查间隔等待后执行维护")
def shutdown(self) -> None:
"""清理 EmojiManager 生命周期资源。"""
@@ -948,33 +960,73 @@ class EmojiManager:
def check_emoji_file_integrity(self) -> None:
"""
检查表情包完整性,删除文件缺失的表情包记录
检查表情包文件和数据库注册记录的一致性。
数据库记录存在但文件缺失时删除数据库记录;文件存在但没有数据库记录时删除文件。
"""
logger.info("[完整性检查] 开始检查表情包文件完整性...")
to_delete_emojis: list[tuple[MaiEmoji, bool]] = []
removal_count = 0
for emoji in self.emojis:
if not emoji.full_path.exists():
logger.warning(f"[完整性检查] 表情包文件缺失,准备修改记录: {emoji.file_name}")
to_delete_emojis.append((emoji, False))
if not emoji.description:
logger.warning(f"[完整性检查] 表情包记录缺失描述,准备删除记录: {emoji.file_name}")
to_delete_emojis.append((emoji, True))
_ensure_directories()
logger.info("[完整性检查] 开始检查表情包文件和注册记录一致性...")
tracked_paths: set[Path] = set()
record_removal_count = 0
file_removal_count = 0
available_emojis: list[MaiEmoji] = []
for emoji, is_description_empty in to_delete_emojis:
if self.delete_emoji(emoji, is_description_empty):
self.emojis.remove(emoji)
self._emoji_num -= 1
removal_count += 1
logger.info(f"[完整性检查] 成功删除缺失文件的表情包记录: {emoji.file_name}")
else:
logger.error(f"[完整性检查] 删除缺失文件的表情包记录失败: {emoji.file_name}")
with get_db_session() as session:
statement = select(Images).filter_by(image_type=ImageType.EMOJI)
records = session.exec(statement).all()
for record in records:
record_path = _resolve_existing_emoji_path(record.full_path)
if record.no_file_flag or record_path is None:
logger.warning(
f"[完整性检查] 表情包数据库记录缺少实际文件,删除数据库记录: id={record.id}, path={record.full_path}"
)
session.delete(record)
record_removal_count += 1
continue
if not record.is_registered or record.is_banned:
tracked_paths.add(record_path)
continue
try:
available_emojis.append(MaiEmoji.from_db_instance(record))
tracked_paths.add(record_path)
except Exception as exc:
logger.error(
f"[完整性检查] 加载表情包记录时出错,将删除异常记录: {exc}\n记录ID: {record.id}, 路径: {record.full_path}"
)
session.delete(record)
record_removal_count += 1
logger.info(f"[完整性检查] 表情包文件完整性检查完成,删除了 {removal_count} 条记录")
for emoji_file in EMOJI_DIR.iterdir():
if not emoji_file.is_file():
continue
resolved_file = emoji_file.absolute().resolve()
if resolved_file in tracked_paths:
continue
try:
emoji_file.unlink()
file_removal_count += 1
logger.warning(f"[完整性检查] 表情包文件缺少数据库记录,删除文件: {emoji_file}")
except Exception as exc:
logger.error(f"[完整性检查] 删除无注册记录的表情包文件失败: {emoji_file}, error={exc}")
self.emojis = available_emojis
self._known_emoji_file_paths = tracked_paths
self._emoji_num = len(self.emojis)
logger.info(
f"[完整性检查] 表情包完整性检查完成,删除数据库记录 {record_removal_count} 条,删除图片文件 {file_removal_count}"
)
async def periodic_emoji_maintenance(self) -> None:
"""Run emoji maintenance tasks periodically."""
while True:
wait_seconds = max(global_config.emoji.check_interval * 60, 0)
try:
await asyncio.wait_for(self._maintenance_wakeup_event.wait(), timeout=wait_seconds)
self._maintenance_wakeup_event.clear()
continue
except asyncio.TimeoutError:
self._maintenance_wakeup_event.clear()
_ensure_directories()
try:
self.check_emoji_file_integrity()
@@ -989,24 +1041,21 @@ class EmojiManager:
for emoji_file in EMOJI_DIR.iterdir():
if not emoji_file.is_file():
continue
resolved_file = emoji_file.absolute().resolve()
if resolved_file in self._known_emoji_file_paths:
continue
try:
register_status = await self.register_emoji_by_filename(emoji_file)
except Exception as e:
logger.error(f"[emoji_maintenance] Failed to process {emoji_file.name}: {e}")
register_status = "failed"
if register_status == "registered":
self._known_emoji_file_paths.add(resolved_file)
break
if register_status == "skipped":
logger.debug(f"[emoji_maintenance] Emoji already registered, keep file: {emoji_file.name}")
else:
logger.debug(f"[emoji_maintenance] Emoji not registered, keep file: {emoji_file.name}")
wait_seconds = max(global_config.emoji.check_interval * 60, 0)
try:
await asyncio.wait_for(self._maintenance_wakeup_event.wait(), timeout=wait_seconds)
except asyncio.TimeoutError:
pass
finally:
self._maintenance_wakeup_event.clear()
async def register_emoji_by_filename(self, filename: Path | str) -> EmojiRegisterStatus:
"""Register an emoji file from ``data/emoji`` without moving or deleting it."""

View File

@@ -39,15 +39,12 @@ class ConfigSchemaGenerator:
ui_parent = getattr(config_class, "__ui_parent__", "")
ui_label = getattr(config_class, "__ui_label__", "")
ui_icon = getattr(config_class, "__ui_icon__", "")
ui_merge_children = getattr(config_class, "__ui_merge_children__", [])
if ui_parent:
schema["uiParent"] = ui_parent
if ui_label:
schema["uiLabel"] = ui_label
if ui_icon:
schema["uiIcon"] = ui_icon
if ui_merge_children:
schema["uiMergeChildren"] = list(ui_merge_children)
return schema

View File

@@ -10,7 +10,7 @@ from sqlmodel import col, delete, select
from src.chat.message_receive.chat_manager import chat_manager as _chat_manager
from src.common.database.database import get_db_session
from src.common.database.database_model import Expression
from src.common.database.database_model import ChatSession, Expression, Messages, ModifiedBy
from src.common.logger import get_logger
from src.webui.dependencies import require_auth
@@ -28,6 +28,7 @@ class ExpressionResponse(BaseModel):
style: str
last_active_time: float
chat_id: str
chat_name: Optional[str] = None
create_date: Optional[float]
checked: bool
rejected: bool
@@ -90,7 +91,61 @@ class ExpressionCreateResponse(BaseModel):
data: ExpressionResponse
def expression_to_response(expression: Expression) -> ExpressionResponse:
def get_chat_name_from_latest_message(chat_id: str, db_session: Any) -> Optional[str]:
"""从最近消息中解析聊天显示名称。"""
statement = (
select(Messages)
.where(col(Messages.session_id) == chat_id)
.order_by(col(Messages.timestamp).desc())
.limit(1)
)
message = db_session.exec(statement).first()
if not message:
return None
if message.group_id:
return message.group_name or f"群聊{message.group_id}"
return message.user_cardname or message.user_nickname or (f"用户{message.user_id}" if message.user_id else None)
def get_chat_name_from_session_record(chat_session: ChatSession) -> str:
"""从会话记录推断兜底显示名称。"""
if chat_session.group_id:
return f"群聊{chat_session.group_id}"
if chat_session.user_id:
return f"用户{chat_session.user_id}"
return chat_session.session_id
def get_chat_name(chat_id: str, db_session: Optional[Any] = None) -> str:
"""根据聊天 ID 获取聊天名称。
Args:
chat_id: 聊天会话 ID。
db_session: 可选数据库会话,用于从历史消息中解析群名或私聊用户名。
Returns:
str: 聊天显示名称,获取失败时返回原始聊天 ID。
"""
try:
if name := _chat_manager.get_session_name(chat_id):
return name
if db_session and (name := get_chat_name_from_latest_message(chat_id, db_session)):
return name
session = _chat_manager.get_session_by_session_id(chat_id)
if session:
if session.group_id:
return f"群聊{session.group_id}"
if session.user_id:
return f"用户{session.user_id}"
return chat_id
except Exception:
return chat_id
def expression_to_response(expression: Expression, db_session: Optional[Any] = None) -> ExpressionResponse:
"""将表达方式模型转换为响应对象。
Args:
@@ -101,38 +156,21 @@ def expression_to_response(expression: Expression) -> ExpressionResponse:
"""
last_active_time = expression.last_active_time.timestamp() if expression.last_active_time else 0.0
create_date = expression.create_time.timestamp() if expression.create_time else None
chat_id = expression.session_id or ""
return ExpressionResponse(
id=expression.id if expression.id is not None else 0,
situation=expression.situation,
style=expression.style,
last_active_time=last_active_time,
chat_id=expression.session_id or "",
chat_id=chat_id,
chat_name=get_chat_name(chat_id, db_session) if chat_id else None,
create_date=create_date,
checked=False,
rejected=False,
modified_by=None,
checked=expression.checked,
rejected=expression.rejected,
modified_by=expression.modified_by.value if expression.modified_by else None,
)
def get_chat_name(chat_id: str) -> str:
"""根据聊天 ID 获取聊天名称。
Args:
chat_id: 聊天会话 ID。
Returns:
str: 聊天显示名称,获取失败时返回原始聊天 ID。
"""
try:
session = _chat_manager.get_session_by_session_id(chat_id)
if not session:
return chat_id
name = _chat_manager.get_session_name(chat_id)
return name or chat_id
except Exception:
return chat_id
def get_chat_names_batch(chat_ids: List[str]) -> Dict[str, str]:
"""批量获取聊天名称。
@@ -145,8 +183,7 @@ def get_chat_names_batch(chat_ids: List[str]) -> Dict[str, str]:
result = {cid: cid for cid in chat_ids} # 默认值为原始ID
try:
for chat_id in chat_ids:
if name := _chat_manager.get_session_name(chat_id):
result[chat_id] = name
result[chat_id] = get_chat_name(chat_id)
except Exception as e:
logger.warning(f"批量获取聊天名称失败: {e}")
return result
@@ -176,19 +213,43 @@ async def get_chat_list() -> ChatListResponse:
ChatListResponse: 可用于下拉选择的聊天列表。
"""
try:
chat_list = []
chat_by_id: Dict[str, ChatInfo] = {}
for session_id, session in _chat_manager.sessions.items():
chat_name = _chat_manager.get_session_name(session_id) or session_id
chat_list.append(
ChatInfo(
chat_id=session_id,
chat_name=chat_name,
platform=session.platform,
is_group=session.is_group_session,
)
chat_by_id[session_id] = ChatInfo(
chat_id=session_id,
chat_name=chat_name,
platform=session.platform,
is_group=session.is_group_session,
)
with get_db_session() as session:
for chat_session in session.exec(select(ChatSession)).all():
if chat_session.session_id in chat_by_id:
continue
chat_name = get_chat_name_from_latest_message(chat_session.session_id, session)
chat_by_id[chat_session.session_id] = ChatInfo(
chat_id=chat_session.session_id,
chat_name=chat_name or get_chat_name_from_session_record(chat_session),
platform=chat_session.platform,
is_group=bool(chat_session.group_id),
)
expression_chat_ids = {
chat_id for chat_id in session.exec(select(Expression.session_id)).all() if chat_id
}
for session_id in expression_chat_ids:
if session_id in chat_by_id:
continue
chat_by_id[session_id] = ChatInfo(
chat_id=session_id,
chat_name=get_chat_name(session_id, session),
platform=None,
is_group=False,
)
# 按名称排序
chat_list = list(chat_by_id.values())
chat_list.sort(key=lambda x: x.chat_name)
return ChatListResponse(success=True, data=chat_list)
@@ -252,7 +313,7 @@ async def get_expression_list(
if chat_id:
count_statement = count_statement.where(col(Expression.session_id) == chat_id)
total = len(session.exec(count_statement).all())
data = [expression_to_response(expr) for expr in expressions]
data = [expression_to_response(expr, session) for expr in expressions]
return ExpressionListResponse(success=True, total=total, page=page, page_size=page_size, data=data)
@@ -281,7 +342,7 @@ async def get_expression_detail(expression_id: int) -> ExpressionDetailResponse:
if not expression:
raise HTTPException(status_code=404, detail=f"未找到 ID 为 {expression_id} 的表达方式")
data = expression_to_response(expression)
data = expression_to_response(expression, session)
return ExpressionDetailResponse(success=True, data=data)
@@ -321,7 +382,7 @@ async def create_expression(
session.add(expression)
session.flush()
expression_id = expression.id
data = expression_to_response(expression)
data = expression_to_response(expression, session)
logger.info(f"表达方式已创建: ID={expression_id}, situation={request.situation}")
@@ -375,7 +436,7 @@ async def update_expression(
db_expression.session_id = update_data["session_id"]
db_expression.last_active_time = update_data["last_active_time"]
session.add(db_expression)
data = expression_to_response(db_expression)
data = expression_to_response(db_expression, session)
logger.info(f"表达方式已更新: ID={expression_id}, 字段: {list(update_data.keys())}")
@@ -524,6 +585,22 @@ class ReviewStatsResponse(BaseModel):
user_checked: int
def apply_review_filter(statement: Any, filter_type: str) -> Any:
"""按审核状态过滤表达方式查询。"""
if filter_type == "unchecked":
return statement.where(col(Expression.checked).is_(False))
if filter_type == "passed":
return statement.where(col(Expression.checked).is_(True), col(Expression.rejected).is_(False))
if filter_type == "rejected":
return statement.where(col(Expression.checked).is_(True), col(Expression.rejected).is_(True))
return statement
def count_expressions(session: Any, statement: Any) -> int:
"""统计表达方式查询结果数量。"""
return len(session.exec(statement).all())
@router.get("/review/stats", response_model=ReviewStatsResponse)
async def get_review_stats() -> ReviewStatsResponse:
"""获取审核统计数据。
@@ -533,12 +610,24 @@ async def get_review_stats() -> ReviewStatsResponse:
"""
try:
with get_db_session() as session:
total = len(session.exec(select(Expression.id)).all())
unchecked = 0
passed = 0
rejected = 0
ai_checked = 0
user_checked = 0
total = count_expressions(session, select(Expression.id))
unchecked = count_expressions(session, apply_review_filter(select(Expression.id), "unchecked"))
passed = count_expressions(session, apply_review_filter(select(Expression.id), "passed"))
rejected = count_expressions(session, apply_review_filter(select(Expression.id), "rejected"))
ai_checked = count_expressions(
session,
select(Expression.id).where(
col(Expression.checked).is_(True),
col(Expression.modified_by) == ModifiedBy.AI,
),
)
user_checked = count_expressions(
session,
select(Expression.id).where(
col(Expression.checked).is_(True),
col(Expression.modified_by) == ModifiedBy.USER,
),
)
return ReviewStatsResponse(
total=total,
@@ -587,10 +676,7 @@ async def get_review_list(
ReviewListResponse: 审核列表响应。
"""
try:
statement = select(Expression)
if filter_type in {"unchecked", "passed", "rejected"}:
statement = statement.where(col(Expression.id) == -1)
statement = apply_review_filter(select(Expression), filter_type)
# all 不需要额外过滤
# 搜索过滤
@@ -615,9 +701,7 @@ async def get_review_list(
with get_db_session() as session:
expressions = session.exec(statement).all()
count_statement = select(Expression.id)
if filter_type in {"unchecked", "passed", "rejected"}:
count_statement = count_statement.where(col(Expression.id) == -1)
count_statement = apply_review_filter(select(Expression.id), filter_type)
if search:
count_statement = count_statement.where(
(col(Expression.situation).contains(search)) | (col(Expression.style).contains(search))
@@ -625,7 +709,7 @@ async def get_review_list(
if chat_id:
count_statement = count_statement.where(col(Expression.session_id) == chat_id)
total = len(session.exec(count_statement).all())
data = [expression_to_response(expr) for expr in expressions]
data = [expression_to_response(expr, session) for expr in expressions]
return ReviewListResponse(
success=True,
@@ -706,10 +790,10 @@ async def batch_review_expressions(
failed += 1
continue
# 冲突检测
if item.require_unchecked:
# 冲突检测:未审核列表发起的操作只允许处理仍处于未审核状态的条目。
if item.require_unchecked and expression.checked:
results.append(
BatchReviewResultItem(id=item.id, success=False, message="当前模型不支持审核状态过滤")
BatchReviewResultItem(id=item.id, success=False, message="该表达方式已被审核,请刷新列表后重试")
)
failed += 1
continue
@@ -727,6 +811,9 @@ async def batch_review_expressions(
)
failed += 1
continue
db_expression.checked = True
db_expression.rejected = item.rejected
db_expression.modified_by = ModifiedBy.USER
db_expression.last_active_time = datetime.now()
session.add(db_expression)

26
uv.lock generated
View File

@@ -1051,6 +1051,11 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[package.optional-dependencies]
socks = [
{ name = "socksio" },
]
[[package]]
name = "httpx-sse"
version = "0.4.3"
@@ -1452,7 +1457,7 @@ dependencies = [
{ name = "faiss-cpu" },
{ name = "fastapi" },
{ name = "google-genai" },
{ name = "httpx" },
{ name = "httpx", extra = ["socks"] },
{ name = "jieba" },
{ name = "json-repair" },
{ name = "maibot-dashboard" },
@@ -1503,10 +1508,10 @@ requires-dist = [
{ name = "faiss-cpu", specifier = ">=1.11.0" },
{ name = "fastapi", specifier = ">=0.116.0" },
{ name = "google-genai", specifier = ">=1.39.1" },
{ name = "httpx" },
{ name = "httpx", extras = ["socks"] },
{ name = "jieba", specifier = ">=0.42.1" },
{ name = "json-repair", specifier = ">=0.47.6" },
{ name = "maibot-dashboard", specifier = "==1.0.1.dev2026050251" },
{ name = "maibot-dashboard", specifier = ">=1.0.4" },
{ name = "maibot-plugin-sdk", specifier = ">=2.4.0" },
{ name = "maim-message", specifier = ">=0.6.2" },
{ name = "matplotlib", specifier = ">=3.10.5" },
@@ -1544,11 +1549,11 @@ dev = [
[[package]]
name = "maibot-dashboard"
version = "1.0.1.dev2026050251"
version = "1.0.4"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/31/bbe39c5e9b1603350a4962fe9bf6dab634be4bea5104b3f2a747ddbaf1e9/maibot_dashboard-1.0.1.dev2026050251.tar.gz", hash = "sha256:2f7c822c9c31ff52c162c09f4d313ad39581fafbf872a4109ed5031db2ce5330", size = 2480032, upload-time = "2026-05-02T03:43:58.249Z" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/0b/970a2c0b2cda503fd829aeca7d590a319f0556e9d9de8025cddfff746343/maibot_dashboard-1.0.4.tar.gz", hash = "sha256:c15b50017a923f8575b2d1991d8af96fd682d75bb4084eb21d6a03956b9168b6", size = 2470802, upload-time = "2026-05-04T10:22:01.146Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/66/377d20ae79afb787df9048fc1c9f11fbba2e8a022f0a82a094571a62aa47/maibot_dashboard-1.0.1.dev2026050251-py3-none-any.whl", hash = "sha256:86475fdb44ca618fa53c6feecc99aad045f9d4b57c6f5f14d3b3d00584b7d1ef", size = 2542575, upload-time = "2026-05-02T03:43:56.454Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/33/99/7d822f14c9ae6f37e0536df239daa52b5e3ea7997d96471c1c8f2dccf248/maibot_dashboard-1.0.4-py3-none-any.whl", hash = "sha256:4b079f87ea537714914f65f53e596fbe166eb527fe27edb42b9dbcfcd115b4a8", size = 2537189, upload-time = "2026-05-04T10:21:59.922Z" },
]
[[package]]
@@ -3281,6 +3286,15 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "socksio"
version = "1.0.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.49"