feat:webui支持更加优化的模型配置,优化多处UI体验,支持设置视觉和cache价格,修复多重表达不生效的问题,修复表情包路径错误
This commit is contained in:
4
dashboard/package-lock.json
generated
4
dashboard/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "maibot-dashboard",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "maibot-dashboard",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.5",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "maibot-dashboard",
|
||||
"private": true,
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.5",
|
||||
"type": "module",
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -76,7 +76,6 @@ function DynamicConfigSection({
|
||||
basePath,
|
||||
hooks,
|
||||
level,
|
||||
mergedChildren = [],
|
||||
nestedSchema,
|
||||
onChange,
|
||||
sectionDescription,
|
||||
@@ -87,11 +86,6 @@ function DynamicConfigSection({
|
||||
basePath: string
|
||||
hooks: FieldHookRegistry
|
||||
level: number
|
||||
mergedChildren?: Array<{
|
||||
key: string
|
||||
schema: ConfigSchema
|
||||
values: Record<string, unknown>
|
||||
}>
|
||||
nestedSchema: ConfigSchema
|
||||
onChange: (field: string, value: unknown) => void
|
||||
sectionDescription?: string
|
||||
@@ -100,9 +94,7 @@ function DynamicConfigSection({
|
||||
values: Record<string, unknown>
|
||||
}) {
|
||||
const [advancedVisible, setAdvancedVisible] = React.useState(false)
|
||||
const hasAdvanced =
|
||||
hasTopLevelAdvancedFields(nestedSchema) ||
|
||||
mergedChildren.some((child) => hasTopLevelAdvancedFields(child.schema))
|
||||
const hasAdvanced = hasTopLevelAdvancedFields(nestedSchema)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -135,37 +127,6 @@ function DynamicConfigSection({
|
||||
level={level}
|
||||
advancedVisible={hasAdvanced ? advancedVisible : undefined}
|
||||
/>
|
||||
{mergedChildren.map((child) => {
|
||||
const childTitle = resolveSectionTitle(child.schema)
|
||||
const childDescription = resolveSectionDescription(child.schema, childTitle)
|
||||
const parentPath = basePath.includes('.')
|
||||
? basePath.replace(/\.[^.]+$/, '')
|
||||
: ''
|
||||
const childPath = buildFieldPath(parentPath, child.key)
|
||||
|
||||
return (
|
||||
<div key={child.key} className="mt-5 border-t border-border/50 pt-4">
|
||||
<div className="mb-3 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<SectionIcon iconName={child.schema.uiIcon} />
|
||||
<h3 className="text-sm font-medium">{childTitle}</h3>
|
||||
</div>
|
||||
{childDescription && (
|
||||
<p className="text-xs text-muted-foreground">{childDescription}</p>
|
||||
)}
|
||||
</div>
|
||||
<DynamicConfigForm
|
||||
schema={child.schema}
|
||||
values={child.values}
|
||||
onChange={(field, value) => onChange(`${child.key}.${field}`, value)}
|
||||
basePath={childPath}
|
||||
hooks={hooks}
|
||||
level={level}
|
||||
advancedVisible={hasAdvanced ? advancedVisible : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -197,17 +158,6 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
() => new Map(schema.fields.map((field) => [field.name, field])),
|
||||
[schema.fields],
|
||||
)
|
||||
const mergedChildKeys = React.useMemo(() => {
|
||||
const keys = new Set<string>()
|
||||
for (const nestedSchema of Object.values(schema.nested ?? {})) {
|
||||
for (const childKey of nestedSchema.uiMergeChildren ?? []) {
|
||||
if (schema.nested?.[childKey]) {
|
||||
keys.add(childKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}, [schema.nested])
|
||||
|
||||
const renderField = (field: FieldSchema) => {
|
||||
const fieldPath = buildFieldPath(basePath, field.name)
|
||||
@@ -294,7 +244,6 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
|
||||
{schema.nested &&
|
||||
Object.entries(schema.nested)
|
||||
.filter(([key]) => !mergedChildKeys.has(key))
|
||||
.map(([key, nestedSchema]) => {
|
||||
const nestedField = fieldMap.get(key)
|
||||
const nestedFieldPath = buildFieldPath(basePath, key)
|
||||
@@ -342,34 +291,11 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
|
||||
const sectionTitle = resolveSectionTitle(nestedSchema)
|
||||
const sectionDescription = resolveSectionDescription(nestedSchema, sectionTitle)
|
||||
const mergedChildren = (nestedSchema.uiMergeChildren ?? [])
|
||||
.map((childKey) => {
|
||||
const childSchema = schema.nested?.[childKey]
|
||||
if (!childSchema) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
key: childKey,
|
||||
schema: childSchema,
|
||||
values: (values[childKey] as Record<string, unknown>) || {},
|
||||
}
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
child,
|
||||
): child is {
|
||||
key: string
|
||||
schema: ConfigSchema
|
||||
values: Record<string, unknown>
|
||||
} => Boolean(child),
|
||||
)
|
||||
|
||||
if (level === 0) {
|
||||
return (
|
||||
<DynamicConfigSection
|
||||
key={key}
|
||||
mergedChildren={mergedChildren}
|
||||
nestedSchema={nestedSchema}
|
||||
values={(values[key] as Record<string, unknown>) || {}}
|
||||
onChange={onChange}
|
||||
|
||||
@@ -31,6 +31,23 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const parseNumericValue = (rawValue: unknown, fallback: number) => {
|
||||
if (typeof rawValue === 'number' && Number.isFinite(rawValue)) {
|
||||
return rawValue
|
||||
}
|
||||
|
||||
if (typeof rawValue === 'string') {
|
||||
const parsedValue = schema.type === 'integer'
|
||||
? parseInt(rawValue, 10)
|
||||
: parseFloat(rawValue)
|
||||
if (Number.isFinite(parsedValue)) {
|
||||
return parsedValue
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
const renderPrimitiveArrayEditor = () => {
|
||||
const itemType = schema.items?.type ?? 'string'
|
||||
const arrayValue = Array.isArray(value)
|
||||
@@ -94,6 +111,12 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
return <IconComponent className="h-4 w-4" />
|
||||
}
|
||||
|
||||
const isRuleTypeSelect =
|
||||
schema.name === 'rule_type' &&
|
||||
(schema.type === 'select' || schema['x-widget'] === 'select')
|
||||
const inlineDescription = isRuleTypeSelect ? '' : schema.description
|
||||
const selectHoverDescription = isRuleTypeSelect ? schema.description : undefined
|
||||
|
||||
const renderFieldHeader = () => (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Label
|
||||
@@ -108,9 +131,9 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
<span className="break-all">{schema.label}</span>
|
||||
{schema.required && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
{schema.description && (
|
||||
{inlineDescription && (
|
||||
<span className="text-[13px] leading-6 text-muted-foreground whitespace-pre-line">
|
||||
{schema.description}
|
||||
{inlineDescription}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -129,6 +152,9 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
case 'slider':
|
||||
return renderSlider()
|
||||
case 'input':
|
||||
if (type === 'integer' || type === 'number') {
|
||||
return renderNumberInput()
|
||||
}
|
||||
return renderTextInput()
|
||||
case 'number':
|
||||
return renderNumberInput()
|
||||
@@ -214,7 +240,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
* 渲染 Slider 组件(用于 number 类型 + x-widget: slider)
|
||||
*/
|
||||
const renderSlider = () => {
|
||||
const numValue = typeof value === 'number' ? value : (schema.default as number ?? 0)
|
||||
const numValue = parseNumericValue(value, schema.default as number ?? 0)
|
||||
const min = schema.minValue ?? 0
|
||||
const max = schema.maxValue ?? 100
|
||||
const step = schema.step ?? 1
|
||||
@@ -241,7 +267,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
* 渲染 Input[type="number"] 组件(用于 number/integer 类型)
|
||||
*/
|
||||
const renderNumberInput = () => {
|
||||
const numValue = typeof value === 'number' ? value : (schema.default as number ?? 0)
|
||||
const numValue = parseNumericValue(value, schema.default as number ?? 0)
|
||||
const min = schema.minValue
|
||||
const max = schema.maxValue
|
||||
const step = schema.step ?? (schema.type === 'integer' ? 1 : 0.1)
|
||||
@@ -250,7 +276,12 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
<Input
|
||||
type="number"
|
||||
value={numValue}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
onChange={(e) => {
|
||||
const nextValue = schema.type === 'integer'
|
||||
? parseInt(e.target.value, 10)
|
||||
: parseFloat(e.target.value)
|
||||
onChange(Number.isFinite(nextValue) ? nextValue : 0)
|
||||
}}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
@@ -262,7 +293,12 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
* 渲染 Input[type="text"] 组件(用于 string 类型)
|
||||
*/
|
||||
const renderTextInput = (type: 'password' | 'text' = 'text') => {
|
||||
const strValue = typeof value === 'string' ? value : (schema.default as string ?? '')
|
||||
const strValue =
|
||||
typeof value === 'string'
|
||||
? value
|
||||
: value === null || value === undefined
|
||||
? String(schema.default ?? '')
|
||||
: String(value)
|
||||
return (
|
||||
<Input
|
||||
type={type}
|
||||
@@ -277,11 +313,19 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
*/
|
||||
const renderTextarea = () => {
|
||||
const strValue = typeof value === 'string' ? value : (schema.default as string ?? '')
|
||||
const minHeight = typeof schema['x-textarea-min-height'] === 'number'
|
||||
? schema['x-textarea-min-height']
|
||||
: undefined
|
||||
const rows = typeof schema['x-textarea-rows'] === 'number'
|
||||
? schema['x-textarea-rows']
|
||||
: 4
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
value={strValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={4}
|
||||
rows={rows}
|
||||
minHeight={minHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -303,7 +347,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
|
||||
return (
|
||||
<Select value={strValue} onValueChange={(val) => onChange(val)}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger title={selectHoverDescription}>
|
||||
<SelectValue placeholder={`Select ${schema.label}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -100,6 +100,42 @@ describe('DynamicField', () => {
|
||||
|
||||
expect(screen.getByText('Custom field requires Hook')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders number Input when x-widget is input but type is integer', () => {
|
||||
const schema: FieldSchema = {
|
||||
name: 'test_integer_input_widget',
|
||||
type: 'integer',
|
||||
label: 'Test Integer Input Widget',
|
||||
description: 'A numeric field rendered as input',
|
||||
required: false,
|
||||
'x-widget': 'input',
|
||||
default: 0,
|
||||
}
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(<DynamicField schema={schema} value={2} onChange={onChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).toHaveValue(2)
|
||||
})
|
||||
|
||||
it('parses string values for numeric input widgets', () => {
|
||||
const schema: FieldSchema = {
|
||||
name: 'test_string_number_input_widget',
|
||||
type: 'integer',
|
||||
label: 'Test String Number Input Widget',
|
||||
description: 'A numeric field with legacy string value',
|
||||
required: false,
|
||||
'x-widget': 'input',
|
||||
default: 0,
|
||||
}
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(<DynamicField schema={schema} value="2" onChange={onChange} />)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('type fallback', () => {
|
||||
@@ -305,6 +341,27 @@ describe('DynamicField', () => {
|
||||
await user.type(input, '123')
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('triggers numeric onChange for input widget with integer type', async () => {
|
||||
const schema: FieldSchema = {
|
||||
name: 'test_integer_input_widget_change',
|
||||
type: 'integer',
|
||||
label: 'Test Integer Input Widget Change',
|
||||
description: 'A numeric field rendered as input',
|
||||
required: false,
|
||||
'x-widget': 'input',
|
||||
default: 0,
|
||||
}
|
||||
const onChange = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<DynamicField schema={schema} value={0} onChange={onChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
await user.clear(input)
|
||||
await user.type(input, '5')
|
||||
expect(onChange).toHaveBeenLastCalledWith(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('visual features', () => {
|
||||
@@ -377,6 +434,25 @@ describe('DynamicField', () => {
|
||||
expect(screen.getByText('50')).toBeInTheDocument()
|
||||
expect(screen.getByText('25')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('parses string values for slider widgets', () => {
|
||||
const schema: FieldSchema = {
|
||||
name: 'test_slider_string_value',
|
||||
type: 'number',
|
||||
label: 'Test Slider String Value',
|
||||
description: 'A slider with legacy string value',
|
||||
required: false,
|
||||
'x-widget': 'slider',
|
||||
minValue: 0,
|
||||
maxValue: 10,
|
||||
default: 0,
|
||||
}
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(<DynamicField schema={schema} value="2.5" onChange={onChange} />)
|
||||
|
||||
expect(screen.getByText('2.5')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('select features', () => {
|
||||
|
||||
@@ -552,8 +552,8 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
|
||||
}
|
||||
|
||||
// 获取聊天名称
|
||||
const getChatName = (chatId: string): string => {
|
||||
return chatNameMap.get(chatId) || chatId
|
||||
const getChatName = (expression: Expression): string => {
|
||||
return expression.chat_name || chatNameMap.get(expression.chat_id) || expression.chat_id
|
||||
}
|
||||
|
||||
// 单条审核
|
||||
@@ -1104,8 +1104,8 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
|
||||
<div className="flex flex-wrap items-center gap-1 sm:gap-2 text-xs text-muted-foreground">
|
||||
<span>#{expr.id}</span>
|
||||
<span>·</span>
|
||||
<span title={getChatName(expr.chat_id)} className="truncate max-w-24 sm:max-w-32">
|
||||
{getChatName(expr.chat_id)}
|
||||
<span title={getChatName(expr)} className="truncate max-w-24 sm:max-w-32">
|
||||
{getChatName(expr)}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{formatTime(expr.create_date)}</span>
|
||||
@@ -1585,8 +1585,8 @@ if (isCurrent) {
|
||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||
<User className="h-3 w-3" />
|
||||
</div>
|
||||
<span title={getChatName(expr.chat_id)} className="truncate max-w-[120px] font-medium">
|
||||
{getChatName(expr.chat_id)}
|
||||
<span title={getChatName(expr)} className="truncate max-w-[120px] font-medium">
|
||||
{getChatName(expr)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono">{formatTime(expr.create_date)}</span>
|
||||
@@ -1638,8 +1638,8 @@ if (isCurrent) {
|
||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||
<User className="h-3 w-3" />
|
||||
</div>
|
||||
<span title={getChatName(expr.chat_id)} className="truncate max-w-[120px] font-medium">
|
||||
{getChatName(expr.chat_id)}
|
||||
<span title={getChatName(expr)} className="truncate max-w-[120px] font-medium">
|
||||
{getChatName(expr)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono">{formatTime(expr.create_date)}</span>
|
||||
|
||||
@@ -29,9 +29,9 @@
|
||||
"modelManagement": "模型管理与分配",
|
||||
"promptManagement": "Prompt 管理",
|
||||
"adapterConfig": "麦麦适配器配置",
|
||||
"emojiManagement": "表情包管理",
|
||||
"expressionManagement": "表达方式管理",
|
||||
"slangManagement": "黑话管理",
|
||||
"emojiManagement": "表情包",
|
||||
"expressionManagement": "表达方式",
|
||||
"slangManagement": "黑话",
|
||||
"personInfo": "人物信息管理",
|
||||
"knowledgeGraph": "长期记忆图谱",
|
||||
"knowledgeBase": "长期记忆",
|
||||
@@ -779,13 +779,13 @@
|
||||
"modelProviderDesc": "配置模型提供商",
|
||||
"model": "麦麦模型配置",
|
||||
"modelDesc": "配置模型参数",
|
||||
"emoji": "表情包管理",
|
||||
"emoji": "表情包",
|
||||
"emojiDesc": "管理麦麦的表情包",
|
||||
"expression": "表达方式管理",
|
||||
"expression": "表达方式",
|
||||
"expressionDesc": "管理麦麦的表达方式",
|
||||
"person": "人物信息管理",
|
||||
"personDesc": "管理人物信息",
|
||||
"jargon": "黑话管理",
|
||||
"jargon": "黑话",
|
||||
"jargonDesc": "管理麦麦学习到的黑话和俚语",
|
||||
"statistics": "统计信息",
|
||||
"statisticsDesc": "查看使用统计",
|
||||
|
||||
@@ -13,7 +13,7 @@ const API_BASE = '/api/webui/config'
|
||||
* 获取麦麦主程序配置架构
|
||||
*/
|
||||
export async function getBotConfigSchema(): Promise<ApiResponse<ConfigSchema>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/schema/bot`)
|
||||
const response = await fetchWithAuth(`${API_BASE}/schema/bot`, { cache: 'no-store' })
|
||||
return parseResponse<ConfigSchema>(response)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function getBotConfigSchema(): Promise<ApiResponse<ConfigSchema>> {
|
||||
* 获取模型配置架构
|
||||
*/
|
||||
export async function getModelConfigSchema(): Promise<ApiResponse<ConfigSchema>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/schema/model`)
|
||||
const response = await fetchWithAuth(`${API_BASE}/schema/model`, { cache: 'no-store' })
|
||||
return parseResponse<ConfigSchema>(response)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function getModelConfigSchema(): Promise<ApiResponse<ConfigSchema>>
|
||||
* 获取指定配置节的架构
|
||||
*/
|
||||
export async function getConfigSectionSchema(sectionName: string): Promise<ApiResponse<ConfigSchema>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/schema/section/${sectionName}`)
|
||||
const response = await fetchWithAuth(`${API_BASE}/schema/section/${sectionName}`, { cache: 'no-store' })
|
||||
return parseResponse<ConfigSchema>(response)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function getConfigSectionSchema(sectionName: string): Promise<ApiRe
|
||||
* 获取麦麦主程序配置数据
|
||||
*/
|
||||
export async function getBotConfig(): Promise<ApiResponse<Record<string, unknown>>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/bot`)
|
||||
const response = await fetchWithAuth(`${API_BASE}/bot`, { cache: 'no-store' })
|
||||
return parseResponse<Record<string, unknown>>(response)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function getBotConfig(): Promise<ApiResponse<Record<string, unknown
|
||||
* 获取模型配置数据
|
||||
*/
|
||||
export async function getModelConfig(): Promise<ApiResponse<Record<string, unknown>>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/model`)
|
||||
const response = await fetchWithAuth(`${API_BASE}/model`, { cache: 'no-store' })
|
||||
return parseResponse<Record<string, unknown>>(response)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function updateBotConfig(
|
||||
* 获取麦麦主程序配置的原始 TOML 内容
|
||||
*/
|
||||
export async function getBotConfigRaw(): Promise<ApiResponse<string>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/bot/raw`)
|
||||
const response = await fetchWithAuth(`${API_BASE}/bot/raw`, { cache: 'no-store' })
|
||||
return parseResponse<string>(response)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,10 +24,11 @@ import { getBotConfig, getBotConfigRaw, getBotConfigSchema, updateBotConfig, upd
|
||||
import { fieldHooks } from '@/lib/field-hooks'
|
||||
import { RestartProvider, useRestart } from '@/lib/restart-context'
|
||||
|
||||
import { Code2, Info, Layout, Power, Save } from 'lucide-react'
|
||||
import { ChevronDown, ChevronUp, Code2, Info, Layout, Power, RefreshCw, Save } from 'lucide-react'
|
||||
|
||||
import type { ConfigSchema } from '@/types/config-schema'
|
||||
import {
|
||||
ChatPromptsHook,
|
||||
ChatTalkValueRulesHook,
|
||||
ExpressionGroupsHook,
|
||||
ExpressionLearningListHook,
|
||||
@@ -50,13 +51,27 @@ const TAB_ORDER = [
|
||||
'personality',
|
||||
'chat',
|
||||
'expression',
|
||||
'visual',
|
||||
'a_memorix',
|
||||
'message_receive',
|
||||
'emoji',
|
||||
'voice',
|
||||
'response_post_process',
|
||||
'webui',
|
||||
'plugin_runtime',
|
||||
'log',
|
||||
]
|
||||
|
||||
/** 默认展示的主配置栏目 */
|
||||
const DEFAULT_VISIBLE_TAB_IDS = new Set([
|
||||
'bot',
|
||||
'personality',
|
||||
'chat',
|
||||
'expression',
|
||||
'visual',
|
||||
'a_memorix',
|
||||
])
|
||||
|
||||
// ==================== Tab 分组类型与构建 ====================
|
||||
interface TabGroup {
|
||||
id: string
|
||||
@@ -143,6 +158,9 @@ function BotConfigPageContent() {
|
||||
const [sourceCode, setSourceCode] = useState<string>('')
|
||||
const [hasTomlError, setHasTomlError] = useState(false)
|
||||
const [tomlErrorMessage, setTomlErrorMessage] = useState<string>('')
|
||||
const [restartNoticeVisible, setRestartNoticeVisible] = useState(
|
||||
() => localStorage.getItem('bot-config-restart-notice-dismissed') !== 'true'
|
||||
)
|
||||
const { toast } = useToast()
|
||||
const { triggerRestart, isRestarting } = useRestart()
|
||||
|
||||
@@ -160,6 +178,7 @@ function BotConfigPageContent() {
|
||||
const [responsePostProcessConfig, setResponsePostProcessConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [chineseTypoConfig, setChineseTypoConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [responseSplitterConfig, setResponseSplitterConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [logConfig, setLogConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [debugConfig, setDebugConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [maimMessageConfig, setMaimMessageConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [telemetryConfig, setTelemetryConfig] = useState<ConfigSectionData | null>(null)
|
||||
@@ -174,6 +193,7 @@ function BotConfigPageContent() {
|
||||
|
||||
// 用于标记初始加载和配置缓存
|
||||
const initialLoadRef = useRef(true)
|
||||
const suppressAutoSaveRef = useRef(false)
|
||||
const configRef = useRef<Record<string, unknown>>({})
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
@@ -240,6 +260,7 @@ function BotConfigPageContent() {
|
||||
* 抽取自 loadConfig 和 handleModeChange 中的重复逻辑
|
||||
*/
|
||||
const parseAndSetConfig = useCallback((config: Record<string, unknown>) => {
|
||||
suppressAutoSaveRef.current = true
|
||||
configRef.current = config
|
||||
|
||||
setBotConfig((config.bot ?? {}) as ConfigSectionData)
|
||||
@@ -255,6 +276,7 @@ function BotConfigPageContent() {
|
||||
setResponsePostProcessConfig((config.response_post_process ?? {}) as ConfigSectionData)
|
||||
setChineseTypoConfig((config.chinese_typo ?? {}) as ConfigSectionData)
|
||||
setResponseSplitterConfig((config.response_splitter ?? {}) as ConfigSectionData)
|
||||
setLogConfig((config.log ?? {}) as ConfigSectionData)
|
||||
setDebugConfig((config.debug ?? {}) as ConfigSectionData)
|
||||
setMaimMessageConfig((config.maim_message ?? {}) as ConfigSectionData)
|
||||
setTelemetryConfig((config.telemetry ?? {}) as ConfigSectionData)
|
||||
@@ -263,6 +285,10 @@ function BotConfigPageContent() {
|
||||
setMcpConfig((config.mcp ?? {}) as ConfigSectionData)
|
||||
setPluginRuntimeConfig((config.plugin_runtime ?? {}) as ConfigSectionData)
|
||||
setAMemorixConfig((config.a_memorix ?? {}) as ConfigSectionData)
|
||||
|
||||
window.setTimeout(() => {
|
||||
suppressAutoSaveRef.current = false
|
||||
}, 0)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
@@ -285,6 +311,7 @@ function BotConfigPageContent() {
|
||||
response_post_process: responsePostProcessConfig,
|
||||
chinese_typo: chineseTypoConfig,
|
||||
response_splitter: responseSplitterConfig,
|
||||
log: logConfig,
|
||||
debug: debugConfig,
|
||||
maim_message: maimMessageConfig,
|
||||
telemetry: telemetryConfig,
|
||||
@@ -308,6 +335,7 @@ function BotConfigPageContent() {
|
||||
responsePostProcessConfig,
|
||||
chineseTypoConfig,
|
||||
responseSplitterConfig,
|
||||
logConfig,
|
||||
debugConfig,
|
||||
maimMessageConfig,
|
||||
telemetryConfig,
|
||||
@@ -394,6 +422,7 @@ function BotConfigPageContent() {
|
||||
|
||||
useEffect(() => {
|
||||
const hookEntries = [
|
||||
['chat.chat_prompts', ChatPromptsHook],
|
||||
['chat.talk_value_rules', ChatTalkValueRulesHook],
|
||||
['expression.expression_groups', ExpressionGroupsHook],
|
||||
['expression.learning_list', ExpressionLearningListHook],
|
||||
@@ -421,30 +450,41 @@ function BotConfigPageContent() {
|
||||
setHasUnsavedChanges
|
||||
)
|
||||
|
||||
const triggerConfigAutoSave = useCallback(
|
||||
(sectionName: Parameters<typeof triggerAutoSave>[0], data: unknown) => {
|
||||
if (suppressAutoSaveRef.current) {
|
||||
return
|
||||
}
|
||||
triggerAutoSave(sectionName, data)
|
||||
},
|
||||
[triggerAutoSave]
|
||||
)
|
||||
|
||||
// 使用 useConfigAutoSave hook 简化配置变化监听
|
||||
// 注意: useConfigAutoSave 是一个 hook,不能在条件语句或循环中调用
|
||||
// 因此我们仍然需要逐个调用,但代码更简洁
|
||||
useConfigAutoSave(botConfig, 'bot', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(personalityConfig, 'personality', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(chatConfig, 'chat', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(expressionConfig, 'expression', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(emojiConfig, 'emoji', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(memoryConfig, 'memory', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(visualConfig, 'visual', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(voiceConfig, 'voice', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(messageReceiveConfig, 'message_receive', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(keywordReactionConfig, 'keyword_reaction', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(responsePostProcessConfig, 'response_post_process', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(chineseTypoConfig, 'chinese_typo', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(responseSplitterConfig, 'response_splitter', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(debugConfig, 'debug', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(maimMessageConfig, 'maim_message', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(telemetryConfig, 'telemetry', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(webuiConfig, 'webui', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(databaseConfig, 'database', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(mcpConfig, 'mcp', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(pluginRuntimeConfig, 'plugin_runtime', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(aMemorixConfig, 'a_memorix', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(botConfig, 'bot', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(personalityConfig, 'personality', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(chatConfig, 'chat', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(expressionConfig, 'expression', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(emojiConfig, 'emoji', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(memoryConfig, 'memory', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(visualConfig, 'visual', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(voiceConfig, 'voice', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(messageReceiveConfig, 'message_receive', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(keywordReactionConfig, 'keyword_reaction', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(responsePostProcessConfig, 'response_post_process', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(chineseTypoConfig, 'chinese_typo', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(responseSplitterConfig, 'response_splitter', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(logConfig, 'log', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(debugConfig, 'debug', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(maimMessageConfig, 'maim_message', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(telemetryConfig, 'telemetry', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(webuiConfig, 'webui', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(databaseConfig, 'database', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(mcpConfig, 'mcp', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(pluginRuntimeConfig, 'plugin_runtime', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(aMemorixConfig, 'a_memorix', initialLoadRef.current, triggerConfigAutoSave)
|
||||
|
||||
// 保存源代码
|
||||
const saveSourceCode = async () => {
|
||||
@@ -592,6 +632,21 @@ function BotConfigPageContent() {
|
||||
await triggerRestart()
|
||||
}
|
||||
|
||||
const dismissRestartNotice = () => {
|
||||
localStorage.setItem('bot-config-restart-notice-dismissed', 'true')
|
||||
setRestartNoticeVisible(false)
|
||||
}
|
||||
|
||||
const handleReloadFromFile = async () => {
|
||||
cancelPendingAutoSave()
|
||||
await loadConfig()
|
||||
setHasUnsavedChanges(false)
|
||||
toast({
|
||||
title: '已刷新',
|
||||
description: '已从 bot_config.toml 重新读取配置',
|
||||
})
|
||||
}
|
||||
|
||||
// 保存并重启
|
||||
const handleSaveAndRestart = async () => {
|
||||
try {
|
||||
@@ -650,6 +705,7 @@ function BotConfigPageContent() {
|
||||
response_post_process: responsePostProcessConfig,
|
||||
chinese_typo: chineseTypoConfig,
|
||||
response_splitter: responseSplitterConfig,
|
||||
log: logConfig,
|
||||
debug: debugConfig,
|
||||
maim_message: maimMessageConfig,
|
||||
telemetry: telemetryConfig,
|
||||
@@ -673,6 +729,7 @@ function BotConfigPageContent() {
|
||||
responsePostProcessConfig,
|
||||
chineseTypoConfig,
|
||||
responseSplitterConfig,
|
||||
logConfig,
|
||||
debugConfig,
|
||||
maimMessageConfig,
|
||||
telemetryConfig,
|
||||
@@ -699,6 +756,7 @@ function BotConfigPageContent() {
|
||||
response_post_process: setResponsePostProcessConfig,
|
||||
chinese_typo: setChineseTypoConfig,
|
||||
response_splitter: setResponseSplitterConfig,
|
||||
log: setLogConfig,
|
||||
debug: setDebugConfig,
|
||||
maim_message: setMaimMessageConfig,
|
||||
telemetry: setTelemetryConfig,
|
||||
@@ -736,6 +794,16 @@ function BotConfigPageContent() {
|
||||
</div>
|
||||
{/* 按钮组 - 桌面端靠右 */}
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<Button
|
||||
onClick={handleReloadFromFile}
|
||||
disabled={saving || autoSaving || isRestarting}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-20 sm:w-24"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
onClick={editMode === 'visual' ? saveConfig : saveSourceCode}
|
||||
disabled={saving || autoSaving || !hasUnsavedChanges || isRestarting}
|
||||
@@ -804,12 +872,19 @@ function BotConfigPageContent() {
|
||||
</div>
|
||||
|
||||
{/* 重启提示 */}
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
配置更新后需要<strong>重启麦麦</strong>才能生效。你可以点击右上角的"保存并重启"按钮一键完成保存和重启。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{restartNoticeVisible && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>
|
||||
配置更新后需要<strong>重启麦麦</strong>才能生效。你可以点击右上角的"保存并重启"按钮一键完成保存和重启。
|
||||
</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={dismissRestartNotice}>
|
||||
我知道了
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 源代码模式 */}
|
||||
{editMode === 'source' && (
|
||||
@@ -903,11 +978,34 @@ interface DynamicConfigTabsProps {
|
||||
|
||||
function DynamicConfigTabs(props: DynamicConfigTabsProps) {
|
||||
const { configSchema, sectionValues, setHasUnsavedChanges, setSectionValue, tabGroups } = props
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState(tabGroups[0]?.id ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
if (!tabGroups.some((tab) => tab.id === activeTab)) {
|
||||
setActiveTab(tabGroups[0]?.id ?? '')
|
||||
}
|
||||
}, [activeTab, tabGroups])
|
||||
|
||||
if (tabGroups.length === 0 || !configSchema?.nested) {
|
||||
return null
|
||||
}
|
||||
|
||||
const visibleTabGroups = expanded
|
||||
? tabGroups
|
||||
: tabGroups.filter((tab) => DEFAULT_VISIBLE_TAB_IDS.has(tab.id))
|
||||
const hasCollapsibleTabs = tabGroups.some((tab) => !DEFAULT_VISIBLE_TAB_IDS.has(tab.id))
|
||||
|
||||
const toggleExpanded = () => {
|
||||
setExpanded((current) => {
|
||||
if (current && !DEFAULT_VISIBLE_TAB_IDS.has(activeTab)) {
|
||||
const firstDefaultTab = tabGroups.find((tab) => DEFAULT_VISIBLE_TAB_IDS.has(tab.id))
|
||||
setActiveTab(firstDefaultTab?.id ?? tabGroups[0]?.id ?? '')
|
||||
}
|
||||
return !current
|
||||
})
|
||||
}
|
||||
|
||||
const renderTabContent = (tab: TabGroup) => {
|
||||
const tabNestedEntries = tab.sections
|
||||
.map((sectionName) => [sectionName, configSchema.nested?.[sectionName]] as const)
|
||||
@@ -953,9 +1051,9 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultValue={tabGroups[0].id} className="w-full">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="flex flex-wrap h-auto gap-1 p-1">
|
||||
{tabGroups.map((tab) => (
|
||||
{visibleTabGroups.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
@@ -964,6 +1062,22 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
{hasCollapsibleTabs && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs sm:h-9 sm:px-3"
|
||||
onClick={toggleExpanded}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="mr-1 h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{expanded ? '收起' : '更多'}
|
||||
</Button>
|
||||
)}
|
||||
</TabsList>
|
||||
{tabGroups.map((tab) => (
|
||||
<TabsContent key={tab.id} value={tab.id} className="space-y-4">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useCallback, useMemo, type CSSProperties } from 'react'
|
||||
import * as LucideIcons from 'lucide-react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
|
||||
@@ -31,6 +31,12 @@ export interface ListItemEditorOptions {
|
||||
emptyText?: string
|
||||
/** 顶部图标(覆盖 schema 自带的 x-icon) */
|
||||
iconName?: string
|
||||
/** 紧凑布局:把指定字段放在同一行展示 */
|
||||
fieldRows?: string[][]
|
||||
/** Hook-local field UI metadata overrides */
|
||||
fieldSchemaOverrides?: Record<string, Partial<FieldSchema>>
|
||||
/** 添加按钮位置 */
|
||||
addButtonPlacement?: 'top' | 'bottom'
|
||||
}
|
||||
|
||||
function resolveLabel(schema?: ConfigSchema | FieldSchema, fieldPath?: string): string {
|
||||
@@ -190,9 +196,105 @@ export function createListItemEditorHook(
|
||||
[items, onChange],
|
||||
)
|
||||
|
||||
const renderItemEditor = (item: Record<string, unknown>, index: number) => {
|
||||
if (!nestedSchema) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!options.fieldRows?.length) {
|
||||
return (
|
||||
<DynamicConfigForm
|
||||
schema={nestedSchema}
|
||||
values={item}
|
||||
onChange={(field, fieldValue) =>
|
||||
handleItemFieldChange(index, field, fieldValue)
|
||||
}
|
||||
basePath=""
|
||||
level={1}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const applyFieldOverride = (field: FieldSchema): FieldSchema => ({
|
||||
...field,
|
||||
...(options.fieldSchemaOverrides?.[field.name] ?? {}),
|
||||
})
|
||||
const fieldMap = new Map(
|
||||
nestedSchema.fields.map((field) => [field.name, applyFieldOverride(field)]),
|
||||
)
|
||||
const rowFieldNames = new Set(options.fieldRows.flat())
|
||||
const remainingFields = nestedSchema.fields
|
||||
.filter((field) => !rowFieldNames.has(field.name))
|
||||
.map(applyFieldOverride)
|
||||
const buildRowSchema = (fields: FieldSchema[]): ConfigSchema => ({
|
||||
...nestedSchema,
|
||||
fields,
|
||||
nested: undefined,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{options.fieldRows.map((row, rowIndex) => {
|
||||
const fields = row
|
||||
.map((fieldName) => fieldMap.get(fieldName))
|
||||
.filter((field): field is FieldSchema => Boolean(field))
|
||||
|
||||
if (fields.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={rowIndex}
|
||||
className="grid gap-3 md:grid-cols-[repeat(var(--field-count),minmax(0,1fr))]"
|
||||
style={{ '--field-count': fields.length } as CSSProperties}
|
||||
>
|
||||
{fields.map((field) => (
|
||||
<DynamicConfigForm
|
||||
key={field.name}
|
||||
schema={buildRowSchema([field])}
|
||||
values={item}
|
||||
onChange={(fieldName, fieldValue) =>
|
||||
handleItemFieldChange(index, fieldName, fieldValue)
|
||||
}
|
||||
basePath=""
|
||||
level={1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{remainingFields.length > 0 && (
|
||||
<DynamicConfigForm
|
||||
schema={buildRowSchema(remainingFields)}
|
||||
values={item}
|
||||
onChange={(field, fieldValue) =>
|
||||
handleItemFieldChange(index, field, fieldValue)
|
||||
}
|
||||
basePath=""
|
||||
level={1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const label = resolveLabel(schema, fieldPath)
|
||||
const description = resolveDescription(schema)
|
||||
const iconName = resolveIconName(options.iconName, schema, nestedSchema)
|
||||
const addButtonPlacement = options.addButtonPlacement ?? 'bottom'
|
||||
const addButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{options.addLabel ?? '添加一项'}
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (!nestedSchema) {
|
||||
return (
|
||||
@@ -220,6 +322,7 @@ export function createListItemEditorHook(
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{addButtonPlacement === 'top' && addButton}
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-6 text-center text-sm text-muted-foreground">
|
||||
{options.emptyText ?? '尚未添加任何条目,点击下方按钮新增。'}
|
||||
@@ -251,29 +354,12 @@ export function createListItemEditorHook(
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
<DynamicConfigForm
|
||||
schema={nestedSchema}
|
||||
values={item}
|
||||
onChange={(field, fieldValue) =>
|
||||
handleItemFieldChange(index, field, fieldValue)
|
||||
}
|
||||
basePath=""
|
||||
level={1}
|
||||
/>
|
||||
{renderItemEditor(item, index)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{options.addLabel ?? '添加一项'}
|
||||
</Button>
|
||||
{addButtonPlacement === 'bottom' && addButton}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -30,8 +30,13 @@ const collectStringList = (value: unknown): string[] => {
|
||||
|
||||
export const ChatTalkValueRulesHook = createListItemEditorHook({
|
||||
addLabel: '添加发言频率规则',
|
||||
addButtonPlacement: 'top',
|
||||
helperText: '可按平台/聊天流/时段分别配置发言频率,留空表示全局。',
|
||||
emptyText: '尚未配置任何规则,将使用全局默认频率。',
|
||||
fieldRows: [
|
||||
['platform', 'item_id', 'rule_type'],
|
||||
['time', 'value'],
|
||||
],
|
||||
itemTitle: (item) => {
|
||||
const time =
|
||||
typeof item.time === 'string' && item.time.trim()
|
||||
@@ -43,10 +48,45 @@ export const ChatTalkValueRulesHook = createListItemEditorHook({
|
||||
},
|
||||
})
|
||||
|
||||
export const ChatPromptsHook = createListItemEditorHook({
|
||||
addLabel: '添加额外 Prompt',
|
||||
helperText: '为指定平台和聊天流添加额外提示。platform、item_id 和 prompt 同时留空时表示空条目;填写任意一项后这三项都需要填写。',
|
||||
emptyText: '尚未配置任何聊天额外 Prompt。',
|
||||
addButtonPlacement: 'top',
|
||||
fieldRows: [['platform', 'item_id', 'rule_type']],
|
||||
fieldSchemaOverrides: {
|
||||
item_id: {
|
||||
'x-input-width': '8rem',
|
||||
'x-layout': 'inline-right',
|
||||
},
|
||||
platform: {
|
||||
'x-input-width': '8rem',
|
||||
'x-layout': 'inline-right',
|
||||
},
|
||||
prompt: {
|
||||
'x-textarea-min-height': 38,
|
||||
'x-textarea-rows': 1,
|
||||
},
|
||||
rule_type: {
|
||||
'x-input-width': '8rem',
|
||||
'x-layout': 'inline-right',
|
||||
},
|
||||
},
|
||||
iconName: 'file-text',
|
||||
itemTitle: (item) => {
|
||||
const prompt = typeof item.prompt === 'string' ? item.prompt.trim() : ''
|
||||
return `${platformLabel(item)} · ${ruleTypeLabel(item.rule_type)} · ${prompt ? truncate(prompt) : '未填写 Prompt'}`
|
||||
},
|
||||
})
|
||||
|
||||
export const ExpressionLearningListHook = createListItemEditorHook({
|
||||
addLabel: '添加表达学习规则',
|
||||
helperText: '为不同聊天流单独配置是否启用表达/jargon 学习。',
|
||||
emptyText: '尚未配置任何学习规则。',
|
||||
fieldRows: [
|
||||
['platform', 'item_id', 'rule_type'],
|
||||
['use_expression', 'enable_learning', 'enable_jargon_learning'],
|
||||
],
|
||||
itemTitle: (item) => {
|
||||
const flags: string[] = []
|
||||
if (item.use_expression) flags.push('表达')
|
||||
|
||||
@@ -11,6 +11,7 @@ export type {
|
||||
UseAutoSaveReturnGeneric,
|
||||
} from './useAutoSave'
|
||||
export {
|
||||
ChatPromptsHook,
|
||||
ChatTalkValueRulesHook,
|
||||
ExpressionGroupsHook,
|
||||
ExpressionLearningListHook,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef, type MouseEvent } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -106,7 +106,14 @@ function ModelConfigPageContent() {
|
||||
const [jumpToPage, setJumpToPage] = useState('')
|
||||
|
||||
const [advancedTemperatureMode, setAdvancedTemperatureMode] = useState(false)
|
||||
const [advancedModelSettingsVisible, setAdvancedModelSettingsVisible] = useState(false)
|
||||
const [advancedTaskSettingsVisible, setAdvancedTaskSettingsVisible] = useState(false)
|
||||
const [restartNoticeVisible, setRestartNoticeVisible] = useState(
|
||||
() => localStorage.getItem('model-config-restart-notice-dismissed') !== 'true'
|
||||
)
|
||||
const [tourEntryVisible, setTourEntryVisible] = useState(
|
||||
() => localStorage.getItem('model-assignment-tour-entry-dismissed') !== 'true'
|
||||
)
|
||||
|
||||
// 模型 Combobox 状态
|
||||
const [modelComboboxOpen, setModelComboboxOpen] = useState(false)
|
||||
@@ -130,11 +137,6 @@ function ModelConfigPageContent() {
|
||||
const { toast } = useToast()
|
||||
const { triggerRestart, isRestarting } = useRestart()
|
||||
|
||||
// Tour 引导 (使用 hook 封装的逻辑)
|
||||
const { startTour: handleStartTour, isRunning: tourIsRunning } = useModelTour({
|
||||
onCloseEditDialog: () => setEditDialogOpen(false),
|
||||
})
|
||||
|
||||
// 自动保存 (使用 hook 封装的逻辑)
|
||||
const { clearTimers: clearAutoSaveTimers, initialLoadRef } = useModelAutoSave({
|
||||
models,
|
||||
@@ -251,6 +253,17 @@ function ModelConfigPageContent() {
|
||||
const handleRestart = async () => {
|
||||
await triggerRestart()
|
||||
}
|
||||
|
||||
const dismissRestartNotice = () => {
|
||||
localStorage.setItem('model-config-restart-notice-dismissed', 'true')
|
||||
setRestartNoticeVisible(false)
|
||||
}
|
||||
|
||||
const dismissTourEntry = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
localStorage.setItem('model-assignment-tour-entry-dismissed', 'true')
|
||||
setTourEntryVisible(false)
|
||||
}
|
||||
|
||||
// 一键删除所有无效模型引用
|
||||
const handleRemoveInvalidRefs = useCallback(() => {
|
||||
@@ -285,6 +298,9 @@ function ModelConfigPageContent() {
|
||||
api_provider: model.api_provider,
|
||||
price_in: model.price_in ?? 0,
|
||||
price_out: model.price_out ?? 0,
|
||||
cache: model.cache ?? false,
|
||||
cache_price_in: model.cache_price_in ?? 0,
|
||||
visual: model.visual ?? false,
|
||||
force_stream_mode: model.force_stream_mode ?? false,
|
||||
extra_params: model.extra_params ?? {},
|
||||
}
|
||||
@@ -406,16 +422,26 @@ function ModelConfigPageContent() {
|
||||
api_provider: providers[0] || '',
|
||||
price_in: 0,
|
||||
price_out: 0,
|
||||
cache: false,
|
||||
cache_price_in: 0,
|
||||
temperature: null,
|
||||
max_tokens: null,
|
||||
visual: false,
|
||||
force_stream_mode: false,
|
||||
extra_params: {},
|
||||
}
|
||||
)
|
||||
setAdvancedModelSettingsVisible(false)
|
||||
setEditingIndex(index)
|
||||
setEditDialogOpen(true)
|
||||
}
|
||||
|
||||
// Tour 引导 (使用 hook 封装的逻辑)
|
||||
const { startTour: handleStartTour, isRunning: tourIsRunning } = useModelTour({
|
||||
onOpenEditDialog: () => openEditDialog(null, null),
|
||||
onCloseEditDialog: () => setEditDialogOpen(false),
|
||||
})
|
||||
|
||||
// 保存编辑
|
||||
const handleSaveEdit = () => {
|
||||
if (!editingModel) return
|
||||
@@ -459,6 +485,9 @@ function ModelConfigPageContent() {
|
||||
api_provider: editingModel.api_provider,
|
||||
price_in: editingModel.price_in ?? 0,
|
||||
price_out: editingModel.price_out ?? 0,
|
||||
cache: editingModel.cache ?? false,
|
||||
cache_price_in: editingModel.cache_price_in ?? 0,
|
||||
visual: editingModel.visual ?? false,
|
||||
force_stream_mode: editingModel.force_stream_mode ?? false,
|
||||
extra_params: editingModel.extra_params ?? {},
|
||||
}
|
||||
@@ -792,12 +821,19 @@ function ModelConfigPageContent() {
|
||||
</div>
|
||||
|
||||
{/* 重启提示 */}
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
配置更新后需要<strong>重启麦麦</strong>才能生效。你可以点击右上角的"保存并重启"按钮一键完成保存和重启。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{restartNoticeVisible && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>
|
||||
配置更新后需要<strong>重启麦麦</strong>才能生效。你可以点击右上角的"保存并重启"按钮一键完成保存和重启。
|
||||
</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={dismissRestartNotice}>
|
||||
我知道了
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 无效模型引用警告 */}
|
||||
{invalidModelRefs.length > 0 && (
|
||||
@@ -841,23 +877,30 @@ function ModelConfigPageContent() {
|
||||
|
||||
|
||||
{/* 新手引导入口 - 仅在桌面端显示,移动端隐藏 */}
|
||||
{tourEntryVisible && (
|
||||
<Alert className="hidden lg:flex border-primary/30 bg-primary/5 cursor-pointer hover:bg-primary/10 transition-colors" onClick={handleStartTour}>
|
||||
<GraduationCap className="h-4 w-4 text-primary" />
|
||||
<AlertDescription className="flex items-center justify-between">
|
||||
<span>
|
||||
<strong className="text-primary">新手引导:</strong>不知道如何配置模型?点击这里开始学习如何为麦麦的组件分配模型。
|
||||
</span>
|
||||
<Button variant="outline" size="sm" className="ml-4 shrink-0">
|
||||
<div className="ml-4 flex shrink-0 items-center gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
开始引导
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={dismissTourEntry}>
|
||||
鍏抽棴
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 标签页 */}
|
||||
<Tabs defaultValue="models" className="w-full">
|
||||
<TabsList className="grid w-full max-w-full sm:max-w-md grid-cols-2">
|
||||
<TabsTrigger value="models">添加模型</TabsTrigger>
|
||||
<TabsTrigger value="tasks" data-tour="tasks-tab-trigger">为模型分配功能</TabsTrigger>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="models" className="w-full">添加模型</TabsTrigger>
|
||||
<TabsTrigger value="tasks" className="w-full" data-tour="tasks-tab-trigger">为模型分配功能</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* 模型配置标签页 */}
|
||||
<TabsContent value="models" className="space-y-4 mt-0">
|
||||
@@ -976,6 +1019,7 @@ function ModelConfigPageContent() {
|
||||
modelNames={modelNames}
|
||||
onChange={(f, value) => updateTaskConfig(field.name, f, value)}
|
||||
advanced={field.advanced}
|
||||
showAdvancedSettings={advancedTaskSettingsVisible}
|
||||
{...(index === 0 ? { dataTour: 'task-model-select' } : {})}
|
||||
/>
|
||||
)
|
||||
@@ -997,64 +1041,89 @@ function ModelConfigPageContent() {
|
||||
<DialogTitle>
|
||||
{editingIndex !== null ? '编辑模型' : '添加模型'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>配置模型的基本信息和参数</DialogDescription>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<DialogDescription>配置模型的基本信息和参数</DialogDescription>
|
||||
<Button
|
||||
type="button"
|
||||
variant={advancedModelSettingsVisible ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setAdvancedModelSettingsVisible((current) => !current)}
|
||||
className="self-start sm:self-auto"
|
||||
>
|
||||
高级设置
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2" data-tour="model-name-input">
|
||||
<Label htmlFor="model_name" className={formErrors.name ? 'text-destructive' : ''}>模型名称 *</Label>
|
||||
<Input
|
||||
id="model_name"
|
||||
value={editingModel?.name || ''}
|
||||
onChange={(e) => {
|
||||
setEditingModel((prev) =>
|
||||
prev ? { ...prev, name: e.target.value } : null
|
||||
)
|
||||
if (formErrors.name) {
|
||||
setFormErrors((prev) => ({ ...prev, name: undefined }))
|
||||
}
|
||||
}}
|
||||
placeholder="例如: qwen3-30b"
|
||||
className={formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Label
|
||||
htmlFor="model_name"
|
||||
className={`sm:w-28 sm:flex-shrink-0 ${formErrors.name ? 'text-destructive' : ''}`}
|
||||
>
|
||||
模型名称 *
|
||||
</Label>
|
||||
<Input
|
||||
id="model_name"
|
||||
value={editingModel?.name || ''}
|
||||
onChange={(e) => {
|
||||
setEditingModel((prev) =>
|
||||
prev ? { ...prev, name: e.target.value } : null
|
||||
)
|
||||
if (formErrors.name) {
|
||||
setFormErrors((prev) => ({ ...prev, name: undefined }))
|
||||
}
|
||||
}}
|
||||
placeholder="例如: qwen3-30b"
|
||||
className={`sm:flex-1 ${formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
{formErrors.name ? (
|
||||
<p className="text-xs text-destructive">{formErrors.name}</p>
|
||||
<p className="text-xs text-destructive sm:pl-28">{formErrors.name}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground sm:pl-28">
|
||||
用于在任务配置中引用此模型
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2" data-tour="model-provider-select">
|
||||
<Label htmlFor="api_provider" className={formErrors.api_provider ? 'text-destructive' : ''}>API 提供商 *</Label>
|
||||
<Select
|
||||
value={editingModel?.api_provider || ''}
|
||||
onValueChange={(value) => {
|
||||
setEditingModel((prev) =>
|
||||
prev ? { ...prev, api_provider: value } : null
|
||||
)
|
||||
// 清空模型列表和错误状态,等待 useEffect 重新获取
|
||||
clearModels()
|
||||
if (formErrors.api_provider) {
|
||||
setFormErrors((prev) => ({ ...prev, api_provider: undefined }))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="api_provider" className={formErrors.api_provider ? 'border-destructive focus-visible:ring-destructive' : ''}>
|
||||
<SelectValue placeholder="选择提供商" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((provider) => (
|
||||
<SelectItem key={provider} value={provider}>
|
||||
{provider}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Label
|
||||
htmlFor="api_provider"
|
||||
className={`sm:w-28 sm:flex-shrink-0 ${formErrors.api_provider ? 'text-destructive' : ''}`}
|
||||
>
|
||||
API 提供商 *
|
||||
</Label>
|
||||
<Select
|
||||
value={editingModel?.api_provider || ''}
|
||||
onValueChange={(value) => {
|
||||
setEditingModel((prev) =>
|
||||
prev ? { ...prev, api_provider: value } : null
|
||||
)
|
||||
// 清空模型列表和错误状态,等待 useEffect 重新获取
|
||||
clearModels()
|
||||
if (formErrors.api_provider) {
|
||||
setFormErrors((prev) => ({ ...prev, api_provider: undefined }))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="api_provider" className={`sm:flex-1 ${formErrors.api_provider ? 'border-destructive focus-visible:ring-destructive' : ''}`}>
|
||||
<SelectValue placeholder="选择提供商" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((provider) => (
|
||||
<SelectItem key={provider} value={provider}>
|
||||
{provider}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{formErrors.api_provider && (
|
||||
<p className="text-xs text-destructive">{formErrors.api_provider}</p>
|
||||
<p className="text-xs text-destructive sm:pl-28">{formErrors.api_provider}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1277,6 +1346,50 @@ function ModelConfigPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{advancedModelSettingsVisible && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50/50 p-4 space-y-4 dark:border-amber-500/40 dark:bg-amber-500/10">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="model_cache" className="cursor-pointer">支持缓存计费</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
开启后,命中缓存的输入 token 会按缓存输入价格统计
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="model_cache"
|
||||
checked={editingModel?.cache || false}
|
||||
onCheckedChange={(checked) =>
|
||||
setEditingModel((prev) =>
|
||||
prev ? { ...prev, cache: checked } : null
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editingModel?.cache && (
|
||||
<div className="grid gap-2 border-t pt-4">
|
||||
<Label htmlFor="cache_price_in">缓存输入价格 (¥/M token)</Label>
|
||||
<Input
|
||||
id="cache_price_in"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={editingModel?.cache_price_in ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === '' ? null : parseFloat(e.target.value)
|
||||
setEditingModel((prev) =>
|
||||
prev
|
||||
? { ...prev, cache_price_in: val }
|
||||
: null
|
||||
)
|
||||
}}
|
||||
placeholder="默认: 0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模型级别温度 */}
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1459,6 +1572,21 @@ function ModelConfigPageContent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="model_visual"
|
||||
checked={editingModel?.visual || false}
|
||||
onCheckedChange={(checked) =>
|
||||
setEditingModel((prev) =>
|
||||
prev ? { ...prev, visual: checked } : null
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="model_visual" className="cursor-pointer">
|
||||
启用视觉
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="force_stream_mode"
|
||||
|
||||
@@ -55,6 +55,11 @@ export const ModelCardList = React.memo(function ModelCardList({
|
||||
>
|
||||
{used ? '已使用' : '未使用'}
|
||||
</Badge>
|
||||
{model.visual && (
|
||||
<Badge variant="outline" className="border-blue-500 text-blue-600">
|
||||
视觉
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground break-all" title={model.model_identifier}>
|
||||
{model.model_identifier}
|
||||
|
||||
@@ -67,6 +67,7 @@ export const ModelTable = React.memo(function ModelTable({
|
||||
<TableHead>模型名称</TableHead>
|
||||
<TableHead>模型标识符</TableHead>
|
||||
<TableHead>提供商</TableHead>
|
||||
<TableHead className="text-center">视觉</TableHead>
|
||||
<TableHead className="text-center">温度</TableHead>
|
||||
<TableHead className="text-right">输入价格</TableHead>
|
||||
<TableHead className="text-right">输出价格</TableHead>
|
||||
@@ -76,7 +77,7 @@ export const ModelTable = React.memo(function ModelTable({
|
||||
<TableBody>
|
||||
{paginatedModels.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center text-muted-foreground py-8">
|
||||
<TableCell colSpan={10} className="text-center text-muted-foreground py-8">
|
||||
{searchQuery ? '未找到匹配的模型' : '暂无模型配置'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -105,6 +106,15 @@ export const ModelTable = React.memo(function ModelTable({
|
||||
{model.model_identifier}
|
||||
</TableCell>
|
||||
<TableCell>{model.api_provider}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{model.visual ? (
|
||||
<Badge variant="outline" className="border-blue-500 text-blue-600">
|
||||
启用
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{model.temperature != null ? model.temperature : <span className="text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
@@ -139,4 +149,4 @@ export const ModelTable = React.memo(function ModelTable({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,12 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { TaskConfig } from '../types'
|
||||
|
||||
@@ -25,9 +31,28 @@ interface TaskConfigCardProps {
|
||||
hideTemperature?: boolean
|
||||
hideMaxTokens?: boolean
|
||||
advanced?: boolean
|
||||
showAdvancedSettings?: boolean
|
||||
dataTour?: string
|
||||
}
|
||||
|
||||
const selectionStrategyOptions = [
|
||||
{
|
||||
value: 'balance',
|
||||
label: '负载均衡(balance)',
|
||||
description: '优先选择当前使用次数较少的模型,适合多个同类模型共同承担请求。',
|
||||
},
|
||||
{
|
||||
value: 'random',
|
||||
label: '随机选择(random)',
|
||||
description: '每次请求从模型列表中随机选择一个模型,适合简单分散请求。',
|
||||
},
|
||||
{
|
||||
value: 'sequential',
|
||||
label: '按顺序优先(sequential)',
|
||||
description: '优先使用模型列表中靠前的模型,前面的模型不可用时再尝试后面的模型。',
|
||||
},
|
||||
]
|
||||
|
||||
export const TaskConfigCard = React.memo(function TaskConfigCard({
|
||||
title,
|
||||
description,
|
||||
@@ -37,6 +62,7 @@ export const TaskConfigCard = React.memo(function TaskConfigCard({
|
||||
hideTemperature = false,
|
||||
hideMaxTokens = false,
|
||||
advanced = false,
|
||||
showAdvancedSettings = false,
|
||||
dataTour,
|
||||
}: TaskConfigCardProps) {
|
||||
const handleModelChange = (values: string[]) => {
|
||||
@@ -68,8 +94,8 @@ export const TaskConfigCard = React.memo(function TaskConfigCard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 温度和最大 Token */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* 推理参数 */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
{!hideTemperature && (
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -112,51 +138,66 @@ export const TaskConfigCard = React.memo(function TaskConfigCard({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 慢请求阈值 */}
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>慢请求阈值 (秒)</Label>
|
||||
<span className="text-xs text-muted-foreground">超时警告</span>
|
||||
{/* 模型选择策略 */}
|
||||
<div className="grid gap-2">
|
||||
<Label>模型选择策略</Label>
|
||||
<Select
|
||||
value={taskConfig.selection_strategy ?? 'balance'}
|
||||
onValueChange={(value) => onChange('selection_strategy', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择模型选择策略" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<TooltipProvider delayDuration={150}>
|
||||
{selectionStrategyOptions.map((option) => (
|
||||
<Tooltip key={option.value}>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectItem value={option.value} title={option.description}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
className="max-w-72 bg-background text-foreground border shadow-lg"
|
||||
>
|
||||
{option.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="1"
|
||||
value={taskConfig.slow_threshold ?? 15}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value)
|
||||
if (!isNaN(value) && value >= 1) {
|
||||
onChange('slow_threshold', value)
|
||||
}
|
||||
}}
|
||||
placeholder="15"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
模型响应时间超过此阈值将输出警告日志
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 模型选择策略 */}
|
||||
<div className="grid gap-2">
|
||||
<Label>模型选择策略</Label>
|
||||
<Select
|
||||
value={taskConfig.selection_strategy ?? 'balance'}
|
||||
onValueChange={(value) => onChange('selection_strategy', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择模型选择策略" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="balance">负载均衡(balance)</SelectItem>
|
||||
<SelectItem value="random">随机选择(random)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
负载均衡:优先选择使用次数少的模型。随机选择:完全随机从模型列表中选择
|
||||
</p>
|
||||
</div>
|
||||
{showAdvancedSettings && (
|
||||
<div className="grid gap-2 rounded-md border border-amber-200 bg-amber-50/50 p-3 dark:border-amber-500/40 dark:bg-amber-500/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>慢请求阈值 (秒)</Label>
|
||||
<span className="text-xs text-muted-foreground">高级配置</span>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="1"
|
||||
value={taskConfig.slow_threshold ?? 15}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value)
|
||||
if (!isNaN(value) && value >= 1) {
|
||||
onChange('slow_threshold', value)
|
||||
}
|
||||
}}
|
||||
placeholder="15"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
模型响应时间超过此阈值将输出警告日志
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -18,6 +18,16 @@ export const modelListCache = new Map<string, { models: ModelListItem[], timesta
|
||||
* 任务配置信息
|
||||
*/
|
||||
export const TASK_CONFIGS = [
|
||||
{
|
||||
key: 'replyer' as const,
|
||||
title: '回复模型 (replyer)',
|
||||
description: '用于表达器和表达方式学习',
|
||||
},
|
||||
{
|
||||
key: 'planner' as const,
|
||||
title: '规划模型 (planner)',
|
||||
description: '负责决定麦麦该什么时候回复',
|
||||
},
|
||||
{
|
||||
key: 'utils' as const,
|
||||
title: '组件模型 (utils)',
|
||||
@@ -33,16 +43,6 @@ export const TASK_CONFIGS = [
|
||||
title: '工具调用模型 (tool_use)',
|
||||
description: '需要使用支持工具调用的模型',
|
||||
},
|
||||
{
|
||||
key: 'replyer' as const,
|
||||
title: '首要回复模型 (replyer)',
|
||||
description: '用于表达器和表达方式学习',
|
||||
},
|
||||
{
|
||||
key: 'planner' as const,
|
||||
title: '决策模型 (planner)',
|
||||
description: '负责决定麦麦该什么时候回复',
|
||||
},
|
||||
{
|
||||
key: 'vlm' as const,
|
||||
title: '图像识别模型 (vlm)',
|
||||
@@ -55,6 +55,7 @@ export const TASK_CONFIGS = [
|
||||
description: '语音转文字',
|
||||
hideTemperature: true,
|
||||
hideMaxTokens: true,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: 'embedding' as const,
|
||||
@@ -95,8 +96,11 @@ export const DEFAULT_MODEL_INFO = {
|
||||
api_provider: '',
|
||||
price_in: 0,
|
||||
price_out: 0,
|
||||
cache: false,
|
||||
cache_price_in: 0,
|
||||
temperature: null,
|
||||
max_tokens: null,
|
||||
visual: false,
|
||||
force_stream_mode: false,
|
||||
extra_params: {},
|
||||
} as const
|
||||
|
||||
@@ -66,6 +66,9 @@ export function useModelAutoSave(
|
||||
api_provider: model.api_provider,
|
||||
price_in: model.price_in ?? 0,
|
||||
price_out: model.price_out ?? 0,
|
||||
cache: model.cache ?? false,
|
||||
cache_price_in: model.cache_price_in ?? 0,
|
||||
visual: model.visual ?? false,
|
||||
force_stream_mode: model.force_stream_mode ?? false,
|
||||
extra_params: model.extra_params ?? {},
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useTour } from '@/components/tour'
|
||||
import { MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps, STEP_ROUTE_MAP } from '@/components/tour/tours/model-assignment-tour'
|
||||
|
||||
interface UseModelTourOptions {
|
||||
/** 打开模型编辑对话框回调 */
|
||||
onOpenEditDialog?: () => void
|
||||
/** 关闭编辑对话框回调 */
|
||||
onCloseEditDialog?: () => void
|
||||
}
|
||||
@@ -24,13 +26,33 @@ interface UseModelTourReturn {
|
||||
* Model 配置页面 Tour 引导 Hook
|
||||
*/
|
||||
export function useModelTour(options: UseModelTourOptions = {}): UseModelTourReturn {
|
||||
const { onCloseEditDialog } = options
|
||||
const { onOpenEditDialog, onCloseEditDialog } = options
|
||||
const navigate = useNavigate()
|
||||
const { registerTour, startTour: startTourFn, state: tourState, goToStep } = useTour()
|
||||
|
||||
// 用于追踪前一个步骤
|
||||
const prevTourStepRef = useRef(tourState.stepIndex)
|
||||
|
||||
const didClickTourTarget = useCallback((event: MouseEvent, selector: string) => {
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
if (target?.closest(selector)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const element = document.querySelector(selector)
|
||||
if (!element) {
|
||||
return false
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect()
|
||||
return (
|
||||
event.clientX >= rect.left &&
|
||||
event.clientX <= rect.right &&
|
||||
event.clientY >= rect.top &&
|
||||
event.clientY <= rect.bottom
|
||||
)
|
||||
}, [])
|
||||
|
||||
// 注册 Tour
|
||||
useEffect(() => {
|
||||
registerTour(MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps)
|
||||
@@ -67,34 +89,59 @@ export function useModelTour(options: UseModelTourOptions = {}): UseModelTourRet
|
||||
if (tourState.activeTourId !== MODEL_ASSIGNMENT_TOUR_ID || !tourState.isRunning) return
|
||||
|
||||
const handleTourClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
const currentStep = tourState.stepIndex
|
||||
|
||||
// Step 3 (index 2): 点击添加提供商按钮
|
||||
if (currentStep === 2 && target.closest('[data-tour="add-provider-button"]')) {
|
||||
if (currentStep === 2 && didClickTourTarget(e, '[data-tour="add-provider-button"]')) {
|
||||
setTimeout(() => goToStep(3), 300)
|
||||
}
|
||||
// Step 10 (index 9): 点击取消按钮(关闭提供商弹窗)
|
||||
else if (currentStep === 9 && target.closest('[data-tour="provider-cancel-button"]')) {
|
||||
else if (currentStep === 9 && didClickTourTarget(e, '[data-tour="provider-cancel-button"]')) {
|
||||
setTimeout(() => goToStep(10), 300)
|
||||
}
|
||||
// Step 12 (index 11): 点击添加模型按钮
|
||||
else if (currentStep === 11 && target.closest('[data-tour="add-model-button"]')) {
|
||||
else if (currentStep === 11 && didClickTourTarget(e, '[data-tour="add-model-button"]')) {
|
||||
onOpenEditDialog?.()
|
||||
setTimeout(() => goToStep(12), 300)
|
||||
}
|
||||
// Step 18 (index 17): 点击取消按钮(关闭模型弹窗)
|
||||
else if (currentStep === 17 && target.closest('[data-tour="model-cancel-button"]')) {
|
||||
else if (currentStep === 17 && didClickTourTarget(e, '[data-tour="model-cancel-button"]')) {
|
||||
setTimeout(() => goToStep(18), 300)
|
||||
}
|
||||
// Step 19 (index 18): 点击为模型分配功能标签页
|
||||
else if (currentStep === 18 && target.closest('[data-tour="tasks-tab-trigger"]')) {
|
||||
else if (currentStep === 18 && didClickTourTarget(e, '[data-tour="tasks-tab-trigger"]')) {
|
||||
setTimeout(() => goToStep(19), 300)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleTourClick, true)
|
||||
return () => document.removeEventListener('click', handleTourClick, true)
|
||||
}, [tourState, goToStep])
|
||||
}, [tourState, goToStep, onOpenEditDialog, didClickTourTarget])
|
||||
|
||||
// Step 12 的 spotlight 点击在部分浏览器/布局下会被 Joyride 遮罩截获。
|
||||
// 这里直接给目标按钮补一个原生监听,确保点中按钮时能打开模型弹窗。
|
||||
useEffect(() => {
|
||||
if (
|
||||
tourState.activeTourId !== MODEL_ASSIGNMENT_TOUR_ID ||
|
||||
!tourState.isRunning ||
|
||||
tourState.stepIndex !== 11
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const addModelButton = document.querySelector('[data-tour="add-model-button"]')
|
||||
if (!addModelButton) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleAddModelButtonClick = () => {
|
||||
onOpenEditDialog?.()
|
||||
setTimeout(() => goToStep(12), 300)
|
||||
}
|
||||
|
||||
addModelButton.addEventListener('click', handleAddModelButtonClick, true)
|
||||
return () => addModelButton.removeEventListener('click', handleAddModelButtonClick, true)
|
||||
}, [tourState.activeTourId, tourState.isRunning, tourState.stepIndex, goToStep, onOpenEditDialog])
|
||||
|
||||
// 开始引导
|
||||
const handleStartTour = useCallback(() => {
|
||||
|
||||
@@ -11,8 +11,11 @@ export interface ModelInfo {
|
||||
api_provider: string
|
||||
price_in: number | null
|
||||
price_out: number | null
|
||||
cache?: boolean
|
||||
cache_price_in?: number | null
|
||||
temperature?: number | null // 模型级别温度,覆盖任务配置中的温度
|
||||
max_tokens?: number | null // 模型级别最大token数,覆盖任务配置中的max_tokens
|
||||
visual?: boolean
|
||||
force_stream_mode?: boolean
|
||||
extra_params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ function ModelProviderConfigPageContent() {
|
||||
})
|
||||
const [testingProviders, setTestingProviders] = useState<Set<string>>(new Set())
|
||||
const [testResults, setTestResults] = useState<Map<string, TestConnectionResult>>(new Map())
|
||||
const [restartNoticeVisible, setRestartNoticeVisible] = useState(
|
||||
() => localStorage.getItem('model-provider-restart-notice-dismissed') !== 'true'
|
||||
)
|
||||
|
||||
const { toast } = useToast()
|
||||
const navigate = useNavigate()
|
||||
@@ -172,6 +175,11 @@ function ModelProviderConfigPageContent() {
|
||||
await triggerRestart()
|
||||
}
|
||||
|
||||
const dismissRestartNotice = () => {
|
||||
localStorage.setItem('model-provider-restart-notice-dismissed', 'true')
|
||||
setRestartNoticeVisible(false)
|
||||
}
|
||||
|
||||
const handleSaveAndRestart = async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
@@ -796,12 +804,19 @@ function ModelProviderConfigPageContent() {
|
||||
</div>
|
||||
|
||||
{/* 重启提示 */}
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
配置更新后需要<strong>重启麦麦</strong>才能生效。你可以点击右上角的"保存并重启"按钮一键完成保存和重启。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{restartNoticeVisible && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>
|
||||
配置更新后需要<strong>重启麦麦</strong>才能生效。你可以点击右上角的"保存并重启"按钮一键完成保存和重启。
|
||||
</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={dismissRestartNotice}>
|
||||
我知道了
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-260px)]">
|
||||
<ProviderList
|
||||
|
||||
@@ -440,6 +440,9 @@ function MCPSettingsPageContent() {
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
|
||||
const [mcpConfig, setMcpConfig] = useState<ConfigSectionData>({})
|
||||
const [mcpSchema, setMcpSchema] = useState<ConfigSchema | null>(null)
|
||||
const [restartNoticeVisible, setRestartNoticeVisible] = useState(
|
||||
() => localStorage.getItem('mcp-settings-restart-notice-dismissed') !== 'true',
|
||||
)
|
||||
const { toast } = useToast()
|
||||
const { triggerRestart, isRestarting } = useRestart()
|
||||
|
||||
@@ -547,6 +550,11 @@ function MCPSettingsPageContent() {
|
||||
await triggerRestart({ delay: 500 })
|
||||
}, [saveConfig, triggerRestart])
|
||||
|
||||
const dismissRestartNotice = useCallback(() => {
|
||||
localStorage.setItem('mcp-settings-restart-notice-dismissed', 'true')
|
||||
setRestartNoticeVisible(false)
|
||||
}, [])
|
||||
|
||||
const formSchema: ConfigSchema | null = mcpSchema
|
||||
? {
|
||||
className: 'MCPSettings',
|
||||
@@ -600,12 +608,17 @@ function MCPSettingsPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
MCP 设置保存后需要重启麦麦才会生效。这里与主程序配置中的 MCP 栏目使用同一份配置。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{restartNoticeVisible && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>MCP 设置保存后需要重启麦麦才会生效。这里与主程序配置中的 MCP 栏目使用同一份配置。</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={dismissRestartNotice}>
|
||||
我知道了
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">
|
||||
|
||||
@@ -280,7 +280,7 @@ export function EmojiManagementPage() {
|
||||
{/* 页面标题 */}
|
||||
<div className="mb-4 sm:mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold">表情包管理</h1>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold">表情包</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
管理麦麦的表情包资源
|
||||
</p>
|
||||
|
||||
@@ -60,8 +60,8 @@ export function ExpressionDetailDialog({
|
||||
return new Date(timestamp * 1000).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const getChatName = (chatId: string): string => {
|
||||
return chatNameMap.get(chatId) || chatId
|
||||
const getChatName = (): string => {
|
||||
return expression.chat_name || chatNameMap.get(expression.chat_id) || expression.chat_id
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -81,7 +81,7 @@ export function ExpressionDetailDialog({
|
||||
<InfoItem label="风格" value={expression.style} />
|
||||
<InfoItem
|
||||
label="聊天"
|
||||
value={getChatName(expression.chat_id)}
|
||||
value={getChatName()}
|
||||
/>
|
||||
<InfoItem icon={Hash} label="记录ID" value={expression.id.toString()} mono />
|
||||
</div>
|
||||
|
||||
@@ -51,8 +51,8 @@ export function ExpressionList({
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
|
||||
const getChatName = (chatId: string): string => {
|
||||
return chatNameMap.get(chatId) || chatId
|
||||
const getChatName = (expression: Expression): string => {
|
||||
return expression.chat_name || chatNameMap.get(expression.chat_id) || expression.chat_id
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
@@ -117,11 +117,11 @@ export function ExpressionList({
|
||||
<TableCell className="max-w-xs truncate">{expression.style}</TableCell>
|
||||
<TableCell
|
||||
className="max-w-[200px] truncate"
|
||||
title={getChatName(expression.chat_id)}
|
||||
title={getChatName(expression)}
|
||||
style={{ wordBreak: 'keep-all' }}
|
||||
>
|
||||
<span className="whitespace-nowrap overflow-hidden text-ellipsis block">
|
||||
{getChatName(expression.chat_id)}
|
||||
{getChatName(expression)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
@@ -201,10 +201,10 @@ export function ExpressionList({
|
||||
<div className="text-xs text-muted-foreground mb-1">聊天</div>
|
||||
<p
|
||||
className="text-sm truncate"
|
||||
title={getChatName(expression.chat_id)}
|
||||
title={getChatName(expression)}
|
||||
style={{ wordBreak: 'keep-all' }}
|
||||
>
|
||||
{getChatName(expression.chat_id)}
|
||||
{getChatName(expression)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -267,7 +267,7 @@ export function ExpressionManagementPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2">
|
||||
<MessageSquare className="h-8 w-8" strokeWidth={2} />
|
||||
表达方式管理
|
||||
表达方式
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm sm:text-base">
|
||||
管理麦麦的表达方式和话术模板
|
||||
@@ -316,28 +316,21 @@ export function ExpressionManagementPage() {
|
||||
|
||||
{/* 搜索和批量操作 */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<Label htmlFor="search">搜索</Label>
|
||||
<div className="flex flex-col sm:flex-row gap-2 mt-1.5">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="搜索情境、风格或上下文..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="search">搜索</Label>
|
||||
<div className="relative mt-1.5">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="搜索情境、风格或上下文..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 批量操作工具栏 */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mt-4 pt-4 border-t">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{selectedIds.size > 0 && (
|
||||
<span>已选择 {selectedIds.size} 个表达方式</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 sm:pb-0.5">
|
||||
<Label htmlFor="page-size" className="text-sm whitespace-nowrap">每页显示</Label>
|
||||
<Select
|
||||
value={pageSize.toString()}
|
||||
@@ -357,6 +350,17 @@ export function ExpressionManagementPage() {
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 批量操作工具栏 */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mt-4 pt-4 border-t">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{selectedIds.size > 0 && (
|
||||
<span>已选择 {selectedIds.size} 个表达方式</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedIds.size > 0 && (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -250,7 +250,7 @@ export function JargonManagementPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2">
|
||||
<MessageCircle className="h-8 w-8" strokeWidth={2} />
|
||||
黑话管理
|
||||
黑话
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm sm:text-base">
|
||||
管理麦麦学习到的黑话和俗语
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface FieldSchema {
|
||||
'x-icon'?: string
|
||||
'x-layout'?: 'inline-right'
|
||||
'x-input-width'?: string
|
||||
'x-textarea-min-height'?: number
|
||||
'x-textarea-rows'?: number
|
||||
advanced?: boolean
|
||||
step?: number
|
||||
}
|
||||
@@ -52,7 +54,6 @@ export interface ConfigSchema {
|
||||
uiParent?: string
|
||||
uiLabel?: string
|
||||
uiIcon?: string
|
||||
uiMergeChildren?: string[]
|
||||
}
|
||||
|
||||
export interface ConfigSchemaResponse {
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface Expression {
|
||||
style: string
|
||||
last_active_time: number
|
||||
chat_id: string
|
||||
chat_name?: string | null
|
||||
create_date: number | null
|
||||
checked: boolean
|
||||
rejected: boolean
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Literal, Optional, Tuple
|
||||
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
@@ -103,6 +104,24 @@ class BaseMaisakaReplyGenerator:
|
||||
logger.warning(f"构建 Maisaka 人设提示词失败: {exc}")
|
||||
return "你的名字是麦麦。\n是人类。"
|
||||
|
||||
@staticmethod
|
||||
def _select_reply_style() -> str:
|
||||
"""按配置概率选择本次 replyer 使用的表达风格。"""
|
||||
personality_config = global_config.personality
|
||||
reply_style = personality_config.reply_style
|
||||
candidate_styles = [style.strip() for style in personality_config.multiple_reply_style if style.strip()]
|
||||
|
||||
if not candidate_styles:
|
||||
return reply_style
|
||||
|
||||
probability = personality_config.multiple_probability
|
||||
if probability <= 0:
|
||||
return reply_style
|
||||
if random.random() > probability:
|
||||
return reply_style
|
||||
|
||||
return random.choice(candidate_styles)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_content(content: str, limit: int = 500) -> str:
|
||||
normalized = " ".join((content or "").split())
|
||||
@@ -293,7 +312,7 @@ class BaseMaisakaReplyGenerator:
|
||||
group_chat_attention_block=self._build_group_chat_attention_block(session_id),
|
||||
replyer_at_block=self._build_replyer_at_block(),
|
||||
identity=self._personality_prompt,
|
||||
reply_style=global_config.personality.reply_style,
|
||||
reply_style=self._select_reply_style(),
|
||||
)
|
||||
except Exception:
|
||||
system_prompt = "你是一个友好的 AI 助手,请根据聊天记录自然回复。"
|
||||
|
||||
@@ -58,7 +58,7 @@ LEGACY_ENV_PATH: Path = (PROJECT_ROOT / ".env").resolve().absolute()
|
||||
A_MEMORIX_LEGACY_CONFIG_PATH: Path = (CONFIG_DIR / "a_memorix.toml").resolve().absolute()
|
||||
MMC_VERSION: str = "1.0.0-pre.10"
|
||||
CONFIG_VERSION: str = "8.10.6"
|
||||
MODEL_CONFIG_VERSION: str = "1.14.8"
|
||||
MODEL_CONFIG_VERSION: str = "1.15.3"
|
||||
|
||||
logger = get_logger("config")
|
||||
|
||||
|
||||
@@ -135,7 +135,6 @@ class ConfigBase(BaseModel, AttrDocBase):
|
||||
__ui_parent__: ClassVar[str] = "" # 父配置类在 Config 中的字段名,空表示独立 Tab
|
||||
__ui_label__: ClassVar[str] = "" # Tab 显示名称(仅做 Tab 主人时使用),空则使用 classDoc
|
||||
__ui_icon__: ClassVar[str] = "" # Tab 图标名称(Lucide 图标名)
|
||||
__ui_merge_children__: ClassVar[List[str]] = [] # 在 WebUI 中并入当前配置卡片展示的子配置字段名
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, attribute_data: AttributeData, data: dict[str, Any]):
|
||||
|
||||
@@ -402,6 +402,7 @@ class TaskConfig(ConfigBase):
|
||||
"x-widget": "input",
|
||||
"x-icon": "alert-circle",
|
||||
"step": 0.1,
|
||||
"advanced": True,
|
||||
},
|
||||
)
|
||||
"""慢请求阈值(秒),超过此值会输出警告日志"""
|
||||
@@ -420,15 +421,6 @@ class TaskConfig(ConfigBase):
|
||||
class ModelTaskConfig(ConfigBase):
|
||||
"""模型配置类"""
|
||||
|
||||
utils: TaskConfig = Field(
|
||||
default_factory=TaskConfig,
|
||||
json_schema_extra={
|
||||
"x-widget": "custom",
|
||||
"x-icon": "wrench",
|
||||
},
|
||||
)
|
||||
"""组件使用的模型, 例如表情包模块, 取名模块, 关系模块, 麦麦的情绪变化等,是麦麦必须的模型"""
|
||||
|
||||
replyer: TaskConfig = Field(
|
||||
default_factory=TaskConfig,
|
||||
json_schema_extra={
|
||||
@@ -436,17 +428,7 @@ class ModelTaskConfig(ConfigBase):
|
||||
"x-icon": "message-square",
|
||||
},
|
||||
)
|
||||
"""首要回复模型配置"""
|
||||
|
||||
learner: TaskConfig = Field(
|
||||
default_factory=TaskConfig,
|
||||
json_schema_extra={
|
||||
"x-widget": "custom",
|
||||
"x-icon": "graduation-cap",
|
||||
"advanced": True,
|
||||
},
|
||||
)
|
||||
"""学习模型配置,用于表达方式学习和黑话学习;留空时自动继用 utils 模型"""
|
||||
"""回复模型配置"""
|
||||
|
||||
planner: TaskConfig = Field(
|
||||
default_factory=TaskConfig,
|
||||
@@ -457,6 +439,25 @@ class ModelTaskConfig(ConfigBase):
|
||||
)
|
||||
"""规划模型配置"""
|
||||
|
||||
utils: TaskConfig = Field(
|
||||
default_factory=TaskConfig,
|
||||
json_schema_extra={
|
||||
"x-widget": "custom",
|
||||
"x-icon": "wrench",
|
||||
},
|
||||
)
|
||||
"""组件使用的模型, 例如表情包模块, 取名模块, 关系模块, 麦麦的情绪变化等,是麦麦必须的模型"""
|
||||
|
||||
learner: TaskConfig = Field(
|
||||
default_factory=TaskConfig,
|
||||
json_schema_extra={
|
||||
"x-widget": "custom",
|
||||
"x-icon": "graduation-cap",
|
||||
"advanced": True,
|
||||
},
|
||||
)
|
||||
"""学习模型配置,用于表达方式学习和黑话学习;留空时自动继用 utils 模型"""
|
||||
|
||||
vlm: TaskConfig = Field(
|
||||
default_factory=TaskConfig,
|
||||
json_schema_extra={
|
||||
@@ -471,6 +472,7 @@ class ModelTaskConfig(ConfigBase):
|
||||
json_schema_extra={
|
||||
"x-widget": "custom",
|
||||
"x-icon": "volume-2",
|
||||
"advanced": True,
|
||||
},
|
||||
)
|
||||
"""语音识别模型配置"""
|
||||
|
||||
@@ -84,6 +84,8 @@ class PersonalityConfig(ConfigBase):
|
||||
json_schema_extra={
|
||||
"x-widget": "textarea",
|
||||
"x-icon": "user-circle",
|
||||
"x-textarea-min-height": 40,
|
||||
"x-textarea-rows": 1,
|
||||
},
|
||||
)
|
||||
"""人格,建议200字以内,描述人格特质和身份特征;可以写完整设定。要求第二人称"""
|
||||
@@ -93,6 +95,8 @@ class PersonalityConfig(ConfigBase):
|
||||
json_schema_extra={
|
||||
"x-widget": "textarea",
|
||||
"x-icon": "message-square",
|
||||
"x-textarea-min-height": 40,
|
||||
"x-textarea-rows": 1,
|
||||
},
|
||||
)
|
||||
"""默认表达风格,描述麦麦说话的表达风格,表达习惯,如要修改,可以酌情新增内容,建议1-2行"""
|
||||
@@ -321,7 +325,8 @@ class ChatConfig(ConfigBase):
|
||||
class MessageReceiveConfig(ConfigBase):
|
||||
"""消息接收配置类"""
|
||||
|
||||
__ui_parent__ = "response_post_process"
|
||||
__ui_label__ = "消息接收"
|
||||
__ui_icon__ = "message-square-text"
|
||||
|
||||
image_parse_threshold: int = Field(
|
||||
default=5,
|
||||
@@ -394,7 +399,7 @@ class TargetItem(ConfigBase):
|
||||
class MemoryConfig(ConfigBase):
|
||||
"""记忆配置类"""
|
||||
|
||||
__ui_parent__ = "emoji"
|
||||
__ui_parent__ = "a_memorix"
|
||||
|
||||
|
||||
global_memory: bool = Field(
|
||||
@@ -1051,7 +1056,7 @@ class LearningItem(ConfigBase):
|
||||
"x-icon": "message-square",
|
||||
},
|
||||
)
|
||||
"""是否启用表达学习"""
|
||||
"""是否使用表达"""
|
||||
|
||||
enable_learning: bool = Field(
|
||||
default=True,
|
||||
@@ -1060,7 +1065,7 @@ class LearningItem(ConfigBase):
|
||||
"x-icon": "graduation-cap",
|
||||
},
|
||||
)
|
||||
"""是否启用表达优化学习"""
|
||||
"""是否学习表达"""
|
||||
|
||||
enable_jargon_learning: bool = Field(
|
||||
default=False,
|
||||
@@ -1069,7 +1074,7 @@ class LearningItem(ConfigBase):
|
||||
"x-icon": "book",
|
||||
},
|
||||
)
|
||||
"""是否启用jargon学习"""
|
||||
"""是否学习黑话"""
|
||||
|
||||
class ExpressionGroup(ConfigBase):
|
||||
"""表达互通组配置类,若列表为空代表全局共享"""
|
||||
@@ -1177,7 +1182,8 @@ class ExpressionConfig(ConfigBase):
|
||||
class VoiceConfig(ConfigBase):
|
||||
"""语音识别配置类"""
|
||||
|
||||
__ui_parent__ = "emoji"
|
||||
__ui_label__ = "语音"
|
||||
__ui_icon__ = "mic"
|
||||
|
||||
enable_asr: bool = Field(
|
||||
default=False,
|
||||
@@ -1192,8 +1198,8 @@ class VoiceConfig(ConfigBase):
|
||||
class EmojiConfig(ConfigBase):
|
||||
"""表情包配置类"""
|
||||
|
||||
__ui_label__ = "功能"
|
||||
__ui_icon__ = "puzzle"
|
||||
__ui_label__ = "表情包"
|
||||
__ui_icon__ = "smile"
|
||||
|
||||
emoji_send_num: int = Field(
|
||||
default=25,
|
||||
@@ -1314,7 +1320,7 @@ class KeywordRuleConfig(ConfigBase):
|
||||
class KeywordReactionConfig(ConfigBase):
|
||||
"""关键词配置类"""
|
||||
|
||||
__ui_parent__ = "response_post_process"
|
||||
__ui_parent__ = "message_receive"
|
||||
|
||||
keyword_rules: list[KeywordRuleConfig] = Field(
|
||||
default_factory=lambda: [],
|
||||
@@ -1345,9 +1351,8 @@ class KeywordReactionConfig(ConfigBase):
|
||||
class ResponsePostProcessConfig(ConfigBase):
|
||||
"""回复后处理配置类"""
|
||||
|
||||
__ui_label__ = "处理"
|
||||
__ui_label__ = "后处理"
|
||||
__ui_icon__ = "settings"
|
||||
__ui_merge_children__ = ["chinese_typo", "response_splitter"]
|
||||
|
||||
enable_response_post_process: bool = Field(
|
||||
default=True,
|
||||
|
||||
@@ -29,7 +29,7 @@ logger = get_logger("emoji")
|
||||
|
||||
install(extra_lines=3)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent.parent.absolute().resolve()
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
DATA_DIR = PROJECT_ROOT / "data"
|
||||
EmojiRegisterStatus = Literal["registered", "skipped", "failed"]
|
||||
EMOJI_DIR = DATA_DIR / "emoji" # 表情包存储目录
|
||||
@@ -215,6 +215,17 @@ def _is_available_emoji_record(record: Images) -> bool:
|
||||
return record_path.exists() and record_path.is_file()
|
||||
|
||||
|
||||
def _resolve_existing_emoji_path(raw_path: str | Path | None) -> Optional[Path]:
|
||||
"""将表情包路径归一化;路径为空或不存在时返回 ``None``。"""
|
||||
if not raw_path:
|
||||
return None
|
||||
|
||||
record_path = Path(raw_path).absolute().resolve()
|
||||
if not record_path.exists() or not record_path.is_file():
|
||||
return None
|
||||
return record_path
|
||||
|
||||
|
||||
def _is_vlm_task_configured() -> bool:
|
||||
"""判断是否配置了可用于表情包识别和审核的视觉模型任务。"""
|
||||
|
||||
@@ -244,6 +255,7 @@ class EmojiManager:
|
||||
|
||||
self._emoji_num: int = 0
|
||||
self.emojis: list[MaiEmoji] = []
|
||||
self._known_emoji_file_paths: set[Path] = set()
|
||||
self._maintenance_wakeup_event: asyncio.Event = asyncio.Event()
|
||||
self._pending_description_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self._reload_callback_registered: bool = False
|
||||
@@ -254,9 +266,9 @@ class EmojiManager:
|
||||
logger.info("启动表情包管理器")
|
||||
|
||||
def reload_runtime_config(self) -> None:
|
||||
"""响应配置热重载,唤醒维护循环以尽快应用最新配置。"""
|
||||
"""响应配置热重载,重置维护循环等待时间以应用最新配置。"""
|
||||
self._maintenance_wakeup_event.set()
|
||||
logger.info("[配置热重载] Emoji 模块配置已更新,将立即应用到维护循环")
|
||||
logger.info("[配置热重载] Emoji 模块配置已更新,将按新的检查间隔等待后执行维护")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""清理 EmojiManager 生命周期资源。"""
|
||||
@@ -948,33 +960,73 @@ class EmojiManager:
|
||||
|
||||
def check_emoji_file_integrity(self) -> None:
|
||||
"""
|
||||
检查表情包完整性,删除文件缺失的表情包记录
|
||||
检查表情包文件和数据库注册记录的一致性。
|
||||
|
||||
数据库记录存在但文件缺失时删除数据库记录;文件存在但没有数据库记录时删除文件。
|
||||
"""
|
||||
logger.info("[完整性检查] 开始检查表情包文件完整性...")
|
||||
to_delete_emojis: list[tuple[MaiEmoji, bool]] = []
|
||||
removal_count = 0
|
||||
for emoji in self.emojis:
|
||||
if not emoji.full_path.exists():
|
||||
logger.warning(f"[完整性检查] 表情包文件缺失,准备修改记录: {emoji.file_name}")
|
||||
to_delete_emojis.append((emoji, False))
|
||||
if not emoji.description:
|
||||
logger.warning(f"[完整性检查] 表情包记录缺失描述,准备删除记录: {emoji.file_name}")
|
||||
to_delete_emojis.append((emoji, True))
|
||||
_ensure_directories()
|
||||
logger.info("[完整性检查] 开始检查表情包文件和注册记录一致性...")
|
||||
tracked_paths: set[Path] = set()
|
||||
record_removal_count = 0
|
||||
file_removal_count = 0
|
||||
available_emojis: list[MaiEmoji] = []
|
||||
|
||||
for emoji, is_description_empty in to_delete_emojis:
|
||||
if self.delete_emoji(emoji, is_description_empty):
|
||||
self.emojis.remove(emoji)
|
||||
self._emoji_num -= 1
|
||||
removal_count += 1
|
||||
logger.info(f"[完整性检查] 成功删除缺失文件的表情包记录: {emoji.file_name}")
|
||||
else:
|
||||
logger.error(f"[完整性检查] 删除缺失文件的表情包记录失败: {emoji.file_name}")
|
||||
with get_db_session() as session:
|
||||
statement = select(Images).filter_by(image_type=ImageType.EMOJI)
|
||||
records = session.exec(statement).all()
|
||||
for record in records:
|
||||
record_path = _resolve_existing_emoji_path(record.full_path)
|
||||
if record.no_file_flag or record_path is None:
|
||||
logger.warning(
|
||||
f"[完整性检查] 表情包数据库记录缺少实际文件,删除数据库记录: id={record.id}, path={record.full_path}"
|
||||
)
|
||||
session.delete(record)
|
||||
record_removal_count += 1
|
||||
continue
|
||||
if not record.is_registered or record.is_banned:
|
||||
tracked_paths.add(record_path)
|
||||
continue
|
||||
try:
|
||||
available_emojis.append(MaiEmoji.from_db_instance(record))
|
||||
tracked_paths.add(record_path)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"[完整性检查] 加载表情包记录时出错,将删除异常记录: {exc}\n记录ID: {record.id}, 路径: {record.full_path}"
|
||||
)
|
||||
session.delete(record)
|
||||
record_removal_count += 1
|
||||
|
||||
logger.info(f"[完整性检查] 表情包文件完整性检查完成,删除了 {removal_count} 条记录")
|
||||
for emoji_file in EMOJI_DIR.iterdir():
|
||||
if not emoji_file.is_file():
|
||||
continue
|
||||
resolved_file = emoji_file.absolute().resolve()
|
||||
if resolved_file in tracked_paths:
|
||||
continue
|
||||
try:
|
||||
emoji_file.unlink()
|
||||
file_removal_count += 1
|
||||
logger.warning(f"[完整性检查] 表情包文件缺少数据库记录,删除文件: {emoji_file}")
|
||||
except Exception as exc:
|
||||
logger.error(f"[完整性检查] 删除无注册记录的表情包文件失败: {emoji_file}, error={exc}")
|
||||
|
||||
self.emojis = available_emojis
|
||||
self._known_emoji_file_paths = tracked_paths
|
||||
self._emoji_num = len(self.emojis)
|
||||
logger.info(
|
||||
f"[完整性检查] 表情包完整性检查完成,删除数据库记录 {record_removal_count} 条,删除图片文件 {file_removal_count} 个"
|
||||
)
|
||||
|
||||
async def periodic_emoji_maintenance(self) -> None:
|
||||
"""Run emoji maintenance tasks periodically."""
|
||||
while True:
|
||||
wait_seconds = max(global_config.emoji.check_interval * 60, 0)
|
||||
try:
|
||||
await asyncio.wait_for(self._maintenance_wakeup_event.wait(), timeout=wait_seconds)
|
||||
self._maintenance_wakeup_event.clear()
|
||||
continue
|
||||
except asyncio.TimeoutError:
|
||||
self._maintenance_wakeup_event.clear()
|
||||
|
||||
_ensure_directories()
|
||||
try:
|
||||
self.check_emoji_file_integrity()
|
||||
@@ -989,24 +1041,21 @@ class EmojiManager:
|
||||
for emoji_file in EMOJI_DIR.iterdir():
|
||||
if not emoji_file.is_file():
|
||||
continue
|
||||
resolved_file = emoji_file.absolute().resolve()
|
||||
if resolved_file in self._known_emoji_file_paths:
|
||||
continue
|
||||
try:
|
||||
register_status = await self.register_emoji_by_filename(emoji_file)
|
||||
except Exception as e:
|
||||
logger.error(f"[emoji_maintenance] Failed to process {emoji_file.name}: {e}")
|
||||
register_status = "failed"
|
||||
if register_status == "registered":
|
||||
self._known_emoji_file_paths.add(resolved_file)
|
||||
break
|
||||
if register_status == "skipped":
|
||||
logger.debug(f"[emoji_maintenance] Emoji already registered, keep file: {emoji_file.name}")
|
||||
else:
|
||||
logger.debug(f"[emoji_maintenance] Emoji not registered, keep file: {emoji_file.name}")
|
||||
wait_seconds = max(global_config.emoji.check_interval * 60, 0)
|
||||
try:
|
||||
await asyncio.wait_for(self._maintenance_wakeup_event.wait(), timeout=wait_seconds)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
finally:
|
||||
self._maintenance_wakeup_event.clear()
|
||||
|
||||
async def register_emoji_by_filename(self, filename: Path | str) -> EmojiRegisterStatus:
|
||||
"""Register an emoji file from ``data/emoji`` without moving or deleting it."""
|
||||
|
||||
@@ -39,15 +39,12 @@ class ConfigSchemaGenerator:
|
||||
ui_parent = getattr(config_class, "__ui_parent__", "")
|
||||
ui_label = getattr(config_class, "__ui_label__", "")
|
||||
ui_icon = getattr(config_class, "__ui_icon__", "")
|
||||
ui_merge_children = getattr(config_class, "__ui_merge_children__", [])
|
||||
if ui_parent:
|
||||
schema["uiParent"] = ui_parent
|
||||
if ui_label:
|
||||
schema["uiLabel"] = ui_label
|
||||
if ui_icon:
|
||||
schema["uiIcon"] = ui_icon
|
||||
if ui_merge_children:
|
||||
schema["uiMergeChildren"] = list(ui_merge_children)
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from sqlmodel import col, delete, select
|
||||
|
||||
from src.chat.message_receive.chat_manager import chat_manager as _chat_manager
|
||||
from src.common.database.database import get_db_session
|
||||
from src.common.database.database_model import Expression
|
||||
from src.common.database.database_model import ChatSession, Expression, Messages, ModifiedBy
|
||||
from src.common.logger import get_logger
|
||||
from src.webui.dependencies import require_auth
|
||||
|
||||
@@ -28,6 +28,7 @@ class ExpressionResponse(BaseModel):
|
||||
style: str
|
||||
last_active_time: float
|
||||
chat_id: str
|
||||
chat_name: Optional[str] = None
|
||||
create_date: Optional[float]
|
||||
checked: bool
|
||||
rejected: bool
|
||||
@@ -90,7 +91,61 @@ class ExpressionCreateResponse(BaseModel):
|
||||
data: ExpressionResponse
|
||||
|
||||
|
||||
def expression_to_response(expression: Expression) -> ExpressionResponse:
|
||||
def get_chat_name_from_latest_message(chat_id: str, db_session: Any) -> Optional[str]:
|
||||
"""从最近消息中解析聊天显示名称。"""
|
||||
|
||||
statement = (
|
||||
select(Messages)
|
||||
.where(col(Messages.session_id) == chat_id)
|
||||
.order_by(col(Messages.timestamp).desc())
|
||||
.limit(1)
|
||||
)
|
||||
message = db_session.exec(statement).first()
|
||||
if not message:
|
||||
return None
|
||||
if message.group_id:
|
||||
return message.group_name or f"群聊{message.group_id}"
|
||||
return message.user_cardname or message.user_nickname or (f"用户{message.user_id}" if message.user_id else None)
|
||||
|
||||
|
||||
def get_chat_name_from_session_record(chat_session: ChatSession) -> str:
|
||||
"""从会话记录推断兜底显示名称。"""
|
||||
|
||||
if chat_session.group_id:
|
||||
return f"群聊{chat_session.group_id}"
|
||||
if chat_session.user_id:
|
||||
return f"用户{chat_session.user_id}"
|
||||
return chat_session.session_id
|
||||
|
||||
|
||||
def get_chat_name(chat_id: str, db_session: Optional[Any] = None) -> str:
|
||||
"""根据聊天 ID 获取聊天名称。
|
||||
|
||||
Args:
|
||||
chat_id: 聊天会话 ID。
|
||||
db_session: 可选数据库会话,用于从历史消息中解析群名或私聊用户名。
|
||||
|
||||
Returns:
|
||||
str: 聊天显示名称,获取失败时返回原始聊天 ID。
|
||||
"""
|
||||
|
||||
try:
|
||||
if name := _chat_manager.get_session_name(chat_id):
|
||||
return name
|
||||
if db_session and (name := get_chat_name_from_latest_message(chat_id, db_session)):
|
||||
return name
|
||||
session = _chat_manager.get_session_by_session_id(chat_id)
|
||||
if session:
|
||||
if session.group_id:
|
||||
return f"群聊{session.group_id}"
|
||||
if session.user_id:
|
||||
return f"用户{session.user_id}"
|
||||
return chat_id
|
||||
except Exception:
|
||||
return chat_id
|
||||
|
||||
|
||||
def expression_to_response(expression: Expression, db_session: Optional[Any] = None) -> ExpressionResponse:
|
||||
"""将表达方式模型转换为响应对象。
|
||||
|
||||
Args:
|
||||
@@ -101,38 +156,21 @@ def expression_to_response(expression: Expression) -> ExpressionResponse:
|
||||
"""
|
||||
last_active_time = expression.last_active_time.timestamp() if expression.last_active_time else 0.0
|
||||
create_date = expression.create_time.timestamp() if expression.create_time else None
|
||||
chat_id = expression.session_id or ""
|
||||
return ExpressionResponse(
|
||||
id=expression.id if expression.id is not None else 0,
|
||||
situation=expression.situation,
|
||||
style=expression.style,
|
||||
last_active_time=last_active_time,
|
||||
chat_id=expression.session_id or "",
|
||||
chat_id=chat_id,
|
||||
chat_name=get_chat_name(chat_id, db_session) if chat_id else None,
|
||||
create_date=create_date,
|
||||
checked=False,
|
||||
rejected=False,
|
||||
modified_by=None,
|
||||
checked=expression.checked,
|
||||
rejected=expression.rejected,
|
||||
modified_by=expression.modified_by.value if expression.modified_by else None,
|
||||
)
|
||||
|
||||
|
||||
def get_chat_name(chat_id: str) -> str:
|
||||
"""根据聊天 ID 获取聊天名称。
|
||||
|
||||
Args:
|
||||
chat_id: 聊天会话 ID。
|
||||
|
||||
Returns:
|
||||
str: 聊天显示名称,获取失败时返回原始聊天 ID。
|
||||
"""
|
||||
try:
|
||||
session = _chat_manager.get_session_by_session_id(chat_id)
|
||||
if not session:
|
||||
return chat_id
|
||||
name = _chat_manager.get_session_name(chat_id)
|
||||
return name or chat_id
|
||||
except Exception:
|
||||
return chat_id
|
||||
|
||||
|
||||
def get_chat_names_batch(chat_ids: List[str]) -> Dict[str, str]:
|
||||
"""批量获取聊天名称。
|
||||
|
||||
@@ -145,8 +183,7 @@ def get_chat_names_batch(chat_ids: List[str]) -> Dict[str, str]:
|
||||
result = {cid: cid for cid in chat_ids} # 默认值为原始ID
|
||||
try:
|
||||
for chat_id in chat_ids:
|
||||
if name := _chat_manager.get_session_name(chat_id):
|
||||
result[chat_id] = name
|
||||
result[chat_id] = get_chat_name(chat_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"批量获取聊天名称失败: {e}")
|
||||
return result
|
||||
@@ -176,19 +213,43 @@ async def get_chat_list() -> ChatListResponse:
|
||||
ChatListResponse: 可用于下拉选择的聊天列表。
|
||||
"""
|
||||
try:
|
||||
chat_list = []
|
||||
chat_by_id: Dict[str, ChatInfo] = {}
|
||||
for session_id, session in _chat_manager.sessions.items():
|
||||
chat_name = _chat_manager.get_session_name(session_id) or session_id
|
||||
chat_list.append(
|
||||
ChatInfo(
|
||||
chat_id=session_id,
|
||||
chat_name=chat_name,
|
||||
platform=session.platform,
|
||||
is_group=session.is_group_session,
|
||||
)
|
||||
chat_by_id[session_id] = ChatInfo(
|
||||
chat_id=session_id,
|
||||
chat_name=chat_name,
|
||||
platform=session.platform,
|
||||
is_group=session.is_group_session,
|
||||
)
|
||||
|
||||
with get_db_session() as session:
|
||||
for chat_session in session.exec(select(ChatSession)).all():
|
||||
if chat_session.session_id in chat_by_id:
|
||||
continue
|
||||
chat_name = get_chat_name_from_latest_message(chat_session.session_id, session)
|
||||
chat_by_id[chat_session.session_id] = ChatInfo(
|
||||
chat_id=chat_session.session_id,
|
||||
chat_name=chat_name or get_chat_name_from_session_record(chat_session),
|
||||
platform=chat_session.platform,
|
||||
is_group=bool(chat_session.group_id),
|
||||
)
|
||||
|
||||
expression_chat_ids = {
|
||||
chat_id for chat_id in session.exec(select(Expression.session_id)).all() if chat_id
|
||||
}
|
||||
for session_id in expression_chat_ids:
|
||||
if session_id in chat_by_id:
|
||||
continue
|
||||
chat_by_id[session_id] = ChatInfo(
|
||||
chat_id=session_id,
|
||||
chat_name=get_chat_name(session_id, session),
|
||||
platform=None,
|
||||
is_group=False,
|
||||
)
|
||||
|
||||
# 按名称排序
|
||||
chat_list = list(chat_by_id.values())
|
||||
chat_list.sort(key=lambda x: x.chat_name)
|
||||
|
||||
return ChatListResponse(success=True, data=chat_list)
|
||||
@@ -252,7 +313,7 @@ async def get_expression_list(
|
||||
if chat_id:
|
||||
count_statement = count_statement.where(col(Expression.session_id) == chat_id)
|
||||
total = len(session.exec(count_statement).all())
|
||||
data = [expression_to_response(expr) for expr in expressions]
|
||||
data = [expression_to_response(expr, session) for expr in expressions]
|
||||
|
||||
return ExpressionListResponse(success=True, total=total, page=page, page_size=page_size, data=data)
|
||||
|
||||
@@ -281,7 +342,7 @@ async def get_expression_detail(expression_id: int) -> ExpressionDetailResponse:
|
||||
if not expression:
|
||||
raise HTTPException(status_code=404, detail=f"未找到 ID 为 {expression_id} 的表达方式")
|
||||
|
||||
data = expression_to_response(expression)
|
||||
data = expression_to_response(expression, session)
|
||||
|
||||
return ExpressionDetailResponse(success=True, data=data)
|
||||
|
||||
@@ -321,7 +382,7 @@ async def create_expression(
|
||||
session.add(expression)
|
||||
session.flush()
|
||||
expression_id = expression.id
|
||||
data = expression_to_response(expression)
|
||||
data = expression_to_response(expression, session)
|
||||
|
||||
logger.info(f"表达方式已创建: ID={expression_id}, situation={request.situation}")
|
||||
|
||||
@@ -375,7 +436,7 @@ async def update_expression(
|
||||
db_expression.session_id = update_data["session_id"]
|
||||
db_expression.last_active_time = update_data["last_active_time"]
|
||||
session.add(db_expression)
|
||||
data = expression_to_response(db_expression)
|
||||
data = expression_to_response(db_expression, session)
|
||||
|
||||
logger.info(f"表达方式已更新: ID={expression_id}, 字段: {list(update_data.keys())}")
|
||||
|
||||
@@ -524,6 +585,22 @@ class ReviewStatsResponse(BaseModel):
|
||||
user_checked: int
|
||||
|
||||
|
||||
def apply_review_filter(statement: Any, filter_type: str) -> Any:
|
||||
"""按审核状态过滤表达方式查询。"""
|
||||
if filter_type == "unchecked":
|
||||
return statement.where(col(Expression.checked).is_(False))
|
||||
if filter_type == "passed":
|
||||
return statement.where(col(Expression.checked).is_(True), col(Expression.rejected).is_(False))
|
||||
if filter_type == "rejected":
|
||||
return statement.where(col(Expression.checked).is_(True), col(Expression.rejected).is_(True))
|
||||
return statement
|
||||
|
||||
|
||||
def count_expressions(session: Any, statement: Any) -> int:
|
||||
"""统计表达方式查询结果数量。"""
|
||||
return len(session.exec(statement).all())
|
||||
|
||||
|
||||
@router.get("/review/stats", response_model=ReviewStatsResponse)
|
||||
async def get_review_stats() -> ReviewStatsResponse:
|
||||
"""获取审核统计数据。
|
||||
@@ -533,12 +610,24 @@ async def get_review_stats() -> ReviewStatsResponse:
|
||||
"""
|
||||
try:
|
||||
with get_db_session() as session:
|
||||
total = len(session.exec(select(Expression.id)).all())
|
||||
unchecked = 0
|
||||
passed = 0
|
||||
rejected = 0
|
||||
ai_checked = 0
|
||||
user_checked = 0
|
||||
total = count_expressions(session, select(Expression.id))
|
||||
unchecked = count_expressions(session, apply_review_filter(select(Expression.id), "unchecked"))
|
||||
passed = count_expressions(session, apply_review_filter(select(Expression.id), "passed"))
|
||||
rejected = count_expressions(session, apply_review_filter(select(Expression.id), "rejected"))
|
||||
ai_checked = count_expressions(
|
||||
session,
|
||||
select(Expression.id).where(
|
||||
col(Expression.checked).is_(True),
|
||||
col(Expression.modified_by) == ModifiedBy.AI,
|
||||
),
|
||||
)
|
||||
user_checked = count_expressions(
|
||||
session,
|
||||
select(Expression.id).where(
|
||||
col(Expression.checked).is_(True),
|
||||
col(Expression.modified_by) == ModifiedBy.USER,
|
||||
),
|
||||
)
|
||||
|
||||
return ReviewStatsResponse(
|
||||
total=total,
|
||||
@@ -587,10 +676,7 @@ async def get_review_list(
|
||||
ReviewListResponse: 审核列表响应。
|
||||
"""
|
||||
try:
|
||||
statement = select(Expression)
|
||||
|
||||
if filter_type in {"unchecked", "passed", "rejected"}:
|
||||
statement = statement.where(col(Expression.id) == -1)
|
||||
statement = apply_review_filter(select(Expression), filter_type)
|
||||
# all 不需要额外过滤
|
||||
|
||||
# 搜索过滤
|
||||
@@ -615,9 +701,7 @@ async def get_review_list(
|
||||
with get_db_session() as session:
|
||||
expressions = session.exec(statement).all()
|
||||
|
||||
count_statement = select(Expression.id)
|
||||
if filter_type in {"unchecked", "passed", "rejected"}:
|
||||
count_statement = count_statement.where(col(Expression.id) == -1)
|
||||
count_statement = apply_review_filter(select(Expression.id), filter_type)
|
||||
if search:
|
||||
count_statement = count_statement.where(
|
||||
(col(Expression.situation).contains(search)) | (col(Expression.style).contains(search))
|
||||
@@ -625,7 +709,7 @@ async def get_review_list(
|
||||
if chat_id:
|
||||
count_statement = count_statement.where(col(Expression.session_id) == chat_id)
|
||||
total = len(session.exec(count_statement).all())
|
||||
data = [expression_to_response(expr) for expr in expressions]
|
||||
data = [expression_to_response(expr, session) for expr in expressions]
|
||||
|
||||
return ReviewListResponse(
|
||||
success=True,
|
||||
@@ -706,10 +790,10 @@ async def batch_review_expressions(
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
# 冲突检测
|
||||
if item.require_unchecked:
|
||||
# 冲突检测:未审核列表发起的操作只允许处理仍处于未审核状态的条目。
|
||||
if item.require_unchecked and expression.checked:
|
||||
results.append(
|
||||
BatchReviewResultItem(id=item.id, success=False, message="当前模型不支持审核状态过滤")
|
||||
BatchReviewResultItem(id=item.id, success=False, message="该表达方式已被审核,请刷新列表后重试")
|
||||
)
|
||||
failed += 1
|
||||
continue
|
||||
@@ -727,6 +811,9 @@ async def batch_review_expressions(
|
||||
)
|
||||
failed += 1
|
||||
continue
|
||||
db_expression.checked = True
|
||||
db_expression.rejected = item.rejected
|
||||
db_expression.modified_by = ModifiedBy.USER
|
||||
db_expression.last_active_time = datetime.now()
|
||||
session.add(db_expression)
|
||||
|
||||
|
||||
26
uv.lock
generated
26
uv.lock
generated
@@ -1051,6 +1051,11 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
socks = [
|
||||
{ name = "socksio" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.3"
|
||||
@@ -1452,7 +1457,7 @@ dependencies = [
|
||||
{ name = "faiss-cpu" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "google-genai" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx", extra = ["socks"] },
|
||||
{ name = "jieba" },
|
||||
{ name = "json-repair" },
|
||||
{ name = "maibot-dashboard" },
|
||||
@@ -1503,10 +1508,10 @@ requires-dist = [
|
||||
{ name = "faiss-cpu", specifier = ">=1.11.0" },
|
||||
{ name = "fastapi", specifier = ">=0.116.0" },
|
||||
{ name = "google-genai", specifier = ">=1.39.1" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx", extras = ["socks"] },
|
||||
{ name = "jieba", specifier = ">=0.42.1" },
|
||||
{ name = "json-repair", specifier = ">=0.47.6" },
|
||||
{ name = "maibot-dashboard", specifier = "==1.0.1.dev2026050251" },
|
||||
{ name = "maibot-dashboard", specifier = ">=1.0.4" },
|
||||
{ name = "maibot-plugin-sdk", specifier = ">=2.4.0" },
|
||||
{ name = "maim-message", specifier = ">=0.6.2" },
|
||||
{ name = "matplotlib", specifier = ">=3.10.5" },
|
||||
@@ -1544,11 +1549,11 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "maibot-dashboard"
|
||||
version = "1.0.1.dev2026050251"
|
||||
version = "1.0.4"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/31/bbe39c5e9b1603350a4962fe9bf6dab634be4bea5104b3f2a747ddbaf1e9/maibot_dashboard-1.0.1.dev2026050251.tar.gz", hash = "sha256:2f7c822c9c31ff52c162c09f4d313ad39581fafbf872a4109ed5031db2ce5330", size = 2480032, upload-time = "2026-05-02T03:43:58.249Z" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/0b/970a2c0b2cda503fd829aeca7d590a319f0556e9d9de8025cddfff746343/maibot_dashboard-1.0.4.tar.gz", hash = "sha256:c15b50017a923f8575b2d1991d8af96fd682d75bb4084eb21d6a03956b9168b6", size = 2470802, upload-time = "2026-05-04T10:22:01.146Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/66/377d20ae79afb787df9048fc1c9f11fbba2e8a022f0a82a094571a62aa47/maibot_dashboard-1.0.1.dev2026050251-py3-none-any.whl", hash = "sha256:86475fdb44ca618fa53c6feecc99aad045f9d4b57c6f5f14d3b3d00584b7d1ef", size = 2542575, upload-time = "2026-05-02T03:43:56.454Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/33/99/7d822f14c9ae6f37e0536df239daa52b5e3ea7997d96471c1c8f2dccf248/maibot_dashboard-1.0.4-py3-none-any.whl", hash = "sha256:4b079f87ea537714914f65f53e596fbe166eb527fe27edb42b9dbcfcd115b4a8", size = 2537189, upload-time = "2026-05-04T10:21:59.922Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3281,6 +3286,15 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socksio"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.49"
|
||||
|
||||
Reference in New Issue
Block a user