feat:webui支持更加优化的模型配置,优化多处UI体验,支持设置视觉和cache价格,修复多重表达不生效的问题,修复表情包路径错误
This commit is contained in:
4
dashboard/package-lock.json
generated
4
dashboard/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "maibot-dashboard",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "maibot-dashboard",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.5",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "maibot-dashboard",
|
||||
"private": true,
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.5",
|
||||
"type": "module",
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -76,7 +76,6 @@ function DynamicConfigSection({
|
||||
basePath,
|
||||
hooks,
|
||||
level,
|
||||
mergedChildren = [],
|
||||
nestedSchema,
|
||||
onChange,
|
||||
sectionDescription,
|
||||
@@ -87,11 +86,6 @@ function DynamicConfigSection({
|
||||
basePath: string
|
||||
hooks: FieldHookRegistry
|
||||
level: number
|
||||
mergedChildren?: Array<{
|
||||
key: string
|
||||
schema: ConfigSchema
|
||||
values: Record<string, unknown>
|
||||
}>
|
||||
nestedSchema: ConfigSchema
|
||||
onChange: (field: string, value: unknown) => void
|
||||
sectionDescription?: string
|
||||
@@ -100,9 +94,7 @@ function DynamicConfigSection({
|
||||
values: Record<string, unknown>
|
||||
}) {
|
||||
const [advancedVisible, setAdvancedVisible] = React.useState(false)
|
||||
const hasAdvanced =
|
||||
hasTopLevelAdvancedFields(nestedSchema) ||
|
||||
mergedChildren.some((child) => hasTopLevelAdvancedFields(child.schema))
|
||||
const hasAdvanced = hasTopLevelAdvancedFields(nestedSchema)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -135,37 +127,6 @@ function DynamicConfigSection({
|
||||
level={level}
|
||||
advancedVisible={hasAdvanced ? advancedVisible : undefined}
|
||||
/>
|
||||
{mergedChildren.map((child) => {
|
||||
const childTitle = resolveSectionTitle(child.schema)
|
||||
const childDescription = resolveSectionDescription(child.schema, childTitle)
|
||||
const parentPath = basePath.includes('.')
|
||||
? basePath.replace(/\.[^.]+$/, '')
|
||||
: ''
|
||||
const childPath = buildFieldPath(parentPath, child.key)
|
||||
|
||||
return (
|
||||
<div key={child.key} className="mt-5 border-t border-border/50 pt-4">
|
||||
<div className="mb-3 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<SectionIcon iconName={child.schema.uiIcon} />
|
||||
<h3 className="text-sm font-medium">{childTitle}</h3>
|
||||
</div>
|
||||
{childDescription && (
|
||||
<p className="text-xs text-muted-foreground">{childDescription}</p>
|
||||
)}
|
||||
</div>
|
||||
<DynamicConfigForm
|
||||
schema={child.schema}
|
||||
values={child.values}
|
||||
onChange={(field, value) => onChange(`${child.key}.${field}`, value)}
|
||||
basePath={childPath}
|
||||
hooks={hooks}
|
||||
level={level}
|
||||
advancedVisible={hasAdvanced ? advancedVisible : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -197,17 +158,6 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
() => new Map(schema.fields.map((field) => [field.name, field])),
|
||||
[schema.fields],
|
||||
)
|
||||
const mergedChildKeys = React.useMemo(() => {
|
||||
const keys = new Set<string>()
|
||||
for (const nestedSchema of Object.values(schema.nested ?? {})) {
|
||||
for (const childKey of nestedSchema.uiMergeChildren ?? []) {
|
||||
if (schema.nested?.[childKey]) {
|
||||
keys.add(childKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}, [schema.nested])
|
||||
|
||||
const renderField = (field: FieldSchema) => {
|
||||
const fieldPath = buildFieldPath(basePath, field.name)
|
||||
@@ -294,7 +244,6 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
|
||||
{schema.nested &&
|
||||
Object.entries(schema.nested)
|
||||
.filter(([key]) => !mergedChildKeys.has(key))
|
||||
.map(([key, nestedSchema]) => {
|
||||
const nestedField = fieldMap.get(key)
|
||||
const nestedFieldPath = buildFieldPath(basePath, key)
|
||||
@@ -342,34 +291,11 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
|
||||
const sectionTitle = resolveSectionTitle(nestedSchema)
|
||||
const sectionDescription = resolveSectionDescription(nestedSchema, sectionTitle)
|
||||
const mergedChildren = (nestedSchema.uiMergeChildren ?? [])
|
||||
.map((childKey) => {
|
||||
const childSchema = schema.nested?.[childKey]
|
||||
if (!childSchema) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
key: childKey,
|
||||
schema: childSchema,
|
||||
values: (values[childKey] as Record<string, unknown>) || {},
|
||||
}
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
child,
|
||||
): child is {
|
||||
key: string
|
||||
schema: ConfigSchema
|
||||
values: Record<string, unknown>
|
||||
} => Boolean(child),
|
||||
)
|
||||
|
||||
if (level === 0) {
|
||||
return (
|
||||
<DynamicConfigSection
|
||||
key={key}
|
||||
mergedChildren={mergedChildren}
|
||||
nestedSchema={nestedSchema}
|
||||
values={(values[key] as Record<string, unknown>) || {}}
|
||||
onChange={onChange}
|
||||
|
||||
@@ -31,6 +31,23 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const parseNumericValue = (rawValue: unknown, fallback: number) => {
|
||||
if (typeof rawValue === 'number' && Number.isFinite(rawValue)) {
|
||||
return rawValue
|
||||
}
|
||||
|
||||
if (typeof rawValue === 'string') {
|
||||
const parsedValue = schema.type === 'integer'
|
||||
? parseInt(rawValue, 10)
|
||||
: parseFloat(rawValue)
|
||||
if (Number.isFinite(parsedValue)) {
|
||||
return parsedValue
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
const renderPrimitiveArrayEditor = () => {
|
||||
const itemType = schema.items?.type ?? 'string'
|
||||
const arrayValue = Array.isArray(value)
|
||||
@@ -94,6 +111,12 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
return <IconComponent className="h-4 w-4" />
|
||||
}
|
||||
|
||||
const isRuleTypeSelect =
|
||||
schema.name === 'rule_type' &&
|
||||
(schema.type === 'select' || schema['x-widget'] === 'select')
|
||||
const inlineDescription = isRuleTypeSelect ? '' : schema.description
|
||||
const selectHoverDescription = isRuleTypeSelect ? schema.description : undefined
|
||||
|
||||
const renderFieldHeader = () => (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Label
|
||||
@@ -108,9 +131,9 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
<span className="break-all">{schema.label}</span>
|
||||
{schema.required && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
{schema.description && (
|
||||
{inlineDescription && (
|
||||
<span className="text-[13px] leading-6 text-muted-foreground whitespace-pre-line">
|
||||
{schema.description}
|
||||
{inlineDescription}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -129,6 +152,9 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
case 'slider':
|
||||
return renderSlider()
|
||||
case 'input':
|
||||
if (type === 'integer' || type === 'number') {
|
||||
return renderNumberInput()
|
||||
}
|
||||
return renderTextInput()
|
||||
case 'number':
|
||||
return renderNumberInput()
|
||||
@@ -214,7 +240,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
* 渲染 Slider 组件(用于 number 类型 + x-widget: slider)
|
||||
*/
|
||||
const renderSlider = () => {
|
||||
const numValue = typeof value === 'number' ? value : (schema.default as number ?? 0)
|
||||
const numValue = parseNumericValue(value, schema.default as number ?? 0)
|
||||
const min = schema.minValue ?? 0
|
||||
const max = schema.maxValue ?? 100
|
||||
const step = schema.step ?? 1
|
||||
@@ -241,7 +267,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
* 渲染 Input[type="number"] 组件(用于 number/integer 类型)
|
||||
*/
|
||||
const renderNumberInput = () => {
|
||||
const numValue = typeof value === 'number' ? value : (schema.default as number ?? 0)
|
||||
const numValue = parseNumericValue(value, schema.default as number ?? 0)
|
||||
const min = schema.minValue
|
||||
const max = schema.maxValue
|
||||
const step = schema.step ?? (schema.type === 'integer' ? 1 : 0.1)
|
||||
@@ -250,7 +276,12 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
<Input
|
||||
type="number"
|
||||
value={numValue}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
onChange={(e) => {
|
||||
const nextValue = schema.type === 'integer'
|
||||
? parseInt(e.target.value, 10)
|
||||
: parseFloat(e.target.value)
|
||||
onChange(Number.isFinite(nextValue) ? nextValue : 0)
|
||||
}}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
@@ -262,7 +293,12 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
* 渲染 Input[type="text"] 组件(用于 string 类型)
|
||||
*/
|
||||
const renderTextInput = (type: 'password' | 'text' = 'text') => {
|
||||
const strValue = typeof value === 'string' ? value : (schema.default as string ?? '')
|
||||
const strValue =
|
||||
typeof value === 'string'
|
||||
? value
|
||||
: value === null || value === undefined
|
||||
? String(schema.default ?? '')
|
||||
: String(value)
|
||||
return (
|
||||
<Input
|
||||
type={type}
|
||||
@@ -277,11 +313,19 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
*/
|
||||
const renderTextarea = () => {
|
||||
const strValue = typeof value === 'string' ? value : (schema.default as string ?? '')
|
||||
const minHeight = typeof schema['x-textarea-min-height'] === 'number'
|
||||
? schema['x-textarea-min-height']
|
||||
: undefined
|
||||
const rows = typeof schema['x-textarea-rows'] === 'number'
|
||||
? schema['x-textarea-rows']
|
||||
: 4
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
value={strValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={4}
|
||||
rows={rows}
|
||||
minHeight={minHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -303,7 +347,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
|
||||
return (
|
||||
<Select value={strValue} onValueChange={(val) => onChange(val)}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger title={selectHoverDescription}>
|
||||
<SelectValue placeholder={`Select ${schema.label}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -100,6 +100,42 @@ describe('DynamicField', () => {
|
||||
|
||||
expect(screen.getByText('Custom field requires Hook')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders number Input when x-widget is input but type is integer', () => {
|
||||
const schema: FieldSchema = {
|
||||
name: 'test_integer_input_widget',
|
||||
type: 'integer',
|
||||
label: 'Test Integer Input Widget',
|
||||
description: 'A numeric field rendered as input',
|
||||
required: false,
|
||||
'x-widget': 'input',
|
||||
default: 0,
|
||||
}
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(<DynamicField schema={schema} value={2} onChange={onChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).toHaveValue(2)
|
||||
})
|
||||
|
||||
it('parses string values for numeric input widgets', () => {
|
||||
const schema: FieldSchema = {
|
||||
name: 'test_string_number_input_widget',
|
||||
type: 'integer',
|
||||
label: 'Test String Number Input Widget',
|
||||
description: 'A numeric field with legacy string value',
|
||||
required: false,
|
||||
'x-widget': 'input',
|
||||
default: 0,
|
||||
}
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(<DynamicField schema={schema} value="2" onChange={onChange} />)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('type fallback', () => {
|
||||
@@ -305,6 +341,27 @@ describe('DynamicField', () => {
|
||||
await user.type(input, '123')
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('triggers numeric onChange for input widget with integer type', async () => {
|
||||
const schema: FieldSchema = {
|
||||
name: 'test_integer_input_widget_change',
|
||||
type: 'integer',
|
||||
label: 'Test Integer Input Widget Change',
|
||||
description: 'A numeric field rendered as input',
|
||||
required: false,
|
||||
'x-widget': 'input',
|
||||
default: 0,
|
||||
}
|
||||
const onChange = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<DynamicField schema={schema} value={0} onChange={onChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
await user.clear(input)
|
||||
await user.type(input, '5')
|
||||
expect(onChange).toHaveBeenLastCalledWith(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('visual features', () => {
|
||||
@@ -377,6 +434,25 @@ describe('DynamicField', () => {
|
||||
expect(screen.getByText('50')).toBeInTheDocument()
|
||||
expect(screen.getByText('25')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('parses string values for slider widgets', () => {
|
||||
const schema: FieldSchema = {
|
||||
name: 'test_slider_string_value',
|
||||
type: 'number',
|
||||
label: 'Test Slider String Value',
|
||||
description: 'A slider with legacy string value',
|
||||
required: false,
|
||||
'x-widget': 'slider',
|
||||
minValue: 0,
|
||||
maxValue: 10,
|
||||
default: 0,
|
||||
}
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(<DynamicField schema={schema} value="2.5" onChange={onChange} />)
|
||||
|
||||
expect(screen.getByText('2.5')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('select features', () => {
|
||||
|
||||
@@ -552,8 +552,8 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
|
||||
}
|
||||
|
||||
// 获取聊天名称
|
||||
const getChatName = (chatId: string): string => {
|
||||
return chatNameMap.get(chatId) || chatId
|
||||
const getChatName = (expression: Expression): string => {
|
||||
return expression.chat_name || chatNameMap.get(expression.chat_id) || expression.chat_id
|
||||
}
|
||||
|
||||
// 单条审核
|
||||
@@ -1104,8 +1104,8 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
|
||||
<div className="flex flex-wrap items-center gap-1 sm:gap-2 text-xs text-muted-foreground">
|
||||
<span>#{expr.id}</span>
|
||||
<span>·</span>
|
||||
<span title={getChatName(expr.chat_id)} className="truncate max-w-24 sm:max-w-32">
|
||||
{getChatName(expr.chat_id)}
|
||||
<span title={getChatName(expr)} className="truncate max-w-24 sm:max-w-32">
|
||||
{getChatName(expr)}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{formatTime(expr.create_date)}</span>
|
||||
@@ -1585,8 +1585,8 @@ if (isCurrent) {
|
||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||
<User className="h-3 w-3" />
|
||||
</div>
|
||||
<span title={getChatName(expr.chat_id)} className="truncate max-w-[120px] font-medium">
|
||||
{getChatName(expr.chat_id)}
|
||||
<span title={getChatName(expr)} className="truncate max-w-[120px] font-medium">
|
||||
{getChatName(expr)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono">{formatTime(expr.create_date)}</span>
|
||||
@@ -1638,8 +1638,8 @@ if (isCurrent) {
|
||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||
<User className="h-3 w-3" />
|
||||
</div>
|
||||
<span title={getChatName(expr.chat_id)} className="truncate max-w-[120px] font-medium">
|
||||
{getChatName(expr.chat_id)}
|
||||
<span title={getChatName(expr)} className="truncate max-w-[120px] font-medium">
|
||||
{getChatName(expr)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono">{formatTime(expr.create_date)}</span>
|
||||
|
||||
@@ -29,9 +29,9 @@
|
||||
"modelManagement": "模型管理与分配",
|
||||
"promptManagement": "Prompt 管理",
|
||||
"adapterConfig": "麦麦适配器配置",
|
||||
"emojiManagement": "表情包管理",
|
||||
"expressionManagement": "表达方式管理",
|
||||
"slangManagement": "黑话管理",
|
||||
"emojiManagement": "表情包",
|
||||
"expressionManagement": "表达方式",
|
||||
"slangManagement": "黑话",
|
||||
"personInfo": "人物信息管理",
|
||||
"knowledgeGraph": "长期记忆图谱",
|
||||
"knowledgeBase": "长期记忆",
|
||||
@@ -779,13 +779,13 @@
|
||||
"modelProviderDesc": "配置模型提供商",
|
||||
"model": "麦麦模型配置",
|
||||
"modelDesc": "配置模型参数",
|
||||
"emoji": "表情包管理",
|
||||
"emoji": "表情包",
|
||||
"emojiDesc": "管理麦麦的表情包",
|
||||
"expression": "表达方式管理",
|
||||
"expression": "表达方式",
|
||||
"expressionDesc": "管理麦麦的表达方式",
|
||||
"person": "人物信息管理",
|
||||
"personDesc": "管理人物信息",
|
||||
"jargon": "黑话管理",
|
||||
"jargon": "黑话",
|
||||
"jargonDesc": "管理麦麦学习到的黑话和俚语",
|
||||
"statistics": "统计信息",
|
||||
"statisticsDesc": "查看使用统计",
|
||||
|
||||
@@ -13,7 +13,7 @@ const API_BASE = '/api/webui/config'
|
||||
* 获取麦麦主程序配置架构
|
||||
*/
|
||||
export async function getBotConfigSchema(): Promise<ApiResponse<ConfigSchema>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/schema/bot`)
|
||||
const response = await fetchWithAuth(`${API_BASE}/schema/bot`, { cache: 'no-store' })
|
||||
return parseResponse<ConfigSchema>(response)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function getBotConfigSchema(): Promise<ApiResponse<ConfigSchema>> {
|
||||
* 获取模型配置架构
|
||||
*/
|
||||
export async function getModelConfigSchema(): Promise<ApiResponse<ConfigSchema>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/schema/model`)
|
||||
const response = await fetchWithAuth(`${API_BASE}/schema/model`, { cache: 'no-store' })
|
||||
return parseResponse<ConfigSchema>(response)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function getModelConfigSchema(): Promise<ApiResponse<ConfigSchema>>
|
||||
* 获取指定配置节的架构
|
||||
*/
|
||||
export async function getConfigSectionSchema(sectionName: string): Promise<ApiResponse<ConfigSchema>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/schema/section/${sectionName}`)
|
||||
const response = await fetchWithAuth(`${API_BASE}/schema/section/${sectionName}`, { cache: 'no-store' })
|
||||
return parseResponse<ConfigSchema>(response)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function getConfigSectionSchema(sectionName: string): Promise<ApiRe
|
||||
* 获取麦麦主程序配置数据
|
||||
*/
|
||||
export async function getBotConfig(): Promise<ApiResponse<Record<string, unknown>>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/bot`)
|
||||
const response = await fetchWithAuth(`${API_BASE}/bot`, { cache: 'no-store' })
|
||||
return parseResponse<Record<string, unknown>>(response)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function getBotConfig(): Promise<ApiResponse<Record<string, unknown
|
||||
* 获取模型配置数据
|
||||
*/
|
||||
export async function getModelConfig(): Promise<ApiResponse<Record<string, unknown>>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/model`)
|
||||
const response = await fetchWithAuth(`${API_BASE}/model`, { cache: 'no-store' })
|
||||
return parseResponse<Record<string, unknown>>(response)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function updateBotConfig(
|
||||
* 获取麦麦主程序配置的原始 TOML 内容
|
||||
*/
|
||||
export async function getBotConfigRaw(): Promise<ApiResponse<string>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/bot/raw`)
|
||||
const response = await fetchWithAuth(`${API_BASE}/bot/raw`, { cache: 'no-store' })
|
||||
return parseResponse<string>(response)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,10 +24,11 @@ import { getBotConfig, getBotConfigRaw, getBotConfigSchema, updateBotConfig, upd
|
||||
import { fieldHooks } from '@/lib/field-hooks'
|
||||
import { RestartProvider, useRestart } from '@/lib/restart-context'
|
||||
|
||||
import { Code2, Info, Layout, Power, Save } from 'lucide-react'
|
||||
import { ChevronDown, ChevronUp, Code2, Info, Layout, Power, RefreshCw, Save } from 'lucide-react'
|
||||
|
||||
import type { ConfigSchema } from '@/types/config-schema'
|
||||
import {
|
||||
ChatPromptsHook,
|
||||
ChatTalkValueRulesHook,
|
||||
ExpressionGroupsHook,
|
||||
ExpressionLearningListHook,
|
||||
@@ -50,13 +51,27 @@ const TAB_ORDER = [
|
||||
'personality',
|
||||
'chat',
|
||||
'expression',
|
||||
'visual',
|
||||
'a_memorix',
|
||||
'message_receive',
|
||||
'emoji',
|
||||
'voice',
|
||||
'response_post_process',
|
||||
'webui',
|
||||
'plugin_runtime',
|
||||
'log',
|
||||
]
|
||||
|
||||
/** 默认展示的主配置栏目 */
|
||||
const DEFAULT_VISIBLE_TAB_IDS = new Set([
|
||||
'bot',
|
||||
'personality',
|
||||
'chat',
|
||||
'expression',
|
||||
'visual',
|
||||
'a_memorix',
|
||||
])
|
||||
|
||||
// ==================== Tab 分组类型与构建 ====================
|
||||
interface TabGroup {
|
||||
id: string
|
||||
@@ -143,6 +158,9 @@ function BotConfigPageContent() {
|
||||
const [sourceCode, setSourceCode] = useState<string>('')
|
||||
const [hasTomlError, setHasTomlError] = useState(false)
|
||||
const [tomlErrorMessage, setTomlErrorMessage] = useState<string>('')
|
||||
const [restartNoticeVisible, setRestartNoticeVisible] = useState(
|
||||
() => localStorage.getItem('bot-config-restart-notice-dismissed') !== 'true'
|
||||
)
|
||||
const { toast } = useToast()
|
||||
const { triggerRestart, isRestarting } = useRestart()
|
||||
|
||||
@@ -160,6 +178,7 @@ function BotConfigPageContent() {
|
||||
const [responsePostProcessConfig, setResponsePostProcessConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [chineseTypoConfig, setChineseTypoConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [responseSplitterConfig, setResponseSplitterConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [logConfig, setLogConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [debugConfig, setDebugConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [maimMessageConfig, setMaimMessageConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [telemetryConfig, setTelemetryConfig] = useState<ConfigSectionData | null>(null)
|
||||
@@ -174,6 +193,7 @@ function BotConfigPageContent() {
|
||||
|
||||
// 用于标记初始加载和配置缓存
|
||||
const initialLoadRef = useRef(true)
|
||||
const suppressAutoSaveRef = useRef(false)
|
||||
const configRef = useRef<Record<string, unknown>>({})
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
@@ -240,6 +260,7 @@ function BotConfigPageContent() {
|
||||
* 抽取自 loadConfig 和 handleModeChange 中的重复逻辑
|
||||
*/
|
||||
const parseAndSetConfig = useCallback((config: Record<string, unknown>) => {
|
||||
suppressAutoSaveRef.current = true
|
||||
configRef.current = config
|
||||
|
||||
setBotConfig((config.bot ?? {}) as ConfigSectionData)
|
||||
@@ -255,6 +276,7 @@ function BotConfigPageContent() {
|
||||
setResponsePostProcessConfig((config.response_post_process ?? {}) as ConfigSectionData)
|
||||
setChineseTypoConfig((config.chinese_typo ?? {}) as ConfigSectionData)
|
||||
setResponseSplitterConfig((config.response_splitter ?? {}) as ConfigSectionData)
|
||||
setLogConfig((config.log ?? {}) as ConfigSectionData)
|
||||
setDebugConfig((config.debug ?? {}) as ConfigSectionData)
|
||||
setMaimMessageConfig((config.maim_message ?? {}) as ConfigSectionData)
|
||||
setTelemetryConfig((config.telemetry ?? {}) as ConfigSectionData)
|
||||
@@ -263,6 +285,10 @@ function BotConfigPageContent() {
|
||||
setMcpConfig((config.mcp ?? {}) as ConfigSectionData)
|
||||
setPluginRuntimeConfig((config.plugin_runtime ?? {}) as ConfigSectionData)
|
||||
setAMemorixConfig((config.a_memorix ?? {}) as ConfigSectionData)
|
||||
|
||||
window.setTimeout(() => {
|
||||
suppressAutoSaveRef.current = false
|
||||
}, 0)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
@@ -285,6 +311,7 @@ function BotConfigPageContent() {
|
||||
response_post_process: responsePostProcessConfig,
|
||||
chinese_typo: chineseTypoConfig,
|
||||
response_splitter: responseSplitterConfig,
|
||||
log: logConfig,
|
||||
debug: debugConfig,
|
||||
maim_message: maimMessageConfig,
|
||||
telemetry: telemetryConfig,
|
||||
@@ -308,6 +335,7 @@ function BotConfigPageContent() {
|
||||
responsePostProcessConfig,
|
||||
chineseTypoConfig,
|
||||
responseSplitterConfig,
|
||||
logConfig,
|
||||
debugConfig,
|
||||
maimMessageConfig,
|
||||
telemetryConfig,
|
||||
@@ -394,6 +422,7 @@ function BotConfigPageContent() {
|
||||
|
||||
useEffect(() => {
|
||||
const hookEntries = [
|
||||
['chat.chat_prompts', ChatPromptsHook],
|
||||
['chat.talk_value_rules', ChatTalkValueRulesHook],
|
||||
['expression.expression_groups', ExpressionGroupsHook],
|
||||
['expression.learning_list', ExpressionLearningListHook],
|
||||
@@ -421,30 +450,41 @@ function BotConfigPageContent() {
|
||||
setHasUnsavedChanges
|
||||
)
|
||||
|
||||
const triggerConfigAutoSave = useCallback(
|
||||
(sectionName: Parameters<typeof triggerAutoSave>[0], data: unknown) => {
|
||||
if (suppressAutoSaveRef.current) {
|
||||
return
|
||||
}
|
||||
triggerAutoSave(sectionName, data)
|
||||
},
|
||||
[triggerAutoSave]
|
||||
)
|
||||
|
||||
// 使用 useConfigAutoSave hook 简化配置变化监听
|
||||
// 注意: useConfigAutoSave 是一个 hook,不能在条件语句或循环中调用
|
||||
// 因此我们仍然需要逐个调用,但代码更简洁
|
||||
useConfigAutoSave(botConfig, 'bot', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(personalityConfig, 'personality', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(chatConfig, 'chat', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(expressionConfig, 'expression', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(emojiConfig, 'emoji', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(memoryConfig, 'memory', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(visualConfig, 'visual', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(voiceConfig, 'voice', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(messageReceiveConfig, 'message_receive', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(keywordReactionConfig, 'keyword_reaction', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(responsePostProcessConfig, 'response_post_process', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(chineseTypoConfig, 'chinese_typo', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(responseSplitterConfig, 'response_splitter', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(debugConfig, 'debug', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(maimMessageConfig, 'maim_message', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(telemetryConfig, 'telemetry', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(webuiConfig, 'webui', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(databaseConfig, 'database', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(mcpConfig, 'mcp', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(pluginRuntimeConfig, 'plugin_runtime', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(aMemorixConfig, 'a_memorix', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(botConfig, 'bot', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(personalityConfig, 'personality', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(chatConfig, 'chat', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(expressionConfig, 'expression', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(emojiConfig, 'emoji', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(memoryConfig, 'memory', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(visualConfig, 'visual', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(voiceConfig, 'voice', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(messageReceiveConfig, 'message_receive', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(keywordReactionConfig, 'keyword_reaction', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(responsePostProcessConfig, 'response_post_process', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(chineseTypoConfig, 'chinese_typo', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(responseSplitterConfig, 'response_splitter', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(logConfig, 'log', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(debugConfig, 'debug', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(maimMessageConfig, 'maim_message', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(telemetryConfig, 'telemetry', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(webuiConfig, 'webui', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(databaseConfig, 'database', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(mcpConfig, 'mcp', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(pluginRuntimeConfig, 'plugin_runtime', initialLoadRef.current, triggerConfigAutoSave)
|
||||
useConfigAutoSave(aMemorixConfig, 'a_memorix', initialLoadRef.current, triggerConfigAutoSave)
|
||||
|
||||
// 保存源代码
|
||||
const saveSourceCode = async () => {
|
||||
@@ -592,6 +632,21 @@ function BotConfigPageContent() {
|
||||
await triggerRestart()
|
||||
}
|
||||
|
||||
const dismissRestartNotice = () => {
|
||||
localStorage.setItem('bot-config-restart-notice-dismissed', 'true')
|
||||
setRestartNoticeVisible(false)
|
||||
}
|
||||
|
||||
const handleReloadFromFile = async () => {
|
||||
cancelPendingAutoSave()
|
||||
await loadConfig()
|
||||
setHasUnsavedChanges(false)
|
||||
toast({
|
||||
title: '已刷新',
|
||||
description: '已从 bot_config.toml 重新读取配置',
|
||||
})
|
||||
}
|
||||
|
||||
// 保存并重启
|
||||
const handleSaveAndRestart = async () => {
|
||||
try {
|
||||
@@ -650,6 +705,7 @@ function BotConfigPageContent() {
|
||||
response_post_process: responsePostProcessConfig,
|
||||
chinese_typo: chineseTypoConfig,
|
||||
response_splitter: responseSplitterConfig,
|
||||
log: logConfig,
|
||||
debug: debugConfig,
|
||||
maim_message: maimMessageConfig,
|
||||
telemetry: telemetryConfig,
|
||||
@@ -673,6 +729,7 @@ function BotConfigPageContent() {
|
||||
responsePostProcessConfig,
|
||||
chineseTypoConfig,
|
||||
responseSplitterConfig,
|
||||
logConfig,
|
||||
debugConfig,
|
||||
maimMessageConfig,
|
||||
telemetryConfig,
|
||||
@@ -699,6 +756,7 @@ function BotConfigPageContent() {
|
||||
response_post_process: setResponsePostProcessConfig,
|
||||
chinese_typo: setChineseTypoConfig,
|
||||
response_splitter: setResponseSplitterConfig,
|
||||
log: setLogConfig,
|
||||
debug: setDebugConfig,
|
||||
maim_message: setMaimMessageConfig,
|
||||
telemetry: setTelemetryConfig,
|
||||
@@ -736,6 +794,16 @@ function BotConfigPageContent() {
|
||||
</div>
|
||||
{/* 按钮组 - 桌面端靠右 */}
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<Button
|
||||
onClick={handleReloadFromFile}
|
||||
disabled={saving || autoSaving || isRestarting}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-20 sm:w-24"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
onClick={editMode === 'visual' ? saveConfig : saveSourceCode}
|
||||
disabled={saving || autoSaving || !hasUnsavedChanges || isRestarting}
|
||||
@@ -804,12 +872,19 @@ function BotConfigPageContent() {
|
||||
</div>
|
||||
|
||||
{/* 重启提示 */}
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
配置更新后需要<strong>重启麦麦</strong>才能生效。你可以点击右上角的"保存并重启"按钮一键完成保存和重启。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{restartNoticeVisible && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>
|
||||
配置更新后需要<strong>重启麦麦</strong>才能生效。你可以点击右上角的"保存并重启"按钮一键完成保存和重启。
|
||||
</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={dismissRestartNotice}>
|
||||
我知道了
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 源代码模式 */}
|
||||
{editMode === 'source' && (
|
||||
@@ -903,11 +978,34 @@ interface DynamicConfigTabsProps {
|
||||
|
||||
function DynamicConfigTabs(props: DynamicConfigTabsProps) {
|
||||
const { configSchema, sectionValues, setHasUnsavedChanges, setSectionValue, tabGroups } = props
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState(tabGroups[0]?.id ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
if (!tabGroups.some((tab) => tab.id === activeTab)) {
|
||||
setActiveTab(tabGroups[0]?.id ?? '')
|
||||
}
|
||||
}, [activeTab, tabGroups])
|
||||
|
||||
if (tabGroups.length === 0 || !configSchema?.nested) {
|
||||
return null
|
||||
}
|
||||
|
||||
const visibleTabGroups = expanded
|
||||
? tabGroups
|
||||
: tabGroups.filter((tab) => DEFAULT_VISIBLE_TAB_IDS.has(tab.id))
|
||||
const hasCollapsibleTabs = tabGroups.some((tab) => !DEFAULT_VISIBLE_TAB_IDS.has(tab.id))
|
||||
|
||||
const toggleExpanded = () => {
|
||||
setExpanded((current) => {
|
||||
if (current && !DEFAULT_VISIBLE_TAB_IDS.has(activeTab)) {
|
||||
const firstDefaultTab = tabGroups.find((tab) => DEFAULT_VISIBLE_TAB_IDS.has(tab.id))
|
||||
setActiveTab(firstDefaultTab?.id ?? tabGroups[0]?.id ?? '')
|
||||
}
|
||||
return !current
|
||||
})
|
||||
}
|
||||
|
||||
const renderTabContent = (tab: TabGroup) => {
|
||||
const tabNestedEntries = tab.sections
|
||||
.map((sectionName) => [sectionName, configSchema.nested?.[sectionName]] as const)
|
||||
@@ -953,9 +1051,9 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultValue={tabGroups[0].id} className="w-full">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="flex flex-wrap h-auto gap-1 p-1">
|
||||
{tabGroups.map((tab) => (
|
||||
{visibleTabGroups.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
@@ -964,6 +1062,22 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
{hasCollapsibleTabs && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs sm:h-9 sm:px-3"
|
||||
onClick={toggleExpanded}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="mr-1 h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{expanded ? '收起' : '更多'}
|
||||
</Button>
|
||||
)}
|
||||
</TabsList>
|
||||
{tabGroups.map((tab) => (
|
||||
<TabsContent key={tab.id} value={tab.id} className="space-y-4">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useCallback, useMemo, type CSSProperties } from 'react'
|
||||
import * as LucideIcons from 'lucide-react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
|
||||
@@ -31,6 +31,12 @@ export interface ListItemEditorOptions {
|
||||
emptyText?: string
|
||||
/** 顶部图标(覆盖 schema 自带的 x-icon) */
|
||||
iconName?: string
|
||||
/** 紧凑布局:把指定字段放在同一行展示 */
|
||||
fieldRows?: string[][]
|
||||
/** Hook-local field UI metadata overrides */
|
||||
fieldSchemaOverrides?: Record<string, Partial<FieldSchema>>
|
||||
/** 添加按钮位置 */
|
||||
addButtonPlacement?: 'top' | 'bottom'
|
||||
}
|
||||
|
||||
function resolveLabel(schema?: ConfigSchema | FieldSchema, fieldPath?: string): string {
|
||||
@@ -190,9 +196,105 @@ export function createListItemEditorHook(
|
||||
[items, onChange],
|
||||
)
|
||||
|
||||
const renderItemEditor = (item: Record<string, unknown>, index: number) => {
|
||||
if (!nestedSchema) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!options.fieldRows?.length) {
|
||||
return (
|
||||
<DynamicConfigForm
|
||||
schema={nestedSchema}
|
||||
values={item}
|
||||
onChange={(field, fieldValue) =>
|
||||
handleItemFieldChange(index, field, fieldValue)
|
||||
}
|
||||
basePath=""
|
||||
level={1}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const applyFieldOverride = (field: FieldSchema): FieldSchema => ({
|
||||
...field,
|
||||
...(options.fieldSchemaOverrides?.[field.name] ?? {}),
|
||||
})
|
||||
const fieldMap = new Map(
|
||||
nestedSchema.fields.map((field) => [field.name, applyFieldOverride(field)]),
|
||||
)
|
||||
const rowFieldNames = new Set(options.fieldRows.flat())
|
||||
const remainingFields = nestedSchema.fields
|
||||
.filter((field) => !rowFieldNames.has(field.name))
|
||||
.map(applyFieldOverride)
|
||||
const buildRowSchema = (fields: FieldSchema[]): ConfigSchema => ({
|
||||
...nestedSchema,
|
||||
fields,
|
||||
nested: undefined,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{options.fieldRows.map((row, rowIndex) => {
|
||||
const fields = row
|
||||
.map((fieldName) => fieldMap.get(fieldName))
|
||||
.filter((field): field is FieldSchema => Boolean(field))
|
||||
|
||||
if (fields.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={rowIndex}
|
||||
className="grid gap-3 md:grid-cols-[repeat(var(--field-count),minmax(0,1fr))]"
|
||||
style={{ '--field-count': fields.length } as CSSProperties}
|
||||
>
|
||||
{fields.map((field) => (
|
||||
<DynamicConfigForm
|
||||
key={field.name}
|
||||
schema={buildRowSchema([field])}
|
||||
values={item}
|
||||
onChange={(fieldName, fieldValue) =>
|
||||
handleItemFieldChange(index, fieldName, fieldValue)
|
||||
}
|
||||
basePath=""
|
||||
level={1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{remainingFields.length > 0 && (
|
||||
<DynamicConfigForm
|
||||
schema={buildRowSchema(remainingFields)}
|
||||
values={item}
|
||||
onChange={(field, fieldValue) =>
|
||||
handleItemFieldChange(index, field, fieldValue)
|
||||
}
|
||||
basePath=""
|
||||
level={1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const label = resolveLabel(schema, fieldPath)
|
||||
const description = resolveDescription(schema)
|
||||
const iconName = resolveIconName(options.iconName, schema, nestedSchema)
|
||||
const addButtonPlacement = options.addButtonPlacement ?? 'bottom'
|
||||
const addButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{options.addLabel ?? '添加一项'}
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (!nestedSchema) {
|
||||
return (
|
||||
@@ -220,6 +322,7 @@ export function createListItemEditorHook(
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{addButtonPlacement === 'top' && addButton}
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-6 text-center text-sm text-muted-foreground">
|
||||
{options.emptyText ?? '尚未添加任何条目,点击下方按钮新增。'}
|
||||
@@ -251,29 +354,12 @@ export function createListItemEditorHook(
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
<DynamicConfigForm
|
||||
schema={nestedSchema}
|
||||
values={item}
|
||||
onChange={(field, fieldValue) =>
|
||||
handleItemFieldChange(index, field, fieldValue)
|
||||
}
|
||||
basePath=""
|
||||
level={1}
|
||||
/>
|
||||
{renderItemEditor(item, index)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{options.addLabel ?? '添加一项'}
|
||||
</Button>
|
||||
{addButtonPlacement === 'bottom' && addButton}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -30,8 +30,13 @@ const collectStringList = (value: unknown): string[] => {
|
||||
|
||||
export const ChatTalkValueRulesHook = createListItemEditorHook({
|
||||
addLabel: '添加发言频率规则',
|
||||
addButtonPlacement: 'top',
|
||||
helperText: '可按平台/聊天流/时段分别配置发言频率,留空表示全局。',
|
||||
emptyText: '尚未配置任何规则,将使用全局默认频率。',
|
||||
fieldRows: [
|
||||
['platform', 'item_id', 'rule_type'],
|
||||
['time', 'value'],
|
||||
],
|
||||
itemTitle: (item) => {
|
||||
const time =
|
||||
typeof item.time === 'string' && item.time.trim()
|
||||
@@ -43,10 +48,45 @@ export const ChatTalkValueRulesHook = createListItemEditorHook({
|
||||
},
|
||||
})
|
||||
|
||||
export const ChatPromptsHook = createListItemEditorHook({
|
||||
addLabel: '添加额外 Prompt',
|
||||
helperText: '为指定平台和聊天流添加额外提示。platform、item_id 和 prompt 同时留空时表示空条目;填写任意一项后这三项都需要填写。',
|
||||
emptyText: '尚未配置任何聊天额外 Prompt。',
|
||||
addButtonPlacement: 'top',
|
||||
fieldRows: [['platform', 'item_id', 'rule_type']],
|
||||
fieldSchemaOverrides: {
|
||||
item_id: {
|
||||
'x-input-width': '8rem',
|
||||
'x-layout': 'inline-right',
|
||||
},
|
||||
platform: {
|
||||
'x-input-width': '8rem',
|
||||
'x-layout': 'inline-right',
|
||||
},
|
||||
prompt: {
|
||||
'x-textarea-min-height': 38,
|
||||
'x-textarea-rows': 1,
|
||||
},
|
||||
rule_type: {
|
||||
'x-input-width': '8rem',
|
||||
'x-layout': 'inline-right',
|
||||
},
|
||||
},
|
||||
iconName: 'file-text',
|
||||
itemTitle: (item) => {
|
||||
const prompt = typeof item.prompt === 'string' ? item.prompt.trim() : ''
|
||||
return `${platformLabel(item)} · ${ruleTypeLabel(item.rule_type)} · ${prompt ? truncate(prompt) : '未填写 Prompt'}`
|
||||
},
|
||||
})
|
||||
|
||||
export const ExpressionLearningListHook = createListItemEditorHook({
|
||||
addLabel: '添加表达学习规则',
|
||||
helperText: '为不同聊天流单独配置是否启用表达/jargon 学习。',
|
||||
emptyText: '尚未配置任何学习规则。',
|
||||
fieldRows: [
|
||||
['platform', 'item_id', 'rule_type'],
|
||||
['use_expression', 'enable_learning', 'enable_jargon_learning'],
|
||||
],
|
||||
itemTitle: (item) => {
|
||||
const flags: string[] = []
|
||||
if (item.use_expression) flags.push('表达')
|
||||
|
||||
@@ -11,6 +11,7 @@ export type {
|
||||
UseAutoSaveReturnGeneric,
|
||||
} from './useAutoSave'
|
||||
export {
|
||||
ChatPromptsHook,
|
||||
ChatTalkValueRulesHook,
|
||||
ExpressionGroupsHook,
|
||||
ExpressionLearningListHook,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef, type MouseEvent } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -106,7 +106,14 @@ function ModelConfigPageContent() {
|
||||
const [jumpToPage, setJumpToPage] = useState('')
|
||||
|
||||
const [advancedTemperatureMode, setAdvancedTemperatureMode] = useState(false)
|
||||
const [advancedModelSettingsVisible, setAdvancedModelSettingsVisible] = useState(false)
|
||||
const [advancedTaskSettingsVisible, setAdvancedTaskSettingsVisible] = useState(false)
|
||||
const [restartNoticeVisible, setRestartNoticeVisible] = useState(
|
||||
() => localStorage.getItem('model-config-restart-notice-dismissed') !== 'true'
|
||||
)
|
||||
const [tourEntryVisible, setTourEntryVisible] = useState(
|
||||
() => localStorage.getItem('model-assignment-tour-entry-dismissed') !== 'true'
|
||||
)
|
||||
|
||||
// 模型 Combobox 状态
|
||||
const [modelComboboxOpen, setModelComboboxOpen] = useState(false)
|
||||
@@ -130,11 +137,6 @@ function ModelConfigPageContent() {
|
||||
const { toast } = useToast()
|
||||
const { triggerRestart, isRestarting } = useRestart()
|
||||
|
||||
// Tour 引导 (使用 hook 封装的逻辑)
|
||||
const { startTour: handleStartTour, isRunning: tourIsRunning } = useModelTour({
|
||||
onCloseEditDialog: () => setEditDialogOpen(false),
|
||||
})
|
||||
|
||||
// 自动保存 (使用 hook 封装的逻辑)
|
||||
const { clearTimers: clearAutoSaveTimers, initialLoadRef } = useModelAutoSave({
|
||||
models,
|
||||
@@ -251,6 +253,17 @@ function ModelConfigPageContent() {
|
||||
const handleRestart = async () => {
|
||||
await triggerRestart()
|
||||
}
|
||||
|
||||
const dismissRestartNotice = () => {
|
||||
localStorage.setItem('model-config-restart-notice-dismissed', 'true')
|
||||
setRestartNoticeVisible(false)
|
||||
}
|
||||
|
||||
const dismissTourEntry = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
localStorage.setItem('model-assignment-tour-entry-dismissed', 'true')
|
||||
setTourEntryVisible(false)
|
||||
}
|
||||
|
||||
// 一键删除所有无效模型引用
|
||||
const handleRemoveInvalidRefs = useCallback(() => {
|
||||
@@ -285,6 +298,9 @@ function ModelConfigPageContent() {
|
||||
api_provider: model.api_provider,
|
||||
price_in: model.price_in ?? 0,
|
||||
price_out: model.price_out ?? 0,
|
||||
cache: model.cache ?? false,
|
||||
cache_price_in: model.cache_price_in ?? 0,
|
||||
visual: model.visual ?? false,
|
||||
force_stream_mode: model.force_stream_mode ?? false,
|
||||
extra_params: model.extra_params ?? {},
|
||||
}
|
||||
@@ -406,16 +422,26 @@ function ModelConfigPageContent() {
|
||||
api_provider: providers[0] || '',
|
||||
price_in: 0,
|
||||
price_out: 0,
|
||||
cache: false,
|
||||
cache_price_in: 0,
|
||||
temperature: null,
|
||||
max_tokens: null,
|
||||
visual: false,
|
||||
force_stream_mode: false,
|
||||
extra_params: {},
|
||||
}
|
||||
)
|
||||
setAdvancedModelSettingsVisible(false)
|
||||
setEditingIndex(index)
|
||||
setEditDialogOpen(true)
|
||||
}
|
||||
|
||||
// Tour 引导 (使用 hook 封装的逻辑)
|
||||
const { startTour: handleStartTour, isRunning: tourIsRunning } = useModelTour({
|
||||
onOpenEditDialog: () => openEditDialog(null, null),
|
||||
onCloseEditDialog: () => setEditDialogOpen(false),
|
||||
})
|
||||
|
||||
// 保存编辑
|
||||
const handleSaveEdit = () => {
|
||||
if (!editingModel) return
|
||||
@@ -459,6 +485,9 @@ function ModelConfigPageContent() {
|
||||
api_provider: editingModel.api_provider,
|
||||
price_in: editingModel.price_in ?? 0,
|
||||
price_out: editingModel.price_out ?? 0,
|
||||
cache: editingModel.cache ?? false,
|
||||
cache_price_in: editingModel.cache_price_in ?? 0,
|
||||
visual: editingModel.visual ?? false,
|
||||
force_stream_mode: editingModel.force_stream_mode ?? false,
|
||||
extra_params: editingModel.extra_params ?? {},
|
||||
}
|
||||
@@ -792,12 +821,19 @@ function ModelConfigPageContent() {
|
||||
</div>
|
||||
|
||||
{/* 重启提示 */}
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
配置更新后需要<strong>重启麦麦</strong>才能生效。你可以点击右上角的"保存并重启"按钮一键完成保存和重启。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{restartNoticeVisible && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>
|
||||
配置更新后需要<strong>重启麦麦</strong>才能生效。你可以点击右上角的"保存并重启"按钮一键完成保存和重启。
|
||||
</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={dismissRestartNotice}>
|
||||
我知道了
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 无效模型引用警告 */}
|
||||
{invalidModelRefs.length > 0 && (
|
||||
@@ -841,23 +877,30 @@ function ModelConfigPageContent() {
|
||||
|
||||
|
||||
{/* 新手引导入口 - 仅在桌面端显示,移动端隐藏 */}
|
||||
{tourEntryVisible && (
|
||||
<Alert className="hidden lg:flex border-primary/30 bg-primary/5 cursor-pointer hover:bg-primary/10 transition-colors" onClick={handleStartTour}>
|
||||
<GraduationCap className="h-4 w-4 text-primary" />
|
||||
<AlertDescription className="flex items-center justify-between">
|
||||
<span>
|
||||
<strong className="text-primary">新手引导:</strong>不知道如何配置模型?点击这里开始学习如何为麦麦的组件分配模型。
|
||||
</span>
|
||||
<Button variant="outline" size="sm" className="ml-4 shrink-0">
|
||||
<div className="ml-4 flex shrink-0 items-center gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
开始引导
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={dismissTourEntry}>
|
||||
鍏抽棴
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 标签页 */}
|
||||
<Tabs defaultValue="models" className="w-full">
|
||||
<TabsList className="grid w-full max-w-full sm:max-w-md grid-cols-2">
|
||||
<TabsTrigger value="models">添加模型</TabsTrigger>
|
||||
<TabsTrigger value="tasks" data-tour="tasks-tab-trigger">为模型分配功能</TabsTrigger>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="models" className="w-full">添加模型</TabsTrigger>
|
||||
<TabsTrigger value="tasks" className="w-full" data-tour="tasks-tab-trigger">为模型分配功能</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* 模型配置标签页 */}
|
||||
<TabsContent value="models" className="space-y-4 mt-0">
|
||||
@@ -976,6 +1019,7 @@ function ModelConfigPageContent() {
|
||||
modelNames={modelNames}
|
||||
onChange={(f, value) => updateTaskConfig(field.name, f, value)}
|
||||
advanced={field.advanced}
|
||||
showAdvancedSettings={advancedTaskSettingsVisible}
|
||||
{...(index === 0 ? { dataTour: 'task-model-select' } : {})}
|
||||
/>
|
||||
)
|
||||
@@ -997,64 +1041,89 @@ function ModelConfigPageContent() {
|
||||
<DialogTitle>
|
||||
{editingIndex !== null ? '编辑模型' : '添加模型'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>配置模型的基本信息和参数</DialogDescription>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<DialogDescription>配置模型的基本信息和参数</DialogDescription>
|
||||
<Button
|
||||
type="button"
|
||||
variant={advancedModelSettingsVisible ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setAdvancedModelSettingsVisible((current) => !current)}
|
||||
className="self-start sm:self-auto"
|
||||
>
|
||||
高级设置
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2" data-tour="model-name-input">
|
||||
<Label htmlFor="model_name" className={formErrors.name ? 'text-destructive' : ''}>模型名称 *</Label>
|
||||
<Input
|
||||
id="model_name"
|
||||
value={editingModel?.name || ''}
|
||||
onChange={(e) => {
|
||||
setEditingModel((prev) =>
|
||||
prev ? { ...prev, name: e.target.value } : null
|
||||
)
|
||||
if (formErrors.name) {
|
||||
setFormErrors((prev) => ({ ...prev, name: undefined }))
|
||||
}
|
||||
}}
|
||||
placeholder="例如: qwen3-30b"
|
||||
className={formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Label
|
||||
htmlFor="model_name"
|
||||
className={`sm:w-28 sm:flex-shrink-0 ${formErrors.name ? 'text-destructive' : ''}`}
|
||||
>
|
||||
模型名称 *
|
||||
</Label>
|
||||
<Input
|
||||
id="model_name"
|
||||
value={editingModel?.name || ''}
|
||||
onChange={(e) => {
|
||||
setEditingModel((prev) =>
|
||||
prev ? { ...prev, name: e.target.value } : null
|
||||
)
|
||||
if (formErrors.name) {
|
||||
setFormErrors((prev) => ({ ...prev, name: undefined }))
|
||||
}
|
||||
}}
|
||||
placeholder="例如: qwen3-30b"
|
||||
className={`sm:flex-1 ${formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
{formErrors.name ? (
|
||||
<p className="text-xs text-destructive">{formErrors.name}</p>
|
||||
<p className="text-xs text-destructive sm:pl-28">{formErrors.name}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground sm:pl-28">
|
||||
用于在任务配置中引用此模型
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2" data-tour="model-provider-select">
|
||||
<Label htmlFor="api_provider" className={formErrors.api_provider ? 'text-destructive' : ''}>API 提供商 *</Label>
|
||||
<Select
|
||||
value={editingModel?.api_provider || ''}
|
||||
onValueChange={(value) => {
|
||||
setEditingModel((prev) =>
|
||||
prev ? { ...prev, api_provider: value } : null
|
||||
)
|
||||
// 清空模型列表和错误状态,等待 useEffect 重新获取
|
||||
clearModels()
|
||||
if (formErrors.api_provider) {
|
||||
setFormErrors((prev) => ({ ...prev, api_provider: undefined }))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="api_provider" className={formErrors.api_provider ? 'border-destructive focus-visible:ring-destructive' : ''}>
|
||||
<SelectValue placeholder="选择提供商" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((provider) => (
|
||||
<SelectItem key={provider} value={provider}>
|
||||
{provider}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Label
|
||||
htmlFor="api_provider"
|
||||
className={`sm:w-28 sm:flex-shrink-0 ${formErrors.api_provider ? 'text-destructive' : ''}`}
|
||||
>
|
||||
API 提供商 *
|
||||
</Label>
|
||||
<Select
|
||||
value={editingModel?.api_provider || ''}
|
||||
onValueChange={(value) => {
|
||||
setEditingModel((prev) =>
|
||||
prev ? { ...prev, api_provider: value } : null
|
||||
)
|
||||
// 清空模型列表和错误状态,等待 useEffect 重新获取
|
||||
clearModels()
|
||||
if (formErrors.api_provider) {
|
||||
setFormErrors((prev) => ({ ...prev, api_provider: undefined }))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="api_provider" className={`sm:flex-1 ${formErrors.api_provider ? 'border-destructive focus-visible:ring-destructive' : ''}`}>
|
||||
<SelectValue placeholder="选择提供商" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((provider) => (
|
||||
<SelectItem key={provider} value={provider}>
|
||||
{provider}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{formErrors.api_provider && (
|
||||
<p className="text-xs text-destructive">{formErrors.api_provider}</p>
|
||||
<p className="text-xs text-destructive sm:pl-28">{formErrors.api_provider}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1277,6 +1346,50 @@ function ModelConfigPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{advancedModelSettingsVisible && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50/50 p-4 space-y-4 dark:border-amber-500/40 dark:bg-amber-500/10">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="model_cache" className="cursor-pointer">支持缓存计费</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
开启后,命中缓存的输入 token 会按缓存输入价格统计
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="model_cache"
|
||||
checked={editingModel?.cache || false}
|
||||
onCheckedChange={(checked) =>
|
||||
setEditingModel((prev) =>
|
||||
prev ? { ...prev, cache: checked } : null
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editingModel?.cache && (
|
||||
<div className="grid gap-2 border-t pt-4">
|
||||
<Label htmlFor="cache_price_in">缓存输入价格 (¥/M token)</Label>
|
||||
<Input
|
||||
id="cache_price_in"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={editingModel?.cache_price_in ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === '' ? null : parseFloat(e.target.value)
|
||||
setEditingModel((prev) =>
|
||||
prev
|
||||
? { ...prev, cache_price_in: val }
|
||||
: null
|
||||
)
|
||||
}}
|
||||
placeholder="默认: 0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模型级别温度 */}
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1459,6 +1572,21 @@ function ModelConfigPageContent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="model_visual"
|
||||
checked={editingModel?.visual || false}
|
||||
onCheckedChange={(checked) =>
|
||||
setEditingModel((prev) =>
|
||||
prev ? { ...prev, visual: checked } : null
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="model_visual" className="cursor-pointer">
|
||||
启用视觉
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="force_stream_mode"
|
||||
|
||||
@@ -55,6 +55,11 @@ export const ModelCardList = React.memo(function ModelCardList({
|
||||
>
|
||||
{used ? '已使用' : '未使用'}
|
||||
</Badge>
|
||||
{model.visual && (
|
||||
<Badge variant="outline" className="border-blue-500 text-blue-600">
|
||||
视觉
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground break-all" title={model.model_identifier}>
|
||||
{model.model_identifier}
|
||||
|
||||
@@ -67,6 +67,7 @@ export const ModelTable = React.memo(function ModelTable({
|
||||
<TableHead>模型名称</TableHead>
|
||||
<TableHead>模型标识符</TableHead>
|
||||
<TableHead>提供商</TableHead>
|
||||
<TableHead className="text-center">视觉</TableHead>
|
||||
<TableHead className="text-center">温度</TableHead>
|
||||
<TableHead className="text-right">输入价格</TableHead>
|
||||
<TableHead className="text-right">输出价格</TableHead>
|
||||
@@ -76,7 +77,7 @@ export const ModelTable = React.memo(function ModelTable({
|
||||
<TableBody>
|
||||
{paginatedModels.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center text-muted-foreground py-8">
|
||||
<TableCell colSpan={10} className="text-center text-muted-foreground py-8">
|
||||
{searchQuery ? '未找到匹配的模型' : '暂无模型配置'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -105,6 +106,15 @@ export const ModelTable = React.memo(function ModelTable({
|
||||
{model.model_identifier}
|
||||
</TableCell>
|
||||
<TableCell>{model.api_provider}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{model.visual ? (
|
||||
<Badge variant="outline" className="border-blue-500 text-blue-600">
|
||||
启用
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{model.temperature != null ? model.temperature : <span className="text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
@@ -139,4 +149,4 @@ export const ModelTable = React.memo(function ModelTable({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,12 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { TaskConfig } from '../types'
|
||||
|
||||
@@ -25,9 +31,28 @@ interface TaskConfigCardProps {
|
||||
hideTemperature?: boolean
|
||||
hideMaxTokens?: boolean
|
||||
advanced?: boolean
|
||||
showAdvancedSettings?: boolean
|
||||
dataTour?: string
|
||||
}
|
||||
|
||||
const selectionStrategyOptions = [
|
||||
{
|
||||
value: 'balance',
|
||||
label: '负载均衡(balance)',
|
||||
description: '优先选择当前使用次数较少的模型,适合多个同类模型共同承担请求。',
|
||||
},
|
||||
{
|
||||
value: 'random',
|
||||
label: '随机选择(random)',
|
||||
description: '每次请求从模型列表中随机选择一个模型,适合简单分散请求。',
|
||||
},
|
||||
{
|
||||
value: 'sequential',
|
||||
label: '按顺序优先(sequential)',
|
||||
description: '优先使用模型列表中靠前的模型,前面的模型不可用时再尝试后面的模型。',
|
||||
},
|
||||
]
|
||||
|
||||
export const TaskConfigCard = React.memo(function TaskConfigCard({
|
||||
title,
|
||||
description,
|
||||
@@ -37,6 +62,7 @@ export const TaskConfigCard = React.memo(function TaskConfigCard({
|
||||
hideTemperature = false,
|
||||
hideMaxTokens = false,
|
||||
advanced = false,
|
||||
showAdvancedSettings = false,
|
||||
dataTour,
|
||||
}: TaskConfigCardProps) {
|
||||
const handleModelChange = (values: string[]) => {
|
||||
@@ -68,8 +94,8 @@ export const TaskConfigCard = React.memo(function TaskConfigCard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 温度和最大 Token */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* 推理参数 */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
{!hideTemperature && (
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -112,51 +138,66 @@ export const TaskConfigCard = React.memo(function TaskConfigCard({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 慢请求阈值 */}
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>慢请求阈值 (秒)</Label>
|
||||
<span className="text-xs text-muted-foreground">超时警告</span>
|
||||
{/* 模型选择策略 */}
|
||||
<div className="grid gap-2">
|
||||
<Label>模型选择策略</Label>
|
||||
<Select
|
||||
value={taskConfig.selection_strategy ?? 'balance'}
|
||||
onValueChange={(value) => onChange('selection_strategy', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择模型选择策略" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<TooltipProvider delayDuration={150}>
|
||||
{selectionStrategyOptions.map((option) => (
|
||||
<Tooltip key={option.value}>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectItem value={option.value} title={option.description}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
className="max-w-72 bg-background text-foreground border shadow-lg"
|
||||
>
|
||||
{option.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="1"
|
||||
value={taskConfig.slow_threshold ?? 15}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value)
|
||||
if (!isNaN(value) && value >= 1) {
|
||||
onChange('slow_threshold', value)
|
||||
}
|
||||
}}
|
||||
placeholder="15"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
模型响应时间超过此阈值将输出警告日志
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 模型选择策略 */}
|
||||
<div className="grid gap-2">
|
||||
<Label>模型选择策略</Label>
|
||||
<Select
|
||||
value={taskConfig.selection_strategy ?? 'balance'}
|
||||
onValueChange={(value) => onChange('selection_strategy', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择模型选择策略" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="balance">负载均衡(balance)</SelectItem>
|
||||
<SelectItem value="random">随机选择(random)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
负载均衡:优先选择使用次数少的模型。随机选择:完全随机从模型列表中选择
|
||||
</p>
|
||||
</div>
|
||||
{showAdvancedSettings && (
|
||||
<div className="grid gap-2 rounded-md border border-amber-200 bg-amber-50/50 p-3 dark:border-amber-500/40 dark:bg-amber-500/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>慢请求阈值 (秒)</Label>
|
||||
<span className="text-xs text-muted-foreground">高级配置</span>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="1"
|
||||
value={taskConfig.slow_threshold ?? 15}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value)
|
||||
if (!isNaN(value) && value >= 1) {
|
||||
onChange('slow_threshold', value)
|
||||
}
|
||||
}}
|
||||
placeholder="15"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
模型响应时间超过此阈值将输出警告日志
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -18,6 +18,16 @@ export const modelListCache = new Map<string, { models: ModelListItem[], timesta
|
||||
* 任务配置信息
|
||||
*/
|
||||
export const TASK_CONFIGS = [
|
||||
{
|
||||
key: 'replyer' as const,
|
||||
title: '回复模型 (replyer)',
|
||||
description: '用于表达器和表达方式学习',
|
||||
},
|
||||
{
|
||||
key: 'planner' as const,
|
||||
title: '规划模型 (planner)',
|
||||
description: '负责决定麦麦该什么时候回复',
|
||||
},
|
||||
{
|
||||
key: 'utils' as const,
|
||||
title: '组件模型 (utils)',
|
||||
@@ -33,16 +43,6 @@ export const TASK_CONFIGS = [
|
||||
title: '工具调用模型 (tool_use)',
|
||||
description: '需要使用支持工具调用的模型',
|
||||
},
|
||||
{
|
||||
key: 'replyer' as const,
|
||||
title: '首要回复模型 (replyer)',
|
||||
description: '用于表达器和表达方式学习',
|
||||
},
|
||||
{
|
||||
key: 'planner' as const,
|
||||
title: '决策模型 (planner)',
|
||||
description: '负责决定麦麦该什么时候回复',
|
||||
},
|
||||
{
|
||||
key: 'vlm' as const,
|
||||
title: '图像识别模型 (vlm)',
|
||||
@@ -55,6 +55,7 @@ export const TASK_CONFIGS = [
|
||||
description: '语音转文字',
|
||||
hideTemperature: true,
|
||||
hideMaxTokens: true,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: 'embedding' as const,
|
||||
@@ -95,8 +96,11 @@ export const DEFAULT_MODEL_INFO = {
|
||||
api_provider: '',
|
||||
price_in: 0,
|
||||
price_out: 0,
|
||||
cache: false,
|
||||
cache_price_in: 0,
|
||||
temperature: null,
|
||||
max_tokens: null,
|
||||
visual: false,
|
||||
force_stream_mode: false,
|
||||
extra_params: {},
|
||||
} as const
|
||||
|
||||
@@ -66,6 +66,9 @@ export function useModelAutoSave(
|
||||
api_provider: model.api_provider,
|
||||
price_in: model.price_in ?? 0,
|
||||
price_out: model.price_out ?? 0,
|
||||
cache: model.cache ?? false,
|
||||
cache_price_in: model.cache_price_in ?? 0,
|
||||
visual: model.visual ?? false,
|
||||
force_stream_mode: model.force_stream_mode ?? false,
|
||||
extra_params: model.extra_params ?? {},
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useTour } from '@/components/tour'
|
||||
import { MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps, STEP_ROUTE_MAP } from '@/components/tour/tours/model-assignment-tour'
|
||||
|
||||
interface UseModelTourOptions {
|
||||
/** 打开模型编辑对话框回调 */
|
||||
onOpenEditDialog?: () => void
|
||||
/** 关闭编辑对话框回调 */
|
||||
onCloseEditDialog?: () => void
|
||||
}
|
||||
@@ -24,13 +26,33 @@ interface UseModelTourReturn {
|
||||
* Model 配置页面 Tour 引导 Hook
|
||||
*/
|
||||
export function useModelTour(options: UseModelTourOptions = {}): UseModelTourReturn {
|
||||
const { onCloseEditDialog } = options
|
||||
const { onOpenEditDialog, onCloseEditDialog } = options
|
||||
const navigate = useNavigate()
|
||||
const { registerTour, startTour: startTourFn, state: tourState, goToStep } = useTour()
|
||||
|
||||
// 用于追踪前一个步骤
|
||||
const prevTourStepRef = useRef(tourState.stepIndex)
|
||||
|
||||
const didClickTourTarget = useCallback((event: MouseEvent, selector: string) => {
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
if (target?.closest(selector)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const element = document.querySelector(selector)
|
||||
if (!element) {
|
||||
return false
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect()
|
||||
return (
|
||||
event.clientX >= rect.left &&
|
||||
event.clientX <= rect.right &&
|
||||
event.clientY >= rect.top &&
|
||||
event.clientY <= rect.bottom
|
||||
)
|
||||
}, [])
|
||||
|
||||
// 注册 Tour
|
||||
useEffect(() => {
|
||||
registerTour(MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps)
|
||||
@@ -67,34 +89,59 @@ export function useModelTour(options: UseModelTourOptions = {}): UseModelTourRet
|
||||
if (tourState.activeTourId !== MODEL_ASSIGNMENT_TOUR_ID || !tourState.isRunning) return
|
||||
|
||||
const handleTourClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
const currentStep = tourState.stepIndex
|
||||
|
||||
// Step 3 (index 2): 点击添加提供商按钮
|
||||
if (currentStep === 2 && target.closest('[data-tour="add-provider-button"]')) {
|
||||
if (currentStep === 2 && didClickTourTarget(e, '[data-tour="add-provider-button"]')) {
|
||||
setTimeout(() => goToStep(3), 300)
|
||||
}
|
||||
// Step 10 (index 9): 点击取消按钮(关闭提供商弹窗)
|
||||
else if (currentStep === 9 && target.closest('[data-tour="provider-cancel-button"]')) {
|
||||
else if (currentStep === 9 && didClickTourTarget(e, '[data-tour="provider-cancel-button"]')) {
|
||||
setTimeout(() => goToStep(10), 300)
|
||||
}
|
||||
// Step 12 (index 11): 点击添加模型按钮
|
||||
else if (currentStep === 11 && target.closest('[data-tour="add-model-button"]')) {
|
||||
else if (currentStep === 11 && didClickTourTarget(e, '[data-tour="add-model-button"]')) {
|
||||
onOpenEditDialog?.()
|
||||
setTimeout(() => goToStep(12), 300)
|
||||
}
|
||||
// Step 18 (index 17): 点击取消按钮(关闭模型弹窗)
|
||||
else if (currentStep === 17 && target.closest('[data-tour="model-cancel-button"]')) {
|
||||
else if (currentStep === 17 && didClickTourTarget(e, '[data-tour="model-cancel-button"]')) {
|
||||
setTimeout(() => goToStep(18), 300)
|
||||
}
|
||||
// Step 19 (index 18): 点击为模型分配功能标签页
|
||||
else if (currentStep === 18 && target.closest('[data-tour="tasks-tab-trigger"]')) {
|
||||
else if (currentStep === 18 && didClickTourTarget(e, '[data-tour="tasks-tab-trigger"]')) {
|
||||
setTimeout(() => goToStep(19), 300)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleTourClick, true)
|
||||
return () => document.removeEventListener('click', handleTourClick, true)
|
||||
}, [tourState, goToStep])
|
||||
}, [tourState, goToStep, onOpenEditDialog, didClickTourTarget])
|
||||
|
||||
// Step 12 的 spotlight 点击在部分浏览器/布局下会被 Joyride 遮罩截获。
|
||||
// 这里直接给目标按钮补一个原生监听,确保点中按钮时能打开模型弹窗。
|
||||
useEffect(() => {
|
||||
if (
|
||||
tourState.activeTourId !== MODEL_ASSIGNMENT_TOUR_ID ||
|
||||
!tourState.isRunning ||
|
||||
tourState.stepIndex !== 11
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const addModelButton = document.querySelector('[data-tour="add-model-button"]')
|
||||
if (!addModelButton) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleAddModelButtonClick = () => {
|
||||
onOpenEditDialog?.()
|
||||
setTimeout(() => goToStep(12), 300)
|
||||
}
|
||||
|
||||
addModelButton.addEventListener('click', handleAddModelButtonClick, true)
|
||||
return () => addModelButton.removeEventListener('click', handleAddModelButtonClick, true)
|
||||
}, [tourState.activeTourId, tourState.isRunning, tourState.stepIndex, goToStep, onOpenEditDialog])
|
||||
|
||||
// 开始引导
|
||||
const handleStartTour = useCallback(() => {
|
||||
|
||||
@@ -11,8 +11,11 @@ export interface ModelInfo {
|
||||
api_provider: string
|
||||
price_in: number | null
|
||||
price_out: number | null
|
||||
cache?: boolean
|
||||
cache_price_in?: number | null
|
||||
temperature?: number | null // 模型级别温度,覆盖任务配置中的温度
|
||||
max_tokens?: number | null // 模型级别最大token数,覆盖任务配置中的max_tokens
|
||||
visual?: boolean
|
||||
force_stream_mode?: boolean
|
||||
extra_params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ function ModelProviderConfigPageContent() {
|
||||
})
|
||||
const [testingProviders, setTestingProviders] = useState<Set<string>>(new Set())
|
||||
const [testResults, setTestResults] = useState<Map<string, TestConnectionResult>>(new Map())
|
||||
const [restartNoticeVisible, setRestartNoticeVisible] = useState(
|
||||
() => localStorage.getItem('model-provider-restart-notice-dismissed') !== 'true'
|
||||
)
|
||||
|
||||
const { toast } = useToast()
|
||||
const navigate = useNavigate()
|
||||
@@ -172,6 +175,11 @@ function ModelProviderConfigPageContent() {
|
||||
await triggerRestart()
|
||||
}
|
||||
|
||||
const dismissRestartNotice = () => {
|
||||
localStorage.setItem('model-provider-restart-notice-dismissed', 'true')
|
||||
setRestartNoticeVisible(false)
|
||||
}
|
||||
|
||||
const handleSaveAndRestart = async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
@@ -796,12 +804,19 @@ function ModelProviderConfigPageContent() {
|
||||
</div>
|
||||
|
||||
{/* 重启提示 */}
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
配置更新后需要<strong>重启麦麦</strong>才能生效。你可以点击右上角的"保存并重启"按钮一键完成保存和重启。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{restartNoticeVisible && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>
|
||||
配置更新后需要<strong>重启麦麦</strong>才能生效。你可以点击右上角的"保存并重启"按钮一键完成保存和重启。
|
||||
</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={dismissRestartNotice}>
|
||||
我知道了
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-260px)]">
|
||||
<ProviderList
|
||||
|
||||
@@ -440,6 +440,9 @@ function MCPSettingsPageContent() {
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
|
||||
const [mcpConfig, setMcpConfig] = useState<ConfigSectionData>({})
|
||||
const [mcpSchema, setMcpSchema] = useState<ConfigSchema | null>(null)
|
||||
const [restartNoticeVisible, setRestartNoticeVisible] = useState(
|
||||
() => localStorage.getItem('mcp-settings-restart-notice-dismissed') !== 'true',
|
||||
)
|
||||
const { toast } = useToast()
|
||||
const { triggerRestart, isRestarting } = useRestart()
|
||||
|
||||
@@ -547,6 +550,11 @@ function MCPSettingsPageContent() {
|
||||
await triggerRestart({ delay: 500 })
|
||||
}, [saveConfig, triggerRestart])
|
||||
|
||||
const dismissRestartNotice = useCallback(() => {
|
||||
localStorage.setItem('mcp-settings-restart-notice-dismissed', 'true')
|
||||
setRestartNoticeVisible(false)
|
||||
}, [])
|
||||
|
||||
const formSchema: ConfigSchema | null = mcpSchema
|
||||
? {
|
||||
className: 'MCPSettings',
|
||||
@@ -600,12 +608,17 @@ function MCPSettingsPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
MCP 设置保存后需要重启麦麦才会生效。这里与主程序配置中的 MCP 栏目使用同一份配置。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{restartNoticeVisible && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>MCP 设置保存后需要重启麦麦才会生效。这里与主程序配置中的 MCP 栏目使用同一份配置。</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={dismissRestartNotice}>
|
||||
我知道了
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">
|
||||
|
||||
@@ -280,7 +280,7 @@ export function EmojiManagementPage() {
|
||||
{/* 页面标题 */}
|
||||
<div className="mb-4 sm:mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold">表情包管理</h1>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold">表情包</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
管理麦麦的表情包资源
|
||||
</p>
|
||||
|
||||
@@ -60,8 +60,8 @@ export function ExpressionDetailDialog({
|
||||
return new Date(timestamp * 1000).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const getChatName = (chatId: string): string => {
|
||||
return chatNameMap.get(chatId) || chatId
|
||||
const getChatName = (): string => {
|
||||
return expression.chat_name || chatNameMap.get(expression.chat_id) || expression.chat_id
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -81,7 +81,7 @@ export function ExpressionDetailDialog({
|
||||
<InfoItem label="风格" value={expression.style} />
|
||||
<InfoItem
|
||||
label="聊天"
|
||||
value={getChatName(expression.chat_id)}
|
||||
value={getChatName()}
|
||||
/>
|
||||
<InfoItem icon={Hash} label="记录ID" value={expression.id.toString()} mono />
|
||||
</div>
|
||||
|
||||
@@ -51,8 +51,8 @@ export function ExpressionList({
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
|
||||
const getChatName = (chatId: string): string => {
|
||||
return chatNameMap.get(chatId) || chatId
|
||||
const getChatName = (expression: Expression): string => {
|
||||
return expression.chat_name || chatNameMap.get(expression.chat_id) || expression.chat_id
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
@@ -117,11 +117,11 @@ export function ExpressionList({
|
||||
<TableCell className="max-w-xs truncate">{expression.style}</TableCell>
|
||||
<TableCell
|
||||
className="max-w-[200px] truncate"
|
||||
title={getChatName(expression.chat_id)}
|
||||
title={getChatName(expression)}
|
||||
style={{ wordBreak: 'keep-all' }}
|
||||
>
|
||||
<span className="whitespace-nowrap overflow-hidden text-ellipsis block">
|
||||
{getChatName(expression.chat_id)}
|
||||
{getChatName(expression)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
@@ -201,10 +201,10 @@ export function ExpressionList({
|
||||
<div className="text-xs text-muted-foreground mb-1">聊天</div>
|
||||
<p
|
||||
className="text-sm truncate"
|
||||
title={getChatName(expression.chat_id)}
|
||||
title={getChatName(expression)}
|
||||
style={{ wordBreak: 'keep-all' }}
|
||||
>
|
||||
{getChatName(expression.chat_id)}
|
||||
{getChatName(expression)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -267,7 +267,7 @@ export function ExpressionManagementPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2">
|
||||
<MessageSquare className="h-8 w-8" strokeWidth={2} />
|
||||
表达方式管理
|
||||
表达方式
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm sm:text-base">
|
||||
管理麦麦的表达方式和话术模板
|
||||
@@ -316,28 +316,21 @@ export function ExpressionManagementPage() {
|
||||
|
||||
{/* 搜索和批量操作 */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<Label htmlFor="search">搜索</Label>
|
||||
<div className="flex flex-col sm:flex-row gap-2 mt-1.5">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="搜索情境、风格或上下文..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="search">搜索</Label>
|
||||
<div className="relative mt-1.5">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="搜索情境、风格或上下文..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 批量操作工具栏 */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mt-4 pt-4 border-t">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{selectedIds.size > 0 && (
|
||||
<span>已选择 {selectedIds.size} 个表达方式</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 sm:pb-0.5">
|
||||
<Label htmlFor="page-size" className="text-sm whitespace-nowrap">每页显示</Label>
|
||||
<Select
|
||||
value={pageSize.toString()}
|
||||
@@ -357,6 +350,17 @@ export function ExpressionManagementPage() {
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 批量操作工具栏 */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mt-4 pt-4 border-t">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{selectedIds.size > 0 && (
|
||||
<span>已选择 {selectedIds.size} 个表达方式</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedIds.size > 0 && (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -250,7 +250,7 @@ export function JargonManagementPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2">
|
||||
<MessageCircle className="h-8 w-8" strokeWidth={2} />
|
||||
黑话管理
|
||||
黑话
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm sm:text-base">
|
||||
管理麦麦学习到的黑话和俗语
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface FieldSchema {
|
||||
'x-icon'?: string
|
||||
'x-layout'?: 'inline-right'
|
||||
'x-input-width'?: string
|
||||
'x-textarea-min-height'?: number
|
||||
'x-textarea-rows'?: number
|
||||
advanced?: boolean
|
||||
step?: number
|
||||
}
|
||||
@@ -52,7 +54,6 @@ export interface ConfigSchema {
|
||||
uiParent?: string
|
||||
uiLabel?: string
|
||||
uiIcon?: string
|
||||
uiMergeChildren?: string[]
|
||||
}
|
||||
|
||||
export interface ConfigSchemaResponse {
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface Expression {
|
||||
style: string
|
||||
last_active_time: number
|
||||
chat_id: string
|
||||
chat_name?: string | null
|
||||
create_date: number | null
|
||||
checked: boolean
|
||||
rejected: boolean
|
||||
|
||||
Reference in New Issue
Block a user