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