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", "name": "maibot-dashboard",
"version": "1.0.3", "version": "1.0.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "maibot-dashboard", "name": "maibot-dashboard",
"version": "1.0.3", "version": "1.0.5",
"dependencies": { "dependencies": {
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-javascript": "^6.2.4",

View File

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

View File

@@ -76,7 +76,6 @@ function DynamicConfigSection({
basePath, basePath,
hooks, hooks,
level, level,
mergedChildren = [],
nestedSchema, nestedSchema,
onChange, onChange,
sectionDescription, sectionDescription,
@@ -87,11 +86,6 @@ function DynamicConfigSection({
basePath: string basePath: string
hooks: FieldHookRegistry hooks: FieldHookRegistry
level: number level: number
mergedChildren?: Array<{
key: string
schema: ConfigSchema
values: Record<string, unknown>
}>
nestedSchema: ConfigSchema nestedSchema: ConfigSchema
onChange: (field: string, value: unknown) => void onChange: (field: string, value: unknown) => void
sectionDescription?: string sectionDescription?: string
@@ -100,9 +94,7 @@ function DynamicConfigSection({
values: Record<string, unknown> values: Record<string, unknown>
}) { }) {
const [advancedVisible, setAdvancedVisible] = React.useState(false) const [advancedVisible, setAdvancedVisible] = React.useState(false)
const hasAdvanced = const hasAdvanced = hasTopLevelAdvancedFields(nestedSchema)
hasTopLevelAdvancedFields(nestedSchema) ||
mergedChildren.some((child) => hasTopLevelAdvancedFields(child.schema))
return ( return (
<Card> <Card>
@@ -135,37 +127,6 @@ function DynamicConfigSection({
level={level} level={level}
advancedVisible={hasAdvanced ? advancedVisible : undefined} 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> </CardContent>
</Card> </Card>
) )
@@ -197,17 +158,6 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
() => new Map(schema.fields.map((field) => [field.name, field])), () => new Map(schema.fields.map((field) => [field.name, field])),
[schema.fields], [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 renderField = (field: FieldSchema) => {
const fieldPath = buildFieldPath(basePath, field.name) const fieldPath = buildFieldPath(basePath, field.name)
@@ -294,7 +244,6 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
{schema.nested && {schema.nested &&
Object.entries(schema.nested) Object.entries(schema.nested)
.filter(([key]) => !mergedChildKeys.has(key))
.map(([key, nestedSchema]) => { .map(([key, nestedSchema]) => {
const nestedField = fieldMap.get(key) const nestedField = fieldMap.get(key)
const nestedFieldPath = buildFieldPath(basePath, key) const nestedFieldPath = buildFieldPath(basePath, key)
@@ -342,34 +291,11 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
const sectionTitle = resolveSectionTitle(nestedSchema) const sectionTitle = resolveSectionTitle(nestedSchema)
const sectionDescription = resolveSectionDescription(nestedSchema, sectionTitle) 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) { if (level === 0) {
return ( return (
<DynamicConfigSection <DynamicConfigSection
key={key} key={key}
mergedChildren={mergedChildren}
nestedSchema={nestedSchema} nestedSchema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}} values={(values[key] as Record<string, unknown>) || {}}
onChange={onChange} onChange={onChange}

View File

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

View File

@@ -100,6 +100,42 @@ describe('DynamicField', () => {
expect(screen.getByText('Custom field requires Hook')).toBeInTheDocument() 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', () => { describe('type fallback', () => {
@@ -305,6 +341,27 @@ describe('DynamicField', () => {
await user.type(input, '123') await user.type(input, '123')
expect(onChange).toHaveBeenCalled() 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', () => { describe('visual features', () => {
@@ -377,6 +434,25 @@ describe('DynamicField', () => {
expect(screen.getByText('50')).toBeInTheDocument() expect(screen.getByText('50')).toBeInTheDocument()
expect(screen.getByText('25')).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', () => { describe('select features', () => {

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ const API_BASE = '/api/webui/config'
* 获取麦麦主程序配置架构 * 获取麦麦主程序配置架构
*/ */
export async function getBotConfigSchema(): Promise<ApiResponse<ConfigSchema>> { 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) return parseResponse<ConfigSchema>(response)
} }
@@ -21,7 +21,7 @@ export async function getBotConfigSchema(): Promise<ApiResponse<ConfigSchema>> {
* 获取模型配置架构 * 获取模型配置架构
*/ */
export async function getModelConfigSchema(): 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) return parseResponse<ConfigSchema>(response)
} }
@@ -29,7 +29,7 @@ export async function getModelConfigSchema(): Promise<ApiResponse<ConfigSchema>>
* 获取指定配置节的架构 * 获取指定配置节的架构
*/ */
export async function getConfigSectionSchema(sectionName: string): 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) 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>>> { 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) 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>>> { 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) return parseResponse<Record<string, unknown>>(response)
} }
@@ -66,7 +66,7 @@ export async function updateBotConfig(
* 获取麦麦主程序配置的原始 TOML 内容 * 获取麦麦主程序配置的原始 TOML 内容
*/ */
export async function getBotConfigRaw(): Promise<ApiResponse<string>> { 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) return parseResponse<string>(response)
} }

View File

@@ -24,10 +24,11 @@ import { getBotConfig, getBotConfigRaw, getBotConfigSchema, updateBotConfig, upd
import { fieldHooks } from '@/lib/field-hooks' import { fieldHooks } from '@/lib/field-hooks'
import { RestartProvider, useRestart } from '@/lib/restart-context' 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 type { ConfigSchema } from '@/types/config-schema'
import { import {
ChatPromptsHook,
ChatTalkValueRulesHook, ChatTalkValueRulesHook,
ExpressionGroupsHook, ExpressionGroupsHook,
ExpressionLearningListHook, ExpressionLearningListHook,
@@ -50,13 +51,27 @@ const TAB_ORDER = [
'personality', 'personality',
'chat', 'chat',
'expression', 'expression',
'visual',
'a_memorix',
'message_receive',
'emoji', 'emoji',
'voice',
'response_post_process', 'response_post_process',
'webui', 'webui',
'plugin_runtime', 'plugin_runtime',
'log', 'log',
] ]
/** 默认展示的主配置栏目 */
const DEFAULT_VISIBLE_TAB_IDS = new Set([
'bot',
'personality',
'chat',
'expression',
'visual',
'a_memorix',
])
// ==================== Tab 分组类型与构建 ==================== // ==================== Tab 分组类型与构建 ====================
interface TabGroup { interface TabGroup {
id: string id: string
@@ -143,6 +158,9 @@ function BotConfigPageContent() {
const [sourceCode, setSourceCode] = useState<string>('') const [sourceCode, setSourceCode] = useState<string>('')
const [hasTomlError, setHasTomlError] = useState(false) const [hasTomlError, setHasTomlError] = useState(false)
const [tomlErrorMessage, setTomlErrorMessage] = useState<string>('') const [tomlErrorMessage, setTomlErrorMessage] = useState<string>('')
const [restartNoticeVisible, setRestartNoticeVisible] = useState(
() => localStorage.getItem('bot-config-restart-notice-dismissed') !== 'true'
)
const { toast } = useToast() const { toast } = useToast()
const { triggerRestart, isRestarting } = useRestart() const { triggerRestart, isRestarting } = useRestart()
@@ -160,6 +178,7 @@ function BotConfigPageContent() {
const [responsePostProcessConfig, setResponsePostProcessConfig] = useState<ConfigSectionData | null>(null) const [responsePostProcessConfig, setResponsePostProcessConfig] = useState<ConfigSectionData | null>(null)
const [chineseTypoConfig, setChineseTypoConfig] = useState<ConfigSectionData | null>(null) const [chineseTypoConfig, setChineseTypoConfig] = useState<ConfigSectionData | null>(null)
const [responseSplitterConfig, setResponseSplitterConfig] = 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 [debugConfig, setDebugConfig] = useState<ConfigSectionData | null>(null)
const [maimMessageConfig, setMaimMessageConfig] = useState<ConfigSectionData | null>(null) const [maimMessageConfig, setMaimMessageConfig] = useState<ConfigSectionData | null>(null)
const [telemetryConfig, setTelemetryConfig] = useState<ConfigSectionData | null>(null) const [telemetryConfig, setTelemetryConfig] = useState<ConfigSectionData | null>(null)
@@ -174,6 +193,7 @@ function BotConfigPageContent() {
// 用于标记初始加载和配置缓存 // 用于标记初始加载和配置缓存
const initialLoadRef = useRef(true) const initialLoadRef = useRef(true)
const suppressAutoSaveRef = useRef(false)
const configRef = useRef<Record<string, unknown>>({}) const configRef = useRef<Record<string, unknown>>({})
// ==================== 辅助函数 ==================== // ==================== 辅助函数 ====================
@@ -240,6 +260,7 @@ function BotConfigPageContent() {
* 抽取自 loadConfig 和 handleModeChange 中的重复逻辑 * 抽取自 loadConfig 和 handleModeChange 中的重复逻辑
*/ */
const parseAndSetConfig = useCallback((config: Record<string, unknown>) => { const parseAndSetConfig = useCallback((config: Record<string, unknown>) => {
suppressAutoSaveRef.current = true
configRef.current = config configRef.current = config
setBotConfig((config.bot ?? {}) as ConfigSectionData) setBotConfig((config.bot ?? {}) as ConfigSectionData)
@@ -255,6 +276,7 @@ function BotConfigPageContent() {
setResponsePostProcessConfig((config.response_post_process ?? {}) as ConfigSectionData) setResponsePostProcessConfig((config.response_post_process ?? {}) as ConfigSectionData)
setChineseTypoConfig((config.chinese_typo ?? {}) as ConfigSectionData) setChineseTypoConfig((config.chinese_typo ?? {}) as ConfigSectionData)
setResponseSplitterConfig((config.response_splitter ?? {}) as ConfigSectionData) setResponseSplitterConfig((config.response_splitter ?? {}) as ConfigSectionData)
setLogConfig((config.log ?? {}) as ConfigSectionData)
setDebugConfig((config.debug ?? {}) as ConfigSectionData) setDebugConfig((config.debug ?? {}) as ConfigSectionData)
setMaimMessageConfig((config.maim_message ?? {}) as ConfigSectionData) setMaimMessageConfig((config.maim_message ?? {}) as ConfigSectionData)
setTelemetryConfig((config.telemetry ?? {}) as ConfigSectionData) setTelemetryConfig((config.telemetry ?? {}) as ConfigSectionData)
@@ -263,6 +285,10 @@ function BotConfigPageContent() {
setMcpConfig((config.mcp ?? {}) as ConfigSectionData) setMcpConfig((config.mcp ?? {}) as ConfigSectionData)
setPluginRuntimeConfig((config.plugin_runtime ?? {}) as ConfigSectionData) setPluginRuntimeConfig((config.plugin_runtime ?? {}) as ConfigSectionData)
setAMemorixConfig((config.a_memorix ?? {}) as ConfigSectionData) setAMemorixConfig((config.a_memorix ?? {}) as ConfigSectionData)
window.setTimeout(() => {
suppressAutoSaveRef.current = false
}, 0)
}, []) }, [])
/** /**
@@ -285,6 +311,7 @@ function BotConfigPageContent() {
response_post_process: responsePostProcessConfig, response_post_process: responsePostProcessConfig,
chinese_typo: chineseTypoConfig, chinese_typo: chineseTypoConfig,
response_splitter: responseSplitterConfig, response_splitter: responseSplitterConfig,
log: logConfig,
debug: debugConfig, debug: debugConfig,
maim_message: maimMessageConfig, maim_message: maimMessageConfig,
telemetry: telemetryConfig, telemetry: telemetryConfig,
@@ -308,6 +335,7 @@ function BotConfigPageContent() {
responsePostProcessConfig, responsePostProcessConfig,
chineseTypoConfig, chineseTypoConfig,
responseSplitterConfig, responseSplitterConfig,
logConfig,
debugConfig, debugConfig,
maimMessageConfig, maimMessageConfig,
telemetryConfig, telemetryConfig,
@@ -394,6 +422,7 @@ function BotConfigPageContent() {
useEffect(() => { useEffect(() => {
const hookEntries = [ const hookEntries = [
['chat.chat_prompts', ChatPromptsHook],
['chat.talk_value_rules', ChatTalkValueRulesHook], ['chat.talk_value_rules', ChatTalkValueRulesHook],
['expression.expression_groups', ExpressionGroupsHook], ['expression.expression_groups', ExpressionGroupsHook],
['expression.learning_list', ExpressionLearningListHook], ['expression.learning_list', ExpressionLearningListHook],
@@ -421,30 +450,41 @@ function BotConfigPageContent() {
setHasUnsavedChanges setHasUnsavedChanges
) )
const triggerConfigAutoSave = useCallback(
(sectionName: Parameters<typeof triggerAutoSave>[0], data: unknown) => {
if (suppressAutoSaveRef.current) {
return
}
triggerAutoSave(sectionName, data)
},
[triggerAutoSave]
)
// 使用 useConfigAutoSave hook 简化配置变化监听 // 使用 useConfigAutoSave hook 简化配置变化监听
// 注意: useConfigAutoSave 是一个 hook不能在条件语句或循环中调用 // 注意: useConfigAutoSave 是一个 hook不能在条件语句或循环中调用
// 因此我们仍然需要逐个调用,但代码更简洁 // 因此我们仍然需要逐个调用,但代码更简洁
useConfigAutoSave(botConfig, 'bot', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(botConfig, 'bot', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(personalityConfig, 'personality', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(personalityConfig, 'personality', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(chatConfig, 'chat', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(chatConfig, 'chat', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(expressionConfig, 'expression', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(expressionConfig, 'expression', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(emojiConfig, 'emoji', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(emojiConfig, 'emoji', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(memoryConfig, 'memory', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(memoryConfig, 'memory', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(visualConfig, 'visual', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(visualConfig, 'visual', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(voiceConfig, 'voice', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(voiceConfig, 'voice', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(messageReceiveConfig, 'message_receive', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(messageReceiveConfig, 'message_receive', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(keywordReactionConfig, 'keyword_reaction', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(keywordReactionConfig, 'keyword_reaction', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(responsePostProcessConfig, 'response_post_process', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(responsePostProcessConfig, 'response_post_process', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(chineseTypoConfig, 'chinese_typo', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(chineseTypoConfig, 'chinese_typo', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(responseSplitterConfig, 'response_splitter', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(responseSplitterConfig, 'response_splitter', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(debugConfig, 'debug', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(logConfig, 'log', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(maimMessageConfig, 'maim_message', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(debugConfig, 'debug', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(telemetryConfig, 'telemetry', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(maimMessageConfig, 'maim_message', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(webuiConfig, 'webui', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(telemetryConfig, 'telemetry', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(databaseConfig, 'database', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(webuiConfig, 'webui', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(mcpConfig, 'mcp', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(databaseConfig, 'database', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(pluginRuntimeConfig, 'plugin_runtime', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(mcpConfig, 'mcp', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(aMemorixConfig, 'a_memorix', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(pluginRuntimeConfig, 'plugin_runtime', initialLoadRef.current, triggerConfigAutoSave)
useConfigAutoSave(aMemorixConfig, 'a_memorix', initialLoadRef.current, triggerConfigAutoSave)
// 保存源代码 // 保存源代码
const saveSourceCode = async () => { const saveSourceCode = async () => {
@@ -592,6 +632,21 @@ function BotConfigPageContent() {
await triggerRestart() 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 () => { const handleSaveAndRestart = async () => {
try { try {
@@ -650,6 +705,7 @@ function BotConfigPageContent() {
response_post_process: responsePostProcessConfig, response_post_process: responsePostProcessConfig,
chinese_typo: chineseTypoConfig, chinese_typo: chineseTypoConfig,
response_splitter: responseSplitterConfig, response_splitter: responseSplitterConfig,
log: logConfig,
debug: debugConfig, debug: debugConfig,
maim_message: maimMessageConfig, maim_message: maimMessageConfig,
telemetry: telemetryConfig, telemetry: telemetryConfig,
@@ -673,6 +729,7 @@ function BotConfigPageContent() {
responsePostProcessConfig, responsePostProcessConfig,
chineseTypoConfig, chineseTypoConfig,
responseSplitterConfig, responseSplitterConfig,
logConfig,
debugConfig, debugConfig,
maimMessageConfig, maimMessageConfig,
telemetryConfig, telemetryConfig,
@@ -699,6 +756,7 @@ function BotConfigPageContent() {
response_post_process: setResponsePostProcessConfig, response_post_process: setResponsePostProcessConfig,
chinese_typo: setChineseTypoConfig, chinese_typo: setChineseTypoConfig,
response_splitter: setResponseSplitterConfig, response_splitter: setResponseSplitterConfig,
log: setLogConfig,
debug: setDebugConfig, debug: setDebugConfig,
maim_message: setMaimMessageConfig, maim_message: setMaimMessageConfig,
telemetry: setTelemetryConfig, telemetry: setTelemetryConfig,
@@ -736,6 +794,16 @@ function BotConfigPageContent() {
</div> </div>
{/* 按钮组 - 桌面端靠右 */} {/* 按钮组 - 桌面端靠右 */}
<div className="flex gap-2 flex-shrink-0"> <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 <Button
onClick={editMode === 'visual' ? saveConfig : saveSourceCode} onClick={editMode === 'visual' ? saveConfig : saveSourceCode}
disabled={saving || autoSaving || !hasUnsavedChanges || isRestarting} disabled={saving || autoSaving || !hasUnsavedChanges || isRestarting}
@@ -804,12 +872,19 @@ function BotConfigPageContent() {
</div> </div>
{/* 重启提示 */} {/* 重启提示 */}
{restartNoticeVisible && (
<Alert> <Alert>
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
<AlertDescription> <AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span>
<strong></strong>"保存并重启" <strong></strong>"保存并重启"
</span>
<Button type="button" variant="outline" size="sm" onClick={dismissRestartNotice}>
</Button>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)}
{/* 源代码模式 */} {/* 源代码模式 */}
{editMode === 'source' && ( {editMode === 'source' && (
@@ -903,11 +978,34 @@ interface DynamicConfigTabsProps {
function DynamicConfigTabs(props: DynamicConfigTabsProps) { function DynamicConfigTabs(props: DynamicConfigTabsProps) {
const { configSchema, sectionValues, setHasUnsavedChanges, setSectionValue, tabGroups } = props 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) { if (tabGroups.length === 0 || !configSchema?.nested) {
return null 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 renderTabContent = (tab: TabGroup) => {
const tabNestedEntries = tab.sections const tabNestedEntries = tab.sections
.map((sectionName) => [sectionName, configSchema.nested?.[sectionName]] as const) .map((sectionName) => [sectionName, configSchema.nested?.[sectionName]] as const)
@@ -953,9 +1051,9 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
} }
return ( 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"> <TabsList className="flex flex-wrap h-auto gap-1 p-1">
{tabGroups.map((tab) => ( {visibleTabGroups.map((tab) => (
<TabsTrigger <TabsTrigger
key={tab.id} key={tab.id}
value={tab.id} value={tab.id}
@@ -964,6 +1062,22 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
{tab.label} {tab.label}
</TabsTrigger> </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> </TabsList>
{tabGroups.map((tab) => ( {tabGroups.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="space-y-4"> <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 * as LucideIcons from 'lucide-react'
import { Plus, Trash2 } from 'lucide-react' import { Plus, Trash2 } from 'lucide-react'
@@ -31,6 +31,12 @@ export interface ListItemEditorOptions {
emptyText?: string emptyText?: string
/** 顶部图标(覆盖 schema 自带的 x-icon */ /** 顶部图标(覆盖 schema 自带的 x-icon */
iconName?: string 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 { function resolveLabel(schema?: ConfigSchema | FieldSchema, fieldPath?: string): string {
@@ -190,9 +196,105 @@ export function createListItemEditorHook(
[items, onChange], [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 label = resolveLabel(schema, fieldPath)
const description = resolveDescription(schema) const description = resolveDescription(schema)
const iconName = resolveIconName(options.iconName, schema, nestedSchema) 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) { if (!nestedSchema) {
return ( return (
@@ -220,6 +322,7 @@ export function createListItemEditorHook(
)} )}
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{addButtonPlacement === 'top' && addButton}
{items.length === 0 ? ( {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"> <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 ?? '尚未添加任何条目,点击下方按钮新增。'} {options.emptyText ?? '尚未添加任何条目,点击下方按钮新增。'}
@@ -251,29 +354,12 @@ export function createListItemEditorHook(
</Button> </Button>
</div> </div>
<DynamicConfigForm {renderItemEditor(item, index)}
schema={nestedSchema}
values={item}
onChange={(field, fieldValue) =>
handleItemFieldChange(index, field, fieldValue)
}
basePath=""
level={1}
/>
</div> </div>
) )
}) })
)} )}
<Button {addButtonPlacement === 'bottom' && addButton}
type="button"
variant="outline"
size="sm"
onClick={handleAdd}
className="w-full"
>
<Plus className="mr-1 h-4 w-4" />
{options.addLabel ?? '添加一项'}
</Button>
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@@ -30,8 +30,13 @@ const collectStringList = (value: unknown): string[] => {
export const ChatTalkValueRulesHook = createListItemEditorHook({ export const ChatTalkValueRulesHook = createListItemEditorHook({
addLabel: '添加发言频率规则', addLabel: '添加发言频率规则',
addButtonPlacement: 'top',
helperText: '可按平台/聊天流/时段分别配置发言频率,留空表示全局。', helperText: '可按平台/聊天流/时段分别配置发言频率,留空表示全局。',
emptyText: '尚未配置任何规则,将使用全局默认频率。', emptyText: '尚未配置任何规则,将使用全局默认频率。',
fieldRows: [
['platform', 'item_id', 'rule_type'],
['time', 'value'],
],
itemTitle: (item) => { itemTitle: (item) => {
const time = const time =
typeof item.time === 'string' && item.time.trim() 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({ export const ExpressionLearningListHook = createListItemEditorHook({
addLabel: '添加表达学习规则', addLabel: '添加表达学习规则',
helperText: '为不同聊天流单独配置是否启用表达/jargon 学习。', helperText: '为不同聊天流单独配置是否启用表达/jargon 学习。',
emptyText: '尚未配置任何学习规则。', emptyText: '尚未配置任何学习规则。',
fieldRows: [
['platform', 'item_id', 'rule_type'],
['use_expression', 'enable_learning', 'enable_jargon_learning'],
],
itemTitle: (item) => { itemTitle: (item) => {
const flags: string[] = [] const flags: string[] = []
if (item.use_expression) flags.push('表达') if (item.use_expression) flags.push('表达')

View File

@@ -11,6 +11,7 @@ export type {
UseAutoSaveReturnGeneric, UseAutoSaveReturnGeneric,
} from './useAutoSave' } from './useAutoSave'
export { export {
ChatPromptsHook,
ChatTalkValueRulesHook, ChatTalkValueRulesHook,
ExpressionGroupsHook, ExpressionGroupsHook,
ExpressionLearningListHook, 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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
@@ -106,7 +106,14 @@ function ModelConfigPageContent() {
const [jumpToPage, setJumpToPage] = useState('') const [jumpToPage, setJumpToPage] = useState('')
const [advancedTemperatureMode, setAdvancedTemperatureMode] = useState(false) const [advancedTemperatureMode, setAdvancedTemperatureMode] = useState(false)
const [advancedModelSettingsVisible, setAdvancedModelSettingsVisible] = useState(false)
const [advancedTaskSettingsVisible, setAdvancedTaskSettingsVisible] = 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 状态 // 模型 Combobox 状态
const [modelComboboxOpen, setModelComboboxOpen] = useState(false) const [modelComboboxOpen, setModelComboboxOpen] = useState(false)
@@ -130,11 +137,6 @@ function ModelConfigPageContent() {
const { toast } = useToast() const { toast } = useToast()
const { triggerRestart, isRestarting } = useRestart() const { triggerRestart, isRestarting } = useRestart()
// Tour 引导 (使用 hook 封装的逻辑)
const { startTour: handleStartTour, isRunning: tourIsRunning } = useModelTour({
onCloseEditDialog: () => setEditDialogOpen(false),
})
// 自动保存 (使用 hook 封装的逻辑) // 自动保存 (使用 hook 封装的逻辑)
const { clearTimers: clearAutoSaveTimers, initialLoadRef } = useModelAutoSave({ const { clearTimers: clearAutoSaveTimers, initialLoadRef } = useModelAutoSave({
models, models,
@@ -252,6 +254,17 @@ function ModelConfigPageContent() {
await triggerRestart() 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(() => { const handleRemoveInvalidRefs = useCallback(() => {
if (!taskConfig) return if (!taskConfig) return
@@ -285,6 +298,9 @@ function ModelConfigPageContent() {
api_provider: model.api_provider, api_provider: model.api_provider,
price_in: model.price_in ?? 0, price_in: model.price_in ?? 0,
price_out: model.price_out ?? 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, force_stream_mode: model.force_stream_mode ?? false,
extra_params: model.extra_params ?? {}, extra_params: model.extra_params ?? {},
} }
@@ -406,16 +422,26 @@ function ModelConfigPageContent() {
api_provider: providers[0] || '', api_provider: providers[0] || '',
price_in: 0, price_in: 0,
price_out: 0, price_out: 0,
cache: false,
cache_price_in: 0,
temperature: null, temperature: null,
max_tokens: null, max_tokens: null,
visual: false,
force_stream_mode: false, force_stream_mode: false,
extra_params: {}, extra_params: {},
} }
) )
setAdvancedModelSettingsVisible(false)
setEditingIndex(index) setEditingIndex(index)
setEditDialogOpen(true) setEditDialogOpen(true)
} }
// Tour 引导 (使用 hook 封装的逻辑)
const { startTour: handleStartTour, isRunning: tourIsRunning } = useModelTour({
onOpenEditDialog: () => openEditDialog(null, null),
onCloseEditDialog: () => setEditDialogOpen(false),
})
// 保存编辑 // 保存编辑
const handleSaveEdit = () => { const handleSaveEdit = () => {
if (!editingModel) return if (!editingModel) return
@@ -459,6 +485,9 @@ function ModelConfigPageContent() {
api_provider: editingModel.api_provider, api_provider: editingModel.api_provider,
price_in: editingModel.price_in ?? 0, price_in: editingModel.price_in ?? 0,
price_out: editingModel.price_out ?? 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, force_stream_mode: editingModel.force_stream_mode ?? false,
extra_params: editingModel.extra_params ?? {}, extra_params: editingModel.extra_params ?? {},
} }
@@ -792,12 +821,19 @@ function ModelConfigPageContent() {
</div> </div>
{/* 重启提示 */} {/* 重启提示 */}
{restartNoticeVisible && (
<Alert> <Alert>
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
<AlertDescription> <AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span>
<strong></strong>"保存并重启" <strong></strong>"保存并重启"
</span>
<Button type="button" variant="outline" size="sm" onClick={dismissRestartNotice}>
</Button>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)}
{/* 无效模型引用警告 */} {/* 无效模型引用警告 */}
{invalidModelRefs.length > 0 && ( {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}> <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" /> <GraduationCap className="h-4 w-4 text-primary" />
<AlertDescription className="flex items-center justify-between"> <AlertDescription className="flex items-center justify-between">
<span> <span>
<strong className="text-primary"></strong> <strong className="text-primary"></strong>
</span> </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>
<Button type="button" variant="ghost" size="sm" onClick={dismissTourEntry}>
</Button>
</div>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)}
{/* 标签页 */} {/* 标签页 */}
<Tabs defaultValue="models" className="w-full"> <Tabs defaultValue="models" className="w-full">
<TabsList className="grid w-full max-w-full sm:max-w-md grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="models"></TabsTrigger> <TabsTrigger value="models" className="w-full"></TabsTrigger>
<TabsTrigger value="tasks" data-tour="tasks-tab-trigger"></TabsTrigger> <TabsTrigger value="tasks" className="w-full" data-tour="tasks-tab-trigger"></TabsTrigger>
</TabsList> </TabsList>
{/* 模型配置标签页 */} {/* 模型配置标签页 */}
<TabsContent value="models" className="space-y-4 mt-0"> <TabsContent value="models" className="space-y-4 mt-0">
@@ -976,6 +1019,7 @@ function ModelConfigPageContent() {
modelNames={modelNames} modelNames={modelNames}
onChange={(f, value) => updateTaskConfig(field.name, f, value)} onChange={(f, value) => updateTaskConfig(field.name, f, value)}
advanced={field.advanced} advanced={field.advanced}
showAdvancedSettings={advancedTaskSettingsVisible}
{...(index === 0 ? { dataTour: 'task-model-select' } : {})} {...(index === 0 ? { dataTour: 'task-model-select' } : {})}
/> />
) )
@@ -997,13 +1041,30 @@ function ModelConfigPageContent() {
<DialogTitle> <DialogTitle>
{editingIndex !== null ? '编辑模型' : '添加模型'} {editingIndex !== null ? '编辑模型' : '添加模型'}
</DialogTitle> </DialogTitle>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<DialogDescription></DialogDescription> <DialogDescription></DialogDescription>
<Button
type="button"
variant={advancedModelSettingsVisible ? 'default' : 'outline'}
size="sm"
onClick={() => setAdvancedModelSettingsVisible((current) => !current)}
className="self-start sm:self-auto"
>
</Button>
</div>
</DialogHeader> </DialogHeader>
<DialogBody> <DialogBody>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2" data-tour="model-name-input"> <div className="grid gap-2" data-tour="model-name-input">
<Label htmlFor="model_name" className={formErrors.name ? 'text-destructive' : ''}> *</Label> <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 <Input
id="model_name" id="model_name"
value={editingModel?.name || ''} value={editingModel?.name || ''}
@@ -1016,19 +1077,26 @@ function ModelConfigPageContent() {
} }
}} }}
placeholder="例如: qwen3-30b" placeholder="例如: qwen3-30b"
className={formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''} className={`sm:flex-1 ${formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''}`}
/> />
</div>
{formErrors.name ? ( {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> </p>
)} )}
</div> </div>
<div className="grid gap-2" data-tour="model-provider-select"> <div className="grid gap-2" data-tour="model-provider-select">
<Label htmlFor="api_provider" className={formErrors.api_provider ? 'text-destructive' : ''}>API *</Label> <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 <Select
value={editingModel?.api_provider || ''} value={editingModel?.api_provider || ''}
onValueChange={(value) => { onValueChange={(value) => {
@@ -1042,7 +1110,7 @@ function ModelConfigPageContent() {
} }
}} }}
> >
<SelectTrigger id="api_provider" className={formErrors.api_provider ? 'border-destructive focus-visible:ring-destructive' : ''}> <SelectTrigger id="api_provider" className={`sm:flex-1 ${formErrors.api_provider ? 'border-destructive focus-visible:ring-destructive' : ''}`}>
<SelectValue placeholder="选择提供商" /> <SelectValue placeholder="选择提供商" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -1053,8 +1121,9 @@ function ModelConfigPageContent() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div>
{formErrors.api_provider && ( {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> </div>
@@ -1277,6 +1346,50 @@ function ModelConfigPageContent() {
</div> </div>
</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="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -1459,6 +1572,21 @@ function ModelConfigPageContent() {
)} )}
</div> </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"> <div className="flex items-center space-x-2">
<Switch <Switch
id="force_stream_mode" id="force_stream_mode"

View File

@@ -55,6 +55,11 @@ export const ModelCardList = React.memo(function ModelCardList({
> >
{used ? '已使用' : '未使用'} {used ? '已使用' : '未使用'}
</Badge> </Badge>
{model.visual && (
<Badge variant="outline" className="border-blue-500 text-blue-600">
</Badge>
)}
</div> </div>
<p className="text-xs text-muted-foreground break-all" title={model.model_identifier}> <p className="text-xs text-muted-foreground break-all" title={model.model_identifier}>
{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></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead> <TableHead className="text-center"></TableHead>
<TableHead className="text-right"></TableHead> <TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead> <TableHead className="text-right"></TableHead>
@@ -76,7 +77,7 @@ export const ModelTable = React.memo(function ModelTable({
<TableBody> <TableBody>
{paginatedModels.length === 0 ? ( {paginatedModels.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={9} className="text-center text-muted-foreground py-8"> <TableCell colSpan={10} className="text-center text-muted-foreground py-8">
{searchQuery ? '未找到匹配的模型' : '暂无模型配置'} {searchQuery ? '未找到匹配的模型' : '暂无模型配置'}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -105,6 +106,15 @@ export const ModelTable = React.memo(function ModelTable({
{model.model_identifier} {model.model_identifier}
</TableCell> </TableCell>
<TableCell>{model.api_provider}</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"> <TableCell className="text-center">
{model.temperature != null ? model.temperature : <span className="text-muted-foreground">-</span>} {model.temperature != null ? model.temperature : <span className="text-muted-foreground">-</span>}
</TableCell> </TableCell>

View File

@@ -13,6 +13,12 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { TaskConfig } from '../types' import type { TaskConfig } from '../types'
@@ -25,9 +31,28 @@ interface TaskConfigCardProps {
hideTemperature?: boolean hideTemperature?: boolean
hideMaxTokens?: boolean hideMaxTokens?: boolean
advanced?: boolean advanced?: boolean
showAdvancedSettings?: boolean
dataTour?: string 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({ export const TaskConfigCard = React.memo(function TaskConfigCard({
title, title,
description, description,
@@ -37,6 +62,7 @@ export const TaskConfigCard = React.memo(function TaskConfigCard({
hideTemperature = false, hideTemperature = false,
hideMaxTokens = false, hideMaxTokens = false,
advanced = false, advanced = false,
showAdvancedSettings = false,
dataTour, dataTour,
}: TaskConfigCardProps) { }: TaskConfigCardProps) {
const handleModelChange = (values: string[]) => { const handleModelChange = (values: string[]) => {
@@ -68,8 +94,8 @@ export const TaskConfigCard = React.memo(function TaskConfigCard({
/> />
</div> </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 && ( {!hideTemperature && (
<div className="grid gap-3"> <div className="grid gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -112,13 +138,46 @@ export const TaskConfigCard = React.memo(function TaskConfigCard({
/> />
</div> </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>
<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>
</div> </div>
{/* 慢请求阈值 */} {showAdvancedSettings && (
<div className="grid gap-2"> <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"> <div className="flex items-center justify-between">
<Label> ()</Label> <Label> ()</Label>
<span className="text-xs text-muted-foreground"></span> <span className="text-xs text-muted-foreground"></span>
</div> </div>
<Input <Input
type="number" type="number"
@@ -137,26 +196,8 @@ export const TaskConfigCard = React.memo(function TaskConfigCard({
</p> </p>
</div> </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>
</div> </div>
</div> </div>
) )

View File

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

View File

@@ -66,6 +66,9 @@ export function useModelAutoSave(
api_provider: model.api_provider, api_provider: model.api_provider,
price_in: model.price_in ?? 0, price_in: model.price_in ?? 0,
price_out: model.price_out ?? 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, force_stream_mode: model.force_stream_mode ?? false,
extra_params: model.extra_params ?? {}, 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' import { MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps, STEP_ROUTE_MAP } from '@/components/tour/tours/model-assignment-tour'
interface UseModelTourOptions { interface UseModelTourOptions {
/** 打开模型编辑对话框回调 */
onOpenEditDialog?: () => void
/** 关闭编辑对话框回调 */ /** 关闭编辑对话框回调 */
onCloseEditDialog?: () => void onCloseEditDialog?: () => void
} }
@@ -24,13 +26,33 @@ interface UseModelTourReturn {
* Model 配置页面 Tour 引导 Hook * Model 配置页面 Tour 引导 Hook
*/ */
export function useModelTour(options: UseModelTourOptions = {}): UseModelTourReturn { export function useModelTour(options: UseModelTourOptions = {}): UseModelTourReturn {
const { onCloseEditDialog } = options const { onOpenEditDialog, onCloseEditDialog } = options
const navigate = useNavigate() const navigate = useNavigate()
const { registerTour, startTour: startTourFn, state: tourState, goToStep } = useTour() const { registerTour, startTour: startTourFn, state: tourState, goToStep } = useTour()
// 用于追踪前一个步骤 // 用于追踪前一个步骤
const prevTourStepRef = useRef(tourState.stepIndex) 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 // 注册 Tour
useEffect(() => { useEffect(() => {
registerTour(MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps) 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 if (tourState.activeTourId !== MODEL_ASSIGNMENT_TOUR_ID || !tourState.isRunning) return
const handleTourClick = (e: MouseEvent) => { const handleTourClick = (e: MouseEvent) => {
const target = e.target as HTMLElement
const currentStep = tourState.stepIndex const currentStep = tourState.stepIndex
// Step 3 (index 2): 点击添加提供商按钮 // 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) setTimeout(() => goToStep(3), 300)
} }
// Step 10 (index 9): 点击取消按钮(关闭提供商弹窗) // 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) setTimeout(() => goToStep(10), 300)
} }
// Step 12 (index 11): 点击添加模型按钮 // 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) setTimeout(() => goToStep(12), 300)
} }
// Step 18 (index 17): 点击取消按钮(关闭模型弹窗) // 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) setTimeout(() => goToStep(18), 300)
} }
// Step 19 (index 18): 点击为模型分配功能标签页 // 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) setTimeout(() => goToStep(19), 300)
} }
} }
document.addEventListener('click', handleTourClick, true) document.addEventListener('click', handleTourClick, true)
return () => document.removeEventListener('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(() => { const handleStartTour = useCallback(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -267,7 +267,7 @@ export function ExpressionManagementPage() {
<div> <div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2"> <h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2">
<MessageSquare className="h-8 w-8" strokeWidth={2} /> <MessageSquare className="h-8 w-8" strokeWidth={2} />
</h1> </h1>
<p className="text-muted-foreground mt-1 text-sm sm:text-base"> <p className="text-muted-foreground mt-1 text-sm sm:text-base">
@@ -316,9 +316,10 @@ export function ExpressionManagementPage() {
{/* 搜索和批量操作 */} {/* 搜索和批量操作 */}
<div className="rounded-lg border bg-card p-4"> <div className="rounded-lg border bg-card p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex-1">
<Label htmlFor="search"></Label> <Label htmlFor="search"></Label>
<div className="flex flex-col sm:flex-row gap-2 mt-1.5"> <div className="relative mt-1.5">
<div className="flex-1 relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
id="search" id="search"
@@ -329,15 +330,7 @@ export function ExpressionManagementPage() {
/> />
</div> </div>
</div> </div>
<div className="flex items-center gap-2 sm:pb-0.5">
{/* 批量操作工具栏 */}
<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">
<Label htmlFor="page-size" className="text-sm whitespace-nowrap"></Label> <Label htmlFor="page-size" className="text-sm whitespace-nowrap"></Label>
<Select <Select
value={pageSize.toString()} value={pageSize.toString()}
@@ -357,6 +350,17 @@ export function ExpressionManagementPage() {
<SelectItem value="100">100</SelectItem> <SelectItem value="100">100</SelectItem>
</SelectContent> </SelectContent>
</Select> </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 && ( {selectedIds.size > 0 && (
<> <>
<Button <Button

View File

@@ -250,7 +250,7 @@ export function JargonManagementPage() {
<div> <div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2"> <h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2">
<MessageCircle className="h-8 w-8" strokeWidth={2} /> <MessageCircle className="h-8 w-8" strokeWidth={2} />
</h1> </h1>
<p className="text-muted-foreground mt-1 text-sm sm:text-base"> <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-icon'?: string
'x-layout'?: 'inline-right' 'x-layout'?: 'inline-right'
'x-input-width'?: string 'x-input-width'?: string
'x-textarea-min-height'?: number
'x-textarea-rows'?: number
advanced?: boolean advanced?: boolean
step?: number step?: number
} }
@@ -52,7 +54,6 @@ export interface ConfigSchema {
uiParent?: string uiParent?: string
uiLabel?: string uiLabel?: string
uiIcon?: string uiIcon?: string
uiMergeChildren?: string[]
} }
export interface ConfigSchemaResponse { export interface ConfigSchemaResponse {

View File

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

View File

@@ -4,6 +4,7 @@ from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Literal, Optional, Tuple from typing import Any, Awaitable, Callable, Dict, List, Literal, Optional, Tuple
import json import json
import random
import time import time
from rich.console import Group, RenderableType from rich.console import Group, RenderableType
@@ -103,6 +104,24 @@ class BaseMaisakaReplyGenerator:
logger.warning(f"构建 Maisaka 人设提示词失败: {exc}") logger.warning(f"构建 Maisaka 人设提示词失败: {exc}")
return "你的名字是麦麦。\n是人类。" 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 @staticmethod
def _normalize_content(content: str, limit: int = 500) -> str: def _normalize_content(content: str, limit: int = 500) -> str:
normalized = " ".join((content or "").split()) normalized = " ".join((content or "").split())
@@ -293,7 +312,7 @@ class BaseMaisakaReplyGenerator:
group_chat_attention_block=self._build_group_chat_attention_block(session_id), group_chat_attention_block=self._build_group_chat_attention_block(session_id),
replyer_at_block=self._build_replyer_at_block(), replyer_at_block=self._build_replyer_at_block(),
identity=self._personality_prompt, identity=self._personality_prompt,
reply_style=global_config.personality.reply_style, reply_style=self._select_reply_style(),
) )
except Exception: except Exception:
system_prompt = "你是一个友好的 AI 助手,请根据聊天记录自然回复。" 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() A_MEMORIX_LEGACY_CONFIG_PATH: Path = (CONFIG_DIR / "a_memorix.toml").resolve().absolute()
MMC_VERSION: str = "1.0.0-pre.10" MMC_VERSION: str = "1.0.0-pre.10"
CONFIG_VERSION: str = "8.10.6" CONFIG_VERSION: str = "8.10.6"
MODEL_CONFIG_VERSION: str = "1.14.8" MODEL_CONFIG_VERSION: str = "1.15.3"
logger = get_logger("config") logger = get_logger("config")

View File

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

View File

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

View File

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

View File

@@ -29,7 +29,7 @@ logger = get_logger("emoji")
install(extra_lines=3) 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" DATA_DIR = PROJECT_ROOT / "data"
EmojiRegisterStatus = Literal["registered", "skipped", "failed"] EmojiRegisterStatus = Literal["registered", "skipped", "failed"]
EMOJI_DIR = DATA_DIR / "emoji" # 表情包存储目录 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() 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: def _is_vlm_task_configured() -> bool:
"""判断是否配置了可用于表情包识别和审核的视觉模型任务。""" """判断是否配置了可用于表情包识别和审核的视觉模型任务。"""
@@ -244,6 +255,7 @@ class EmojiManager:
self._emoji_num: int = 0 self._emoji_num: int = 0
self.emojis: list[MaiEmoji] = [] self.emojis: list[MaiEmoji] = []
self._known_emoji_file_paths: set[Path] = set()
self._maintenance_wakeup_event: asyncio.Event = asyncio.Event() self._maintenance_wakeup_event: asyncio.Event = asyncio.Event()
self._pending_description_tasks: dict[str, asyncio.Task[None]] = {} self._pending_description_tasks: dict[str, asyncio.Task[None]] = {}
self._reload_callback_registered: bool = False self._reload_callback_registered: bool = False
@@ -254,9 +266,9 @@ class EmojiManager:
logger.info("启动表情包管理器") logger.info("启动表情包管理器")
def reload_runtime_config(self) -> None: def reload_runtime_config(self) -> None:
"""响应配置热重载,唤醒维护循环以尽快应用最新配置。""" """响应配置热重载,重置维护循环等待时间以应用最新配置。"""
self._maintenance_wakeup_event.set() self._maintenance_wakeup_event.set()
logger.info("[配置热重载] Emoji 模块配置已更新,将立即应用到维护循环") logger.info("[配置热重载] Emoji 模块配置已更新,将按新的检查间隔等待后执行维护")
def shutdown(self) -> None: def shutdown(self) -> None:
"""清理 EmojiManager 生命周期资源。""" """清理 EmojiManager 生命周期资源。"""
@@ -948,33 +960,73 @@ class EmojiManager:
def check_emoji_file_integrity(self) -> None: def check_emoji_file_integrity(self) -> None:
""" """
检查表情包完整性,删除文件缺失的表情包记录 检查表情包文件和数据库注册记录的一致性。
数据库记录存在但文件缺失时删除数据库记录;文件存在但没有数据库记录时删除文件。
""" """
logger.info("[完整性检查] 开始检查表情包文件完整性...") _ensure_directories()
to_delete_emojis: list[tuple[MaiEmoji, bool]] = [] logger.info("[完整性检查] 开始检查表情包文件和注册记录一致性...")
removal_count = 0 tracked_paths: set[Path] = set()
for emoji in self.emojis: record_removal_count = 0
if not emoji.full_path.exists(): file_removal_count = 0
logger.warning(f"[完整性检查] 表情包文件缺失,准备修改记录: {emoji.file_name}") available_emojis: list[MaiEmoji] = []
to_delete_emojis.append((emoji, False))
if not emoji.description:
logger.warning(f"[完整性检查] 表情包记录缺失描述,准备删除记录: {emoji.file_name}")
to_delete_emojis.append((emoji, True))
for emoji, is_description_empty in to_delete_emojis: with get_db_session() as session:
if self.delete_emoji(emoji, is_description_empty): statement = select(Images).filter_by(image_type=ImageType.EMOJI)
self.emojis.remove(emoji) records = session.exec(statement).all()
self._emoji_num -= 1 for record in records:
removal_count += 1 record_path = _resolve_existing_emoji_path(record.full_path)
logger.info(f"[完整性检查] 成功删除缺失文件的表情包记录: {emoji.file_name}") if record.no_file_flag or record_path is None:
else: logger.warning(
logger.error(f"[完整性检查] 删除缺失文件的表情包记录失败: {emoji.file_name}") 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: async def periodic_emoji_maintenance(self) -> None:
"""Run emoji maintenance tasks periodically.""" """Run emoji maintenance tasks periodically."""
while True: 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() _ensure_directories()
try: try:
self.check_emoji_file_integrity() self.check_emoji_file_integrity()
@@ -989,24 +1041,21 @@ class EmojiManager:
for emoji_file in EMOJI_DIR.iterdir(): for emoji_file in EMOJI_DIR.iterdir():
if not emoji_file.is_file(): if not emoji_file.is_file():
continue continue
resolved_file = emoji_file.absolute().resolve()
if resolved_file in self._known_emoji_file_paths:
continue
try: try:
register_status = await self.register_emoji_by_filename(emoji_file) register_status = await self.register_emoji_by_filename(emoji_file)
except Exception as e: except Exception as e:
logger.error(f"[emoji_maintenance] Failed to process {emoji_file.name}: {e}") logger.error(f"[emoji_maintenance] Failed to process {emoji_file.name}: {e}")
register_status = "failed" register_status = "failed"
if register_status == "registered": if register_status == "registered":
self._known_emoji_file_paths.add(resolved_file)
break break
if register_status == "skipped": if register_status == "skipped":
logger.debug(f"[emoji_maintenance] Emoji already registered, keep file: {emoji_file.name}") logger.debug(f"[emoji_maintenance] Emoji already registered, keep file: {emoji_file.name}")
else: else:
logger.debug(f"[emoji_maintenance] Emoji not registered, keep file: {emoji_file.name}") 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: async def register_emoji_by_filename(self, filename: Path | str) -> EmojiRegisterStatus:
"""Register an emoji file from ``data/emoji`` without moving or deleting it.""" """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_parent = getattr(config_class, "__ui_parent__", "")
ui_label = getattr(config_class, "__ui_label__", "") ui_label = getattr(config_class, "__ui_label__", "")
ui_icon = getattr(config_class, "__ui_icon__", "") ui_icon = getattr(config_class, "__ui_icon__", "")
ui_merge_children = getattr(config_class, "__ui_merge_children__", [])
if ui_parent: if ui_parent:
schema["uiParent"] = ui_parent schema["uiParent"] = ui_parent
if ui_label: if ui_label:
schema["uiLabel"] = ui_label schema["uiLabel"] = ui_label
if ui_icon: if ui_icon:
schema["uiIcon"] = ui_icon schema["uiIcon"] = ui_icon
if ui_merge_children:
schema["uiMergeChildren"] = list(ui_merge_children)
return schema 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.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 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.common.logger import get_logger
from src.webui.dependencies import require_auth from src.webui.dependencies import require_auth
@@ -28,6 +28,7 @@ class ExpressionResponse(BaseModel):
style: str style: str
last_active_time: float last_active_time: float
chat_id: str chat_id: str
chat_name: Optional[str] = None
create_date: Optional[float] create_date: Optional[float]
checked: bool checked: bool
rejected: bool rejected: bool
@@ -90,7 +91,61 @@ class ExpressionCreateResponse(BaseModel):
data: ExpressionResponse 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: 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 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 create_date = expression.create_time.timestamp() if expression.create_time else None
chat_id = expression.session_id or ""
return ExpressionResponse( return ExpressionResponse(
id=expression.id if expression.id is not None else 0, id=expression.id if expression.id is not None else 0,
situation=expression.situation, situation=expression.situation,
style=expression.style, style=expression.style,
last_active_time=last_active_time, 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, create_date=create_date,
checked=False, checked=expression.checked,
rejected=False, rejected=expression.rejected,
modified_by=None, 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]: 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 result = {cid: cid for cid in chat_ids} # 默认值为原始ID
try: try:
for chat_id in chat_ids: for chat_id in chat_ids:
if name := _chat_manager.get_session_name(chat_id): result[chat_id] = get_chat_name(chat_id)
result[chat_id] = name
except Exception as e: except Exception as e:
logger.warning(f"批量获取聊天名称失败: {e}") logger.warning(f"批量获取聊天名称失败: {e}")
return result return result
@@ -176,19 +213,43 @@ async def get_chat_list() -> ChatListResponse:
ChatListResponse: 可用于下拉选择的聊天列表。 ChatListResponse: 可用于下拉选择的聊天列表。
""" """
try: try:
chat_list = [] chat_by_id: Dict[str, ChatInfo] = {}
for session_id, session in _chat_manager.sessions.items(): for session_id, session in _chat_manager.sessions.items():
chat_name = _chat_manager.get_session_name(session_id) or session_id chat_name = _chat_manager.get_session_name(session_id) or session_id
chat_list.append( chat_by_id[session_id] = ChatInfo(
ChatInfo(
chat_id=session_id, chat_id=session_id,
chat_name=chat_name, chat_name=chat_name,
platform=session.platform, platform=session.platform,
is_group=session.is_group_session, 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) chat_list.sort(key=lambda x: x.chat_name)
return ChatListResponse(success=True, data=chat_list) return ChatListResponse(success=True, data=chat_list)
@@ -252,7 +313,7 @@ async def get_expression_list(
if chat_id: if chat_id:
count_statement = count_statement.where(col(Expression.session_id) == chat_id) count_statement = count_statement.where(col(Expression.session_id) == chat_id)
total = len(session.exec(count_statement).all()) 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) 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: if not expression:
raise HTTPException(status_code=404, detail=f"未找到 ID 为 {expression_id} 的表达方式") 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) return ExpressionDetailResponse(success=True, data=data)
@@ -321,7 +382,7 @@ async def create_expression(
session.add(expression) session.add(expression)
session.flush() session.flush()
expression_id = expression.id expression_id = expression.id
data = expression_to_response(expression) data = expression_to_response(expression, session)
logger.info(f"表达方式已创建: ID={expression_id}, situation={request.situation}") 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.session_id = update_data["session_id"]
db_expression.last_active_time = update_data["last_active_time"] db_expression.last_active_time = update_data["last_active_time"]
session.add(db_expression) 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())}") logger.info(f"表达方式已更新: ID={expression_id}, 字段: {list(update_data.keys())}")
@@ -524,6 +585,22 @@ class ReviewStatsResponse(BaseModel):
user_checked: int 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) @router.get("/review/stats", response_model=ReviewStatsResponse)
async def get_review_stats() -> ReviewStatsResponse: async def get_review_stats() -> ReviewStatsResponse:
"""获取审核统计数据。 """获取审核统计数据。
@@ -533,12 +610,24 @@ async def get_review_stats() -> ReviewStatsResponse:
""" """
try: try:
with get_db_session() as session: with get_db_session() as session:
total = len(session.exec(select(Expression.id)).all()) total = count_expressions(session, select(Expression.id))
unchecked = 0 unchecked = count_expressions(session, apply_review_filter(select(Expression.id), "unchecked"))
passed = 0 passed = count_expressions(session, apply_review_filter(select(Expression.id), "passed"))
rejected = 0 rejected = count_expressions(session, apply_review_filter(select(Expression.id), "rejected"))
ai_checked = 0 ai_checked = count_expressions(
user_checked = 0 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( return ReviewStatsResponse(
total=total, total=total,
@@ -587,10 +676,7 @@ async def get_review_list(
ReviewListResponse: 审核列表响应。 ReviewListResponse: 审核列表响应。
""" """
try: try:
statement = select(Expression) statement = apply_review_filter(select(Expression), filter_type)
if filter_type in {"unchecked", "passed", "rejected"}:
statement = statement.where(col(Expression.id) == -1)
# all 不需要额外过滤 # all 不需要额外过滤
# 搜索过滤 # 搜索过滤
@@ -615,9 +701,7 @@ async def get_review_list(
with get_db_session() as session: with get_db_session() as session:
expressions = session.exec(statement).all() expressions = session.exec(statement).all()
count_statement = select(Expression.id) count_statement = apply_review_filter(select(Expression.id), filter_type)
if filter_type in {"unchecked", "passed", "rejected"}:
count_statement = count_statement.where(col(Expression.id) == -1)
if search: if search:
count_statement = count_statement.where( count_statement = count_statement.where(
(col(Expression.situation).contains(search)) | (col(Expression.style).contains(search)) (col(Expression.situation).contains(search)) | (col(Expression.style).contains(search))
@@ -625,7 +709,7 @@ async def get_review_list(
if chat_id: if chat_id:
count_statement = count_statement.where(col(Expression.session_id) == chat_id) count_statement = count_statement.where(col(Expression.session_id) == chat_id)
total = len(session.exec(count_statement).all()) 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( return ReviewListResponse(
success=True, success=True,
@@ -706,10 +790,10 @@ async def batch_review_expressions(
failed += 1 failed += 1
continue continue
# 冲突检测 # 冲突检测:未审核列表发起的操作只允许处理仍处于未审核状态的条目。
if item.require_unchecked: if item.require_unchecked and expression.checked:
results.append( results.append(
BatchReviewResultItem(id=item.id, success=False, message="当前模型不支持审核状态过滤") BatchReviewResultItem(id=item.id, success=False, message="该表达方式已被审核,请刷新列表后重试")
) )
failed += 1 failed += 1
continue continue
@@ -727,6 +811,9 @@ async def batch_review_expressions(
) )
failed += 1 failed += 1
continue continue
db_expression.checked = True
db_expression.rejected = item.rejected
db_expression.modified_by = ModifiedBy.USER
db_expression.last_active_time = datetime.now() db_expression.last_active_time = datetime.now()
session.add(db_expression) 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" }, { 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]] [[package]]
name = "httpx-sse" name = "httpx-sse"
version = "0.4.3" version = "0.4.3"
@@ -1452,7 +1457,7 @@ dependencies = [
{ name = "faiss-cpu" }, { name = "faiss-cpu" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "google-genai" }, { name = "google-genai" },
{ name = "httpx" }, { name = "httpx", extra = ["socks"] },
{ name = "jieba" }, { name = "jieba" },
{ name = "json-repair" }, { name = "json-repair" },
{ name = "maibot-dashboard" }, { name = "maibot-dashboard" },
@@ -1503,10 +1508,10 @@ requires-dist = [
{ name = "faiss-cpu", specifier = ">=1.11.0" }, { name = "faiss-cpu", specifier = ">=1.11.0" },
{ name = "fastapi", specifier = ">=0.116.0" }, { name = "fastapi", specifier = ">=0.116.0" },
{ name = "google-genai", specifier = ">=1.39.1" }, { name = "google-genai", specifier = ">=1.39.1" },
{ name = "httpx" }, { name = "httpx", extras = ["socks"] },
{ name = "jieba", specifier = ">=0.42.1" }, { name = "jieba", specifier = ">=0.42.1" },
{ name = "json-repair", specifier = ">=0.47.6" }, { 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 = "maibot-plugin-sdk", specifier = ">=2.4.0" },
{ name = "maim-message", specifier = ">=0.6.2" }, { name = "maim-message", specifier = ">=0.6.2" },
{ name = "matplotlib", specifier = ">=3.10.5" }, { name = "matplotlib", specifier = ">=3.10.5" },
@@ -1544,11 +1549,11 @@ dev = [
[[package]] [[package]]
name = "maibot-dashboard" name = "maibot-dashboard"
version = "1.0.1.dev2026050251" version = "1.0.4"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } 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 = [ 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]] [[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" }, { 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]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.49" version = "2.0.49"