merge: 同步上游 dev 最新内容
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)
|
||||
@@ -225,6 +175,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
value={values[field.name]}
|
||||
onChange={(v) => onChange(field.name, v)}
|
||||
schema={field}
|
||||
parentValues={values}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -235,6 +186,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
value={values[field.name]}
|
||||
onChange={(v) => onChange(field.name, v)}
|
||||
schema={field}
|
||||
parentValues={values}
|
||||
>
|
||||
<DynamicField
|
||||
schema={field}
|
||||
@@ -265,12 +217,50 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
? [...normalFields, ...advancedFields]
|
||||
: normalFields
|
||||
|
||||
const groupFieldsByRow = (fields: FieldSchema[]) => {
|
||||
const rows: FieldSchema[][] = []
|
||||
let currentRow: FieldSchema[] = []
|
||||
let currentRowKey: string | undefined
|
||||
|
||||
for (const field of fields) {
|
||||
const rowKey = field['x-row']
|
||||
if (rowKey && rowKey === currentRowKey) {
|
||||
currentRow.push(field)
|
||||
continue
|
||||
}
|
||||
|
||||
if (currentRow.length > 0) {
|
||||
rows.push(currentRow)
|
||||
}
|
||||
|
||||
currentRow = [field]
|
||||
currentRowKey = rowKey
|
||||
}
|
||||
|
||||
if (currentRow.length > 0) {
|
||||
rows.push(currentRow)
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
const renderFieldList = (fields: FieldSchema[]) => (
|
||||
<>
|
||||
{fields.map((field, index) => (
|
||||
<React.Fragment key={field.name}>
|
||||
{groupFieldsByRow(fields).map((row, index) => (
|
||||
<React.Fragment key={row.map((field) => field.name).join('|')}>
|
||||
{index > 0 && <Separator className="my-2 bg-border/50" />}
|
||||
<div className="py-1">{renderField(field)}</div>
|
||||
{row.length > 1 ? (
|
||||
<div
|
||||
className="grid gap-4 py-1 md:grid-cols-[repeat(var(--field-row-count),minmax(0,1fr))]"
|
||||
style={{ '--field-row-count': row.length } as React.CSSProperties}
|
||||
>
|
||||
{row.map((field) => (
|
||||
<div key={field.name}>{renderField(field)}</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1">{renderField(row[0])}</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
@@ -294,7 +284,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)
|
||||
@@ -313,6 +302,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
onChange={(v) => onChange(key, v)}
|
||||
schema={nestedField ?? nestedSchema}
|
||||
nestedSchema={nestedSchema}
|
||||
parentValues={values}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -326,6 +316,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
onChange={(v) => onChange(key, v)}
|
||||
schema={nestedField ?? nestedSchema}
|
||||
nestedSchema={nestedSchema}
|
||||
parentValues={values}
|
||||
>
|
||||
<DynamicConfigForm
|
||||
schema={nestedSchema}
|
||||
@@ -342,34 +333,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}
|
||||
|
||||
@@ -8,6 +8,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { FieldSchema } from "@/types/config-schema"
|
||||
|
||||
@@ -31,6 +37,27 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const isNumericField = schema.type === 'integer' || schema.type === 'number'
|
||||
|
||||
const parseNumericValue = (rawValue: unknown, fallbackValue: unknown = 0) => {
|
||||
if (typeof rawValue === 'number' && Number.isFinite(rawValue)) {
|
||||
return rawValue
|
||||
}
|
||||
|
||||
if (typeof rawValue === 'string') {
|
||||
const parsedValue = parseFloat(rawValue)
|
||||
if (Number.isFinite(parsedValue)) {
|
||||
return schema.type === 'integer' ? Math.trunc(parsedValue) : parsedValue
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackValue !== rawValue) {
|
||||
return parseNumericValue(fallbackValue, 0)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
const renderPrimitiveArrayEditor = () => {
|
||||
const itemType = schema.items?.type ?? 'string'
|
||||
const arrayValue = Array.isArray(value)
|
||||
@@ -94,6 +121,10 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
return <IconComponent className="h-4 w-4" />
|
||||
}
|
||||
|
||||
const optionDescriptions = schema['x-option-descriptions'] ?? {}
|
||||
const hasOptionDescriptions = Object.keys(optionDescriptions).length > 0
|
||||
const inlineDescription = hasOptionDescriptions ? '' : schema.description
|
||||
|
||||
const renderFieldHeader = () => (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Label
|
||||
@@ -108,9 +139,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>
|
||||
@@ -122,10 +153,14 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
const renderInputComponent = () => {
|
||||
const widget = schema['x-widget']
|
||||
const type = schema.type
|
||||
const resolvedWidget =
|
||||
isNumericField && (widget === 'input' || widget === 'number' || !widget)
|
||||
? 'number'
|
||||
: widget
|
||||
|
||||
// x-widget 优先
|
||||
if (widget) {
|
||||
switch (widget) {
|
||||
if (resolvedWidget) {
|
||||
switch (resolvedWidget) {
|
||||
case 'slider':
|
||||
return renderSlider()
|
||||
case 'input':
|
||||
@@ -214,7 +249,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)
|
||||
const min = schema.minValue ?? 0
|
||||
const max = schema.maxValue ?? 100
|
||||
const step = schema.step ?? 1
|
||||
@@ -241,7 +276,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)
|
||||
const min = schema.minValue
|
||||
const max = schema.maxValue
|
||||
const step = schema.step ?? (schema.type === 'integer' ? 1 : 0.1)
|
||||
@@ -250,7 +285,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 +302,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 +322,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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -307,11 +360,39 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
<SelectValue placeholder={`Select ${schema.label}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
{hasOptionDescriptions ? (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
{options.map((option) => {
|
||||
const description = optionDescriptions[option]
|
||||
return description ? (
|
||||
<Tooltip key={option}>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectItem value={option} title={description}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
className="max-w-72 bg-background text-foreground border shadow-lg"
|
||||
>
|
||||
{description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
options.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -80,6 +80,7 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
|
||||
// 快速审核模式状态
|
||||
const [quickFilterType, setQuickFilterType] = useState<'unchecked' | 'passed' | 'rejected' | 'all'>('unchecked')
|
||||
const [quickExpressions, setQuickExpressions] = useState<Expression[]>([])
|
||||
const quickExpressionsRef = useRef<Expression[]>([])
|
||||
const [quickCurrentIndex, setQuickCurrentIndex] = useState(0)
|
||||
const [quickLoading, setQuickLoading] = useState(false)
|
||||
const [quickTotal, setQuickTotal] = useState(0)
|
||||
@@ -92,6 +93,10 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const dragStartRef = useRef<{ x: number; y: number } | null>(null)
|
||||
const isDraggingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
quickExpressionsRef.current = quickExpressions
|
||||
}, [quickExpressions])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [statsLoading, setStatsLoading] = useState(false)
|
||||
const [total, setTotal] = useState(0)
|
||||
@@ -180,9 +185,13 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
|
||||
setQuickLoading(true)
|
||||
const pageToLoad = append ? quickPage + 1 : quickPage
|
||||
const result = await getReviewList({
|
||||
page: pageToLoad,
|
||||
page: quickFilterType === 'unchecked' ? 1 : pageToLoad,
|
||||
page_size: 20,
|
||||
filter_type: quickFilterType,
|
||||
order: quickFilterType === 'unchecked' ? 'random' : 'latest',
|
||||
exclude_ids: quickFilterType === 'unchecked' && append
|
||||
? quickExpressionsRef.current.map((expr) => expr.id)
|
||||
: undefined,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
@@ -552,8 +561,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 +1113,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 +1594,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 +1647,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -442,16 +442,20 @@ export async function getReviewList(params: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
filter_type?: 'unchecked' | 'passed' | 'rejected' | 'all'
|
||||
order?: 'latest' | 'random'
|
||||
search?: string
|
||||
chat_id?: string
|
||||
exclude_ids?: number[]
|
||||
}): Promise<ApiResponse<ReviewListResponse>> {
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
if (params.page) queryParams.append('page', params.page.toString())
|
||||
if (params.page_size) queryParams.append('page_size', params.page_size.toString())
|
||||
if (params.filter_type) queryParams.append('filter_type', params.filter_type)
|
||||
if (params.order) queryParams.append('order', params.order)
|
||||
if (params.search) queryParams.append('search', params.search)
|
||||
if (params.chat_id) queryParams.append('chat_id', params.chat_id)
|
||||
params.exclude_ids?.forEach((id) => queryParams.append('exclude_ids', id.toString()))
|
||||
|
||||
const response = await fetchWithAuth(`${API_BASE}/review/list?${queryParams}`)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface FieldHookComponentProps {
|
||||
onChange?: (value: unknown) => void
|
||||
children?: ReactNode
|
||||
schema?: ConfigSchema | FieldSchema
|
||||
parentValues?: Record<string, unknown>
|
||||
/**
|
||||
* 如果当前字段是 `List[ConfigBase]` 或嵌套 ConfigBase,
|
||||
* 这里会传入对应子配置类的 ConfigSchema,便于自定义编辑器
|
||||
|
||||
@@ -121,6 +121,7 @@ export async function fetchPluginList(): Promise<ApiResponse<PluginInfo[]>> {
|
||||
rating: 0,
|
||||
review_count: 0,
|
||||
installed: false,
|
||||
source: 'market' as const,
|
||||
published_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}))
|
||||
|
||||
@@ -8,6 +8,9 @@ export interface PromptFileInfo {
|
||||
name: string
|
||||
size: number
|
||||
modified_at: number
|
||||
display_name: string
|
||||
advanced: boolean
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface PromptCatalog {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 修改此处的版本号后,所有展示版本的地方都会自动更新
|
||||
*/
|
||||
|
||||
export const APP_VERSION = '1.0.3'
|
||||
export const APP_VERSION = '1.0.5'
|
||||
export const APP_NAME = 'MaiBot Dashboard'
|
||||
export const APP_FULL_NAME = `${APP_NAME} v${APP_VERSION}`
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { parse as parseToml } from 'smol-toml'
|
||||
|
||||
import { AlertDescription, Alert } from '@/components/ui/alert'
|
||||
@@ -23,11 +23,14 @@ import { useToast } from '@/hooks/use-toast'
|
||||
import { getBotConfig, getBotConfigRaw, getBotConfigSchema, updateBotConfig, updateBotConfigRaw } from '@/lib/config-api'
|
||||
import { fieldHooks } from '@/lib/field-hooks'
|
||||
import { RestartProvider, useRestart } from '@/lib/restart-context'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
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 {
|
||||
BotPlatformsHook,
|
||||
ChatPromptsHook,
|
||||
ChatTalkValueRulesHook,
|
||||
ExpressionGroupsHook,
|
||||
ExpressionLearningListHook,
|
||||
@@ -47,16 +50,27 @@ const TOAST_DISPLAY_DELAY = 500
|
||||
/** Tab 标签页的首选排列顺序 (host field name) */
|
||||
const TAB_ORDER = [
|
||||
'bot',
|
||||
'personality',
|
||||
'chat',
|
||||
'expression',
|
||||
'a_memorix',
|
||||
'visual',
|
||||
'message_receive',
|
||||
'emoji',
|
||||
'voice',
|
||||
'response_post_process',
|
||||
'webui',
|
||||
'plugin_runtime',
|
||||
'log',
|
||||
]
|
||||
|
||||
/** 默认展示的主配置栏目 */
|
||||
const DEFAULT_VISIBLE_TAB_IDS = new Set([
|
||||
'bot',
|
||||
'chat',
|
||||
'expression',
|
||||
'a_memorix',
|
||||
])
|
||||
|
||||
// ==================== Tab 分组类型与构建 ====================
|
||||
interface TabGroup {
|
||||
id: string
|
||||
@@ -143,6 +157,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 +177,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)
|
||||
@@ -255,6 +273,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)
|
||||
@@ -285,6 +304,7 @@ function BotConfigPageContent() {
|
||||
response_post_process: responsePostProcessConfig,
|
||||
chinese_typo: chineseTypoConfig,
|
||||
response_splitter: responseSplitterConfig,
|
||||
log: logConfig,
|
||||
debug: debugConfig,
|
||||
maim_message: maimMessageConfig,
|
||||
telemetry: telemetryConfig,
|
||||
@@ -308,6 +328,7 @@ function BotConfigPageContent() {
|
||||
responsePostProcessConfig,
|
||||
chineseTypoConfig,
|
||||
responseSplitterConfig,
|
||||
logConfig,
|
||||
debugConfig,
|
||||
maimMessageConfig,
|
||||
telemetryConfig,
|
||||
@@ -394,6 +415,8 @@ function BotConfigPageContent() {
|
||||
|
||||
useEffect(() => {
|
||||
const hookEntries = [
|
||||
['bot.platforms', BotPlatformsHook],
|
||||
['chat.chat_prompts', ChatPromptsHook],
|
||||
['chat.talk_value_rules', ChatTalkValueRulesHook],
|
||||
['expression.expression_groups', ExpressionGroupsHook],
|
||||
['expression.learning_list', ExpressionLearningListHook],
|
||||
@@ -437,6 +460,7 @@ function BotConfigPageContent() {
|
||||
useConfigAutoSave(responsePostProcessConfig, 'response_post_process', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(chineseTypoConfig, 'chinese_typo', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(responseSplitterConfig, 'response_splitter', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(logConfig, 'log', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(debugConfig, 'debug', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(maimMessageConfig, 'maim_message', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(telemetryConfig, 'telemetry', initialLoadRef.current, triggerAutoSave)
|
||||
@@ -450,10 +474,20 @@ function BotConfigPageContent() {
|
||||
const saveSourceCode = async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
// 编辑器展示时会把 basic string 内的 \n 展开成真实换行;保存前先转回 TOML 转义序列。
|
||||
const escapedSourceCode = sourceCode.replace(/"([^"]*)"/g, (_match, content) => {
|
||||
const encoded = content
|
||||
.replace(/\\/g, '\\\\') // 反斜杠必须先转义,避免 \s 等序列被 TOML 当作非法转义
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\t/g, '\\t')
|
||||
.replace(/\r/g, '\\r')
|
||||
return `"${encoded}"`
|
||||
})
|
||||
|
||||
// 前端验证 TOML 格式
|
||||
try {
|
||||
parseToml(sourceCode)
|
||||
parseToml(escapedSourceCode)
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'TOML 格式错误'
|
||||
const translatedMsg = translateTomlError(errorMsg)
|
||||
@@ -468,18 +502,7 @@ function BotConfigPageContent() {
|
||||
return
|
||||
}
|
||||
|
||||
// 将双引号字符串中的实际字符转换回 TOML 转义序列
|
||||
// 使用正则表达式只处理双引号字符串内的内容,不影响单引号字符串
|
||||
const escaped = sourceCode.replace(/"([^"]*)"/g, (_match, content) => {
|
||||
const encoded = content
|
||||
.replace(/\\/g, '\\\\') // 反斜杠(必须放在最前)
|
||||
.replace(/"/g, '\\"') // 双引号
|
||||
.replace(/\n/g, '\\n') // 换行符
|
||||
.replace(/\t/g, '\\t') // 制表符
|
||||
.replace(/\r/g, '\\r') // 回车符
|
||||
return `"${encoded}"`
|
||||
})
|
||||
const result = await updateBotConfigRaw(escaped)
|
||||
const result = await updateBotConfigRaw(escapedSourceCode)
|
||||
if (!result.success) {
|
||||
setHasTomlError(true)
|
||||
const errorMsg = result.error
|
||||
@@ -592,6 +615,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 +688,7 @@ function BotConfigPageContent() {
|
||||
response_post_process: responsePostProcessConfig,
|
||||
chinese_typo: chineseTypoConfig,
|
||||
response_splitter: responseSplitterConfig,
|
||||
log: logConfig,
|
||||
debug: debugConfig,
|
||||
maim_message: maimMessageConfig,
|
||||
telemetry: telemetryConfig,
|
||||
@@ -673,6 +712,7 @@ function BotConfigPageContent() {
|
||||
responsePostProcessConfig,
|
||||
chineseTypoConfig,
|
||||
responseSplitterConfig,
|
||||
logConfig,
|
||||
debugConfig,
|
||||
maimMessageConfig,
|
||||
telemetryConfig,
|
||||
@@ -699,6 +739,7 @@ function BotConfigPageContent() {
|
||||
response_post_process: setResponsePostProcessConfig,
|
||||
chinese_typo: setChineseTypoConfig,
|
||||
response_splitter: setResponseSplitterConfig,
|
||||
log: setLogConfig,
|
||||
debug: setDebugConfig,
|
||||
maim_message: setMaimMessageConfig,
|
||||
telemetry: setTelemetryConfig,
|
||||
@@ -735,7 +776,33 @@ function BotConfigPageContent() {
|
||||
<p className="text-muted-foreground mt-1 text-xs sm:text-sm">管理麦麦的核心功能和行为设置</p>
|
||||
</div>
|
||||
{/* 按钮组 - 桌面端靠右 */}
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<div className="flex flex-wrap gap-2 flex-shrink-0 sm:justify-end">
|
||||
<Tabs
|
||||
value={editMode}
|
||||
onValueChange={(v) => handleModeChange(v as 'visual' | 'source')}
|
||||
className="w-full min-w-[13rem] sm:w-[14rem]"
|
||||
>
|
||||
<TabsList className="grid h-8 w-full grid-cols-2 sm:h-9">
|
||||
<TabsTrigger value="visual" className="px-2 text-xs">
|
||||
<Layout className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
||||
可视化
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="source" className="px-2 text-xs">
|
||||
<Code2 className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
||||
源代码
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<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}
|
||||
@@ -785,31 +852,22 @@ function BotConfigPageContent() {
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模式切换 - 单独一行 */}
|
||||
<div className="flex">
|
||||
<Tabs value={editMode} onValueChange={(v) => handleModeChange(v as 'visual' | 'source')} className="w-full">
|
||||
<TabsList className="h-8 sm:h-9 w-full grid grid-cols-2">
|
||||
<TabsTrigger value="visual" className="text-xs sm:text-sm">
|
||||
<Layout className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||
可视化编辑
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="source" className="text-xs sm:text-sm">
|
||||
<Code2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||
源代码编辑
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</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 +961,37 @@ 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 firstExpandedTabId = visibleTabGroups.find(
|
||||
(tab) => !DEFAULT_VISIBLE_TAB_IDS.has(tab.id)
|
||||
)?.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,17 +1037,44 @@ 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) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm"
|
||||
{visibleTabGroups.map((tab) => {
|
||||
const isExpandedOnlyTab = !DEFAULT_VISIBLE_TAB_IDS.has(tab.id)
|
||||
return (
|
||||
<Fragment key={tab.id}>
|
||||
{tab.id === firstExpandedTabId && (
|
||||
<span className="mx-1 hidden h-6 w-px bg-border/80 sm:block" />
|
||||
)}
|
||||
<TabsTrigger
|
||||
value={tab.id}
|
||||
className={cn(
|
||||
"text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm",
|
||||
isExpandedOnlyTab &&
|
||||
"border border-dashed border-border/70 bg-background/45 text-muted-foreground/80 hover:bg-background/70 data-[state=active]:border-primary/45 data-[state=active]:bg-primary/10 data-[state=active]:text-primary data-[state=active]:shadow-none"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{hasCollapsibleTabs && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs sm:h-9 sm:px-3"
|
||||
onClick={toggleExpanded}
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
{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, useEffect, useMemo, useState, type CSSProperties } from 'react'
|
||||
import * as LucideIcons from 'lucide-react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
|
||||
@@ -31,6 +31,17 @@ 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'
|
||||
/** 根据同级配置决定是否默认折叠 */
|
||||
collapseWhen?: (context: { parentValues?: Record<string, unknown> }) => boolean
|
||||
collapsedText?: string
|
||||
expandLabel?: string
|
||||
collapseLabel?: string
|
||||
}
|
||||
|
||||
function resolveLabel(schema?: ConfigSchema | FieldSchema, fieldPath?: string): string {
|
||||
@@ -153,6 +164,7 @@ export function createListItemEditorHook(
|
||||
onChange,
|
||||
schema,
|
||||
nestedSchema,
|
||||
parentValues,
|
||||
value,
|
||||
}) => {
|
||||
const items = useMemo<Record<string, unknown>[]>(() => {
|
||||
@@ -190,9 +202,115 @@ 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 shouldCollapse = options.collapseWhen?.({ parentValues }) ?? false
|
||||
const [manuallyExpanded, setManuallyExpanded] = useState(false)
|
||||
const collapsed = shouldCollapse && !manuallyExpanded
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldCollapse) {
|
||||
setManuallyExpanded(false)
|
||||
}
|
||||
}, [shouldCollapse])
|
||||
|
||||
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 (
|
||||
@@ -208,9 +326,23 @@ export function createListItemEditorHook(
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-2 pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{renderLucideIcon(iconName, 'h-5 w-5 text-muted-foreground')}
|
||||
<CardTitle className="text-base">{label}</CardTitle>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{renderLucideIcon(iconName, 'h-5 w-5 flex-shrink-0 text-muted-foreground')}
|
||||
<CardTitle className="truncate text-base">{label}</CardTitle>
|
||||
</div>
|
||||
{shouldCollapse && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setManuallyExpanded((current) => !current)}
|
||||
>
|
||||
{collapsed
|
||||
? (options.expandLabel ?? '展开')
|
||||
: (options.collapseLabel ?? '折叠')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<CardDescription className="whitespace-pre-line">{description}</CardDescription>
|
||||
@@ -220,6 +352,13 @@ export function createListItemEditorHook(
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{collapsed ? (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-sm text-muted-foreground">
|
||||
{options.collapsedText ?? '当前配置已折叠,可手动展开查看或编辑。'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{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 +390,14 @@ 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>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,38 @@
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { FieldHookComponent } from '@/lib/field-hooks'
|
||||
|
||||
import { createJsonFieldHook } from './JsonFieldHookFactory'
|
||||
import { createListItemEditorHook } from './ListItemEditorHookFactory'
|
||||
|
||||
type ExpressionRuleType = 'group' | 'private'
|
||||
|
||||
interface ExpressionGroupTarget {
|
||||
platform: string
|
||||
item_id: string
|
||||
rule_type: ExpressionRuleType
|
||||
}
|
||||
|
||||
interface ExpressionGroupValue {
|
||||
expression_groups: ExpressionGroupTarget[]
|
||||
}
|
||||
|
||||
interface PlatformAccountRow {
|
||||
platform: string
|
||||
account: string
|
||||
}
|
||||
|
||||
const ruleTypeLabel = (rule: unknown) => {
|
||||
if (rule === 'private') return '私聊'
|
||||
if (rule === 'group') return '群聊'
|
||||
@@ -28,10 +60,90 @@ const collectStringList = (value: unknown): string[] => {
|
||||
.filter((item) => item.length > 0)
|
||||
}
|
||||
|
||||
const normalizeExpressionRuleType = (value: unknown): ExpressionRuleType => {
|
||||
return value === 'private' ? 'private' : 'group'
|
||||
}
|
||||
|
||||
const normalizeExpressionTarget = (value: unknown): ExpressionGroupTarget => {
|
||||
const source =
|
||||
value && typeof value === 'object'
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
return {
|
||||
platform:
|
||||
typeof source.platform === 'string' ? source.platform.trim() : 'qq',
|
||||
item_id:
|
||||
typeof source.item_id === 'string' ? source.item_id.trim() : '',
|
||||
rule_type: normalizeExpressionRuleType(source.rule_type),
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeExpressionGroups = (value: unknown): ExpressionGroupValue[] => {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.map((item) => {
|
||||
const source =
|
||||
item && typeof item === 'object'
|
||||
? (item as Record<string, unknown>)
|
||||
: {}
|
||||
const members = Array.isArray(source.expression_groups)
|
||||
? source.expression_groups.map(normalizeExpressionTarget)
|
||||
: []
|
||||
return { expression_groups: members }
|
||||
})
|
||||
}
|
||||
|
||||
const createExpressionTarget = (): ExpressionGroupTarget => ({
|
||||
platform: 'qq',
|
||||
item_id: '',
|
||||
rule_type: 'group',
|
||||
})
|
||||
|
||||
const formatExpressionTarget = (target: ExpressionGroupTarget): string => {
|
||||
const platform = target.platform.trim()
|
||||
const itemId = target.item_id.trim()
|
||||
const rule = ruleTypeLabel(target.rule_type)
|
||||
if (!platform && !itemId) return `全局 · ${rule}`
|
||||
if (!itemId) return `${platform} · ${rule}`
|
||||
return `${platform}:${itemId} · ${rule}`
|
||||
}
|
||||
|
||||
const normalizePlatformAccounts = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.map((item) => String(item ?? ''))
|
||||
}
|
||||
|
||||
const parsePlatformAccount = (value: string): PlatformAccountRow => {
|
||||
const separatorIndex = value.indexOf(':')
|
||||
if (separatorIndex < 0) {
|
||||
return { platform: '', account: value }
|
||||
}
|
||||
return {
|
||||
platform: value.slice(0, separatorIndex),
|
||||
account: value.slice(separatorIndex + 1),
|
||||
}
|
||||
}
|
||||
|
||||
const formatPlatformAccount = (row: PlatformAccountRow): string => {
|
||||
const platform = row.platform.trim()
|
||||
const account = row.account.trim()
|
||||
if (!platform) return account
|
||||
if (!account) return `${platform}:`
|
||||
return `${platform}:${account}`
|
||||
}
|
||||
|
||||
export const ChatTalkValueRulesHook = createListItemEditorHook({
|
||||
addLabel: '添加发言频率规则',
|
||||
addButtonPlacement: 'top',
|
||||
collapseWhen: ({ parentValues }) => parentValues?.enable_talk_value_rules === false,
|
||||
collapsedText: '动态发言频率规则未启用,规则列表已折叠。展开后仍可查看或编辑已有规则。',
|
||||
expandLabel: '展开规则',
|
||||
collapseLabel: '折叠规则',
|
||||
helperText: '可按平台/聊天流/时段分别配置发言频率,留空表示全局。',
|
||||
emptyText: '尚未配置任何规则,将使用全局默认频率。',
|
||||
fieldRows: [
|
||||
['platform', 'item_id', 'rule_type'],
|
||||
['time', 'value'],
|
||||
],
|
||||
itemTitle: (item) => {
|
||||
const time =
|
||||
typeof item.time === 'string' && item.time.trim()
|
||||
@@ -43,10 +155,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('表达')
|
||||
@@ -57,6 +204,96 @@ export const ExpressionLearningListHook = createListItemEditorHook({
|
||||
},
|
||||
})
|
||||
|
||||
export const BotPlatformsHook: FieldHookComponent = ({ onChange, value }) => {
|
||||
const platforms = normalizePlatformAccounts(value)
|
||||
const rows = platforms.map(parsePlatformAccount)
|
||||
|
||||
const updateRows = (nextRows: PlatformAccountRow[]) => {
|
||||
onChange?.(nextRows.map(formatPlatformAccount))
|
||||
}
|
||||
|
||||
const addRow = () => {
|
||||
updateRows([...rows, { platform: '', account: '' }])
|
||||
}
|
||||
|
||||
const removeRow = (rowIndex: number) => {
|
||||
updateRows(rows.filter((_, index) => index !== rowIndex))
|
||||
}
|
||||
|
||||
const updateRow = (rowIndex: number, patch: Partial<PlatformAccountRow>) => {
|
||||
updateRows(
|
||||
rows.map((row, index) =>
|
||||
index === rowIndex ? { ...row, ...patch } : row
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm font-medium">其他平台</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
每行保存为 platform:account,例如 wx:114514。
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="outline" onClick={addRow}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加平台
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed bg-muted/30 px-4 py-5 text-center text-sm text-muted-foreground">
|
||||
暂无其他平台账号。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={rowIndex}
|
||||
className="grid gap-2 rounded-md border bg-muted/20 p-3 sm:grid-cols-[minmax(7rem,0.6fr)_minmax(10rem,1fr)_auto]"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">平台</Label>
|
||||
<Input
|
||||
value={row.platform}
|
||||
placeholder="wx"
|
||||
onChange={(event) =>
|
||||
updateRow(rowIndex, { platform: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">账号</Label>
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={row.account}
|
||||
placeholder="114514"
|
||||
onChange={(event) =>
|
||||
updateRow(rowIndex, { account: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={`删除其他平台 ${rowIndex + 1}`}
|
||||
onClick={() => removeRow(rowIndex)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const KeywordRulesHook = createListItemEditorHook({
|
||||
addLabel: '添加关键词规则',
|
||||
helperText: '匹配命中后会用 reaction 内容作为额外上下文。keywords 至少填一条,或使用正则模式。',
|
||||
@@ -95,11 +332,211 @@ export const RegexRulesHook = createListItemEditorHook({
|
||||
},
|
||||
})
|
||||
|
||||
export const ExpressionGroupsHook = createJsonFieldHook({
|
||||
emptyValue: [],
|
||||
helperText: '表达互通组使用 JSON 编辑。每一项包含一个 expression_groups 数组。',
|
||||
placeholder: '[\n {\n "expression_groups": [\n {\n "platform": "qq",\n "item_id": "123456",\n "rule_type": "group"\n }\n ]\n }\n]',
|
||||
})
|
||||
export const ExpressionGroupsHook: FieldHookComponent = ({ onChange, value }) => {
|
||||
const groups = normalizeExpressionGroups(value)
|
||||
|
||||
const updateGroups = (nextGroups: ExpressionGroupValue[]) => {
|
||||
onChange?.(nextGroups)
|
||||
}
|
||||
|
||||
const addGroup = () => {
|
||||
updateGroups([...groups, { expression_groups: [] }])
|
||||
}
|
||||
|
||||
const removeGroup = (groupIndex: number) => {
|
||||
updateGroups(groups.filter((_, index) => index !== groupIndex))
|
||||
}
|
||||
|
||||
const addMember = (groupIndex: number) => {
|
||||
updateGroups(
|
||||
groups.map((group, index) =>
|
||||
index === groupIndex
|
||||
? {
|
||||
expression_groups: [
|
||||
...group.expression_groups,
|
||||
createExpressionTarget(),
|
||||
],
|
||||
}
|
||||
: group
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const removeMember = (groupIndex: number, memberIndex: number) => {
|
||||
updateGroups(
|
||||
groups.map((group, index) =>
|
||||
index === groupIndex
|
||||
? {
|
||||
expression_groups: group.expression_groups.filter(
|
||||
(_, currentMemberIndex) => currentMemberIndex !== memberIndex
|
||||
),
|
||||
}
|
||||
: group
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const updateMember = (
|
||||
groupIndex: number,
|
||||
memberIndex: number,
|
||||
patch: Partial<ExpressionGroupTarget>
|
||||
) => {
|
||||
updateGroups(
|
||||
groups.map((group, index) =>
|
||||
index === groupIndex
|
||||
? {
|
||||
expression_groups: group.expression_groups.map(
|
||||
(member, currentMemberIndex) =>
|
||||
currentMemberIndex === memberIndex
|
||||
? { ...member, ...patch }
|
||||
: member
|
||||
),
|
||||
}
|
||||
: group
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border bg-card p-4 sm:p-5">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-semibold">表达互通组</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
每个互通组内的聊天流会共享已学习的表达方式。成员会保存为
|
||||
expression_groups 数组结构。
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="outline" onClick={addGroup}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加互通组
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed bg-muted/30 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
暂无互通组,点击“添加互通组”开始配置。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{groups.map((group, groupIndex) => (
|
||||
<div
|
||||
key={groupIndex}
|
||||
className="space-y-2 rounded-md border bg-muted/20 p-2.5 sm:p-3"
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
互通组 {groupIndex + 1}
|
||||
</span>
|
||||
<Badge variant="secondary">
|
||||
{group.expression_groups.length} 个成员
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => addMember(groupIndex)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加成员
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={`删除互通组 ${groupIndex + 1}`}
|
||||
onClick={() => removeGroup(groupIndex)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{group.expression_groups.length === 0 ? (
|
||||
<div className="rounded-md bg-background/70 px-3 py-4 text-sm text-muted-foreground">
|
||||
这个互通组还没有成员。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{group.expression_groups.map((member, memberIndex) => (
|
||||
<div
|
||||
key={`${groupIndex}-${memberIndex}`}
|
||||
className="grid items-end gap-2 rounded-md bg-background/80 px-2.5 py-2 md:grid-cols-[minmax(6rem,0.65fr)_minmax(9rem,1fr)_minmax(7rem,0.75fr)_2.25rem]"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-[11px] leading-none text-muted-foreground">平台</Label>
|
||||
<Input
|
||||
className="h-8"
|
||||
value={member.platform}
|
||||
placeholder="qq"
|
||||
onChange={(event) =>
|
||||
updateMember(groupIndex, memberIndex, {
|
||||
platform: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-[11px] leading-none text-muted-foreground">账号 / 群号</Label>
|
||||
<Input
|
||||
className="h-8 font-mono"
|
||||
value={member.item_id}
|
||||
placeholder="123456"
|
||||
onChange={(event) =>
|
||||
updateMember(groupIndex, memberIndex, {
|
||||
item_id: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-[11px] leading-none text-muted-foreground">类型</Label>
|
||||
<Select
|
||||
value={member.rule_type}
|
||||
onValueChange={(nextRuleType) =>
|
||||
updateMember(groupIndex, memberIndex, {
|
||||
rule_type: normalizeExpressionRuleType(nextRuleType),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="group">群聊</SelectItem>
|
||||
<SelectItem value="private">私聊</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-end justify-between gap-2 md:justify-end">
|
||||
<span className="min-w-0 truncate text-xs text-muted-foreground md:hidden">
|
||||
{formatExpressionTarget(member)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
aria-label={`删除互通组 ${groupIndex + 1} 的成员 ${memberIndex + 1}`}
|
||||
onClick={() => removeMember(groupIndex, memberIndex)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MCPRootItemsHook = createJsonFieldHook({
|
||||
emptyValue: [],
|
||||
|
||||
@@ -11,6 +11,8 @@ export type {
|
||||
UseAutoSaveReturnGeneric,
|
||||
} from './useAutoSave'
|
||||
export {
|
||||
BotPlatformsHook,
|
||||
ChatPromptsHook,
|
||||
ChatTalkValueRulesHook,
|
||||
ExpressionGroupsHook,
|
||||
ExpressionLearningListHook,
|
||||
|
||||
@@ -288,10 +288,23 @@ export function useConfigAutoSave<T>(
|
||||
isInitialLoad: boolean,
|
||||
triggerAutoSave: (sectionName: ConfigSectionName, data: unknown) => void
|
||||
): void {
|
||||
const previousSnapshotRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (config && !isInitialLoad) {
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
const snapshot = JSON.stringify(config)
|
||||
if (isInitialLoad || previousSnapshotRef.current === null) {
|
||||
previousSnapshotRef.current = snapshot
|
||||
return
|
||||
}
|
||||
|
||||
if (snapshot !== previousSnapshotRef.current) {
|
||||
previousSnapshotRef.current = snapshot
|
||||
triggerAutoSave(sectionName, config)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config])
|
||||
}, [config, isInitialLoad])
|
||||
}
|
||||
|
||||
@@ -63,27 +63,29 @@ export const BotInfoSection = React.memo(function BotInfoSection({ config, onCha
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">基本信息</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">基础</h3>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="platform">平台</Label>
|
||||
<Input
|
||||
id="platform"
|
||||
value={config.platform}
|
||||
onChange={(e) => onChange({ ...config, platform: e.target.value })}
|
||||
placeholder="qq"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="platform">平台</Label>
|
||||
<Input
|
||||
id="platform"
|
||||
value={config.platform}
|
||||
onChange={(e) => onChange({ ...config, platform: e.target.value })}
|
||||
placeholder="qq"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="qq_account">QQ账号</Label>
|
||||
<Input
|
||||
id="qq_account"
|
||||
value={config.qq_account}
|
||||
onChange={(e) => onChange({ ...config, qq_account: e.target.value })}
|
||||
placeholder="123456789"
|
||||
/>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="qq_account">QQ账号</Label>
|
||||
<Input
|
||||
id="qq_account"
|
||||
value={config.qq_account}
|
||||
onChange={(e) => onChange({ ...config, qq_account: e.target.value })}
|
||||
placeholder="123456789"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
|
||||
@@ -311,23 +311,6 @@ export const FeaturesSection = React.memo(function FeaturesSection({
|
||||
启用表情包过滤
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{emojiConfig.content_filtration && (
|
||||
<div className="grid gap-2 pl-6 border-l-2 border-primary/20">
|
||||
<Label htmlFor="filtration_prompt">过滤要求</Label>
|
||||
<Input
|
||||
id="filtration_prompt"
|
||||
value={emojiConfig.filtration_prompt}
|
||||
onChange={(e) =>
|
||||
onEmojiChange({ ...emojiConfig, filtration_prompt: e.target.value })
|
||||
}
|
||||
placeholder="符合公序良俗"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
只有符合此要求的表情包才会被保存
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -78,7 +78,6 @@ export interface EmojiConfig {
|
||||
check_interval: number
|
||||
steal_emoji: boolean
|
||||
content_filtration: boolean
|
||||
filtration_prompt: string
|
||||
}
|
||||
|
||||
export interface MemoryConfig {
|
||||
|
||||
@@ -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,13 +137,8 @@ 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({
|
||||
const { clearTimers: clearAutoSaveTimers, initialLoadRef, resetSnapshots } = useModelAutoSave({
|
||||
models,
|
||||
taskConfig,
|
||||
onSavingChange: setAutoSaving,
|
||||
@@ -198,6 +200,7 @@ function ModelConfigPageContent() {
|
||||
|
||||
const taskConf = (config.model_task_config as ModelTaskConfig) || null
|
||||
setTaskConfig(taskConf)
|
||||
resetSnapshots(modelList, taskConf)
|
||||
|
||||
// 解析 model_task_config 的 schema
|
||||
if (schemaResult.success && schemaResult.data) {
|
||||
@@ -218,7 +221,7 @@ function ModelConfigPageContent() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [initialLoadRef, checkTaskConfigIssues])
|
||||
}, [initialLoadRef, checkTaskConfigIssues, resetSnapshots])
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
@@ -251,6 +254,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 +299,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 ?? {},
|
||||
}
|
||||
@@ -327,6 +344,7 @@ function ModelConfigPageContent() {
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
resetSnapshots(config.models as ModelInfo[], taskConfig)
|
||||
setHasUnsavedChanges(false)
|
||||
toast({
|
||||
title: '保存成功',
|
||||
@@ -376,6 +394,7 @@ function ModelConfigPageContent() {
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
resetSnapshots(config.models as ModelInfo[], taskConfig)
|
||||
setHasUnsavedChanges(false)
|
||||
toast({
|
||||
title: '保存成功',
|
||||
@@ -406,16 +425,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 +488,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 +824,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 +880,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 +1022,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 +1044,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 +1349,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 +1575,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
|
||||
|
||||
@@ -25,6 +25,7 @@ interface UseModelAutoSaveReturn {
|
||||
clearTimers: () => void
|
||||
/** 初始加载状态标记引用 (用于设置初始加载完成) */
|
||||
initialLoadRef: RefObject<boolean>
|
||||
resetSnapshots: (nextModels: ModelInfo[], nextTaskConfig: ModelTaskConfig | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,6 +46,8 @@ export function useModelAutoSave(
|
||||
const modelsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const taskConfigTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const initialLoadRef = useRef(true)
|
||||
const modelsSnapshotRef = useRef<string | null>(null)
|
||||
const taskConfigSnapshotRef = useRef<string | null>(null)
|
||||
|
||||
// 清除定时器
|
||||
const clearTimers = useCallback(() => {
|
||||
@@ -66,6 +69,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 ?? {},
|
||||
}
|
||||
@@ -80,6 +86,19 @@ export function useModelAutoSave(
|
||||
}, [])
|
||||
|
||||
// 自动保存模型列表
|
||||
const snapshotModels = useCallback((nextModels: ModelInfo[]): string => {
|
||||
return JSON.stringify(nextModels.map(cleanModelForSave))
|
||||
}, [cleanModelForSave])
|
||||
|
||||
const snapshotTaskConfig = useCallback((nextTaskConfig: ModelTaskConfig | null): string | null => {
|
||||
return nextTaskConfig ? JSON.stringify(nextTaskConfig) : null
|
||||
}, [])
|
||||
|
||||
const resetSnapshots = useCallback((nextModels: ModelInfo[], nextTaskConfig: ModelTaskConfig | null) => {
|
||||
modelsSnapshotRef.current = snapshotModels(nextModels)
|
||||
taskConfigSnapshotRef.current = snapshotTaskConfig(nextTaskConfig)
|
||||
}, [snapshotModels, snapshotTaskConfig])
|
||||
|
||||
const autoSaveModels = useCallback(async (newModels: ModelInfo[]) => {
|
||||
try {
|
||||
onSavingChange?.(true)
|
||||
@@ -89,6 +108,7 @@ export function useModelAutoSave(
|
||||
if (!result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
modelsSnapshotRef.current = JSON.stringify(cleanedModels)
|
||||
onUnsavedChange?.(false)
|
||||
} catch (error) {
|
||||
console.error('自动保存模型列表失败:', error)
|
||||
@@ -106,6 +126,7 @@ export function useModelAutoSave(
|
||||
if (!result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
taskConfigSnapshotRef.current = JSON.stringify(newTaskConfig)
|
||||
onUnsavedChange?.(false)
|
||||
} catch (error) {
|
||||
console.error('自动保存任务配置失败:', error)
|
||||
@@ -119,6 +140,13 @@ export function useModelAutoSave(
|
||||
useEffect(() => {
|
||||
if (initialLoadRef.current) return
|
||||
|
||||
const snapshot = snapshotModels(models)
|
||||
if (modelsSnapshotRef.current === null) {
|
||||
modelsSnapshotRef.current = snapshot
|
||||
return
|
||||
}
|
||||
if (snapshot === modelsSnapshotRef.current) return
|
||||
|
||||
onUnsavedChange?.(true)
|
||||
|
||||
if (modelsTimerRef.current) {
|
||||
@@ -134,12 +162,19 @@ export function useModelAutoSave(
|
||||
clearTimeout(modelsTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [models, autoSaveModels, debounceMs, onUnsavedChange])
|
||||
}, [models, autoSaveModels, debounceMs, onUnsavedChange, snapshotModels])
|
||||
|
||||
// 监听 taskConfig 变化
|
||||
useEffect(() => {
|
||||
if (initialLoadRef.current || !taskConfig) return
|
||||
|
||||
const snapshot = snapshotTaskConfig(taskConfig)
|
||||
if (taskConfigSnapshotRef.current === null) {
|
||||
taskConfigSnapshotRef.current = snapshot
|
||||
return
|
||||
}
|
||||
if (snapshot === taskConfigSnapshotRef.current) return
|
||||
|
||||
onUnsavedChange?.(true)
|
||||
|
||||
if (taskConfigTimerRef.current) {
|
||||
@@ -155,7 +190,7 @@ export function useModelAutoSave(
|
||||
clearTimeout(taskConfigTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [taskConfig, autoSaveTaskConfig, debounceMs, onUnsavedChange])
|
||||
}, [taskConfig, autoSaveTaskConfig, debounceMs, onUnsavedChange, snapshotTaskConfig])
|
||||
|
||||
// 组件卸载时清除定时器
|
||||
useEffect(() => {
|
||||
@@ -167,5 +202,6 @@ export function useModelAutoSave(
|
||||
return {
|
||||
clearTimers,
|
||||
initialLoadRef,
|
||||
resetSnapshots,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -75,6 +78,7 @@ function ModelProviderConfigPageContent() {
|
||||
|
||||
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const initialLoadRef = useRef(true)
|
||||
const providersSnapshotRef = useRef<string | null>(null)
|
||||
const prevTourStepRef = useRef(tourState.stepIndex)
|
||||
|
||||
// 注册 Tour
|
||||
@@ -158,7 +162,9 @@ function ModelProviderConfigPageContent() {
|
||||
return
|
||||
}
|
||||
const config = unwrapModelConfig(result.data)
|
||||
setProviders(Array.isArray(config.api_providers) ? config.api_providers as APIProvider[] : [])
|
||||
const providerList = Array.isArray(config.api_providers) ? config.api_providers as APIProvider[] : []
|
||||
setProviders(providerList)
|
||||
providersSnapshotRef.current = JSON.stringify(providerList.map(cleanProviderData))
|
||||
setHasUnsavedChanges(false)
|
||||
initialLoadRef.current = false
|
||||
} catch (error) {
|
||||
@@ -172,6 +178,11 @@ function ModelProviderConfigPageContent() {
|
||||
await triggerRestart()
|
||||
}
|
||||
|
||||
const dismissRestartNotice = () => {
|
||||
localStorage.setItem('model-provider-restart-notice-dismissed', 'true')
|
||||
setRestartNoticeVisible(false)
|
||||
}
|
||||
|
||||
const handleSaveAndRestart = async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
@@ -223,6 +234,7 @@ function ModelProviderConfigPageContent() {
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
providersSnapshotRef.current = JSON.stringify(cleanedProviders)
|
||||
setHasUnsavedChanges(false)
|
||||
toast({
|
||||
title: '保存成功',
|
||||
@@ -348,6 +360,7 @@ function ModelProviderConfigPageContent() {
|
||||
}
|
||||
|
||||
setProviders(deleteConfirmState.pendingProviders)
|
||||
providersSnapshotRef.current = JSON.stringify(cleanedProviders)
|
||||
setHasUnsavedChanges(false)
|
||||
|
||||
toast({
|
||||
@@ -423,6 +436,7 @@ function ModelProviderConfigPageContent() {
|
||||
setHasUnsavedChanges(true)
|
||||
return
|
||||
}
|
||||
providersSnapshotRef.current = JSON.stringify(cleanedProviders)
|
||||
setHasUnsavedChanges(false)
|
||||
} catch (error) {
|
||||
console.error('自动保存失败:', error)
|
||||
@@ -440,6 +454,13 @@ function ModelProviderConfigPageContent() {
|
||||
useEffect(() => {
|
||||
if (initialLoadRef.current) return
|
||||
|
||||
const snapshot = JSON.stringify(providers.map(cleanProviderData))
|
||||
if (providersSnapshotRef.current === null) {
|
||||
providersSnapshotRef.current = snapshot
|
||||
return
|
||||
}
|
||||
if (snapshot === providersSnapshotRef.current) return
|
||||
|
||||
setHasUnsavedChanges(true)
|
||||
|
||||
if (autoSaveTimerRef.current) {
|
||||
@@ -521,6 +542,7 @@ function ModelProviderConfigPageContent() {
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
providersSnapshotRef.current = JSON.stringify(cleanedProviders)
|
||||
setHasUnsavedChanges(false)
|
||||
toast({
|
||||
title: '保存成功',
|
||||
@@ -796,12 +818,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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { FileText, Loader2, RefreshCw, Save, Search } from 'lucide-react'
|
||||
import { FileText, Loader2, RefreshCw, Save, Search, SlidersHorizontal } from 'lucide-react'
|
||||
|
||||
import { CodeEditor } from '@/components/CodeEditor'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -36,6 +36,7 @@ export function PromptManagementPage() {
|
||||
const [loadingFile, setLoadingFile] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [showAdvancedPrompts, setShowAdvancedPrompts] = useState(false)
|
||||
|
||||
const hasUnsavedChanges = content !== savedContent
|
||||
|
||||
@@ -44,13 +45,30 @@ export function PromptManagementPage() {
|
||||
return catalog.files[language] ?? []
|
||||
}, [catalog, language])
|
||||
|
||||
const visiblePromptFiles = useMemo<PromptFileInfo[]>(() => {
|
||||
return showAdvancedPrompts ? promptFiles : promptFiles.filter((file) => !file.advanced)
|
||||
}, [promptFiles, showAdvancedPrompts])
|
||||
|
||||
const filteredFiles = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
if (!normalizedQuery) return promptFiles
|
||||
return promptFiles.filter((file) => file.name.toLowerCase().includes(normalizedQuery))
|
||||
}, [promptFiles, query])
|
||||
if (!normalizedQuery) return visiblePromptFiles
|
||||
return visiblePromptFiles.filter((file) => {
|
||||
const searchableText = [
|
||||
file.name,
|
||||
file.display_name,
|
||||
file.description,
|
||||
].join(' ').toLowerCase()
|
||||
return searchableText.includes(normalizedQuery)
|
||||
})
|
||||
}, [visiblePromptFiles, query])
|
||||
|
||||
const selectedFile = promptFiles.find((file) => file.name === filename)
|
||||
useEffect(() => {
|
||||
if (!filename || showAdvancedPrompts) return
|
||||
const currentFile = promptFiles.find((file) => file.name === filename)
|
||||
if (!currentFile?.advanced) return
|
||||
setFilename(visiblePromptFiles[0]?.name ?? '')
|
||||
}, [filename, promptFiles, showAdvancedPrompts, visiblePromptFiles])
|
||||
|
||||
const loadCatalog = useCallback(async () => {
|
||||
try {
|
||||
@@ -70,7 +88,10 @@ export function PromptManagementPage() {
|
||||
setLanguage(nextLanguage)
|
||||
|
||||
const nextFiles = nextLanguage ? result.data.files[nextLanguage] ?? [] : []
|
||||
setFilename((current) => nextFiles.some((file) => file.name === current) ? current : nextFiles[0]?.name ?? '')
|
||||
const nextBasicFiles = nextFiles.filter((file) => !file.advanced)
|
||||
setFilename((current) =>
|
||||
nextFiles.some((file) => file.name === current) ? current : nextBasicFiles[0]?.name ?? nextFiles[0]?.name ?? ''
|
||||
)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '加载 Prompt 目录失败',
|
||||
@@ -130,7 +151,8 @@ export function PromptManagementPage() {
|
||||
setLanguage(nextLanguage)
|
||||
setQuery('')
|
||||
const nextFiles = catalog?.files[nextLanguage] ?? []
|
||||
setFilename(nextFiles[0]?.name ?? '')
|
||||
const nextVisibleFiles = showAdvancedPrompts ? nextFiles : nextFiles.filter((file) => !file.advanced)
|
||||
setFilename(nextVisibleFiles[0]?.name ?? '')
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -181,6 +203,14 @@ export function PromptManagementPage() {
|
||||
<RefreshCw className={cn('mr-2 h-4 w-4', loadingCatalog && 'animate-spin')} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant={showAdvancedPrompts ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedPrompts((current) => !current)}
|
||||
>
|
||||
<SlidersHorizontal className="mr-2 h-4 w-4" />
|
||||
{showAdvancedPrompts ? '隐藏高级' : '显示高级'}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!hasUnsavedChanges || saving || loadingFile || !filename}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{saving ? '保存中' : hasUnsavedChanges ? '保存' : '已保存'}
|
||||
@@ -194,7 +224,7 @@ export function PromptManagementPage() {
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<FileText className="h-4 w-4" />
|
||||
Prompt 文件
|
||||
<Badge variant="secondary" className="ml-auto">{promptFiles.length}</Badge>
|
||||
<Badge variant="secondary" className="ml-auto">{filteredFiles.length}</Badge>
|
||||
</CardTitle>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
@@ -226,8 +256,16 @@ export function PromptManagementPage() {
|
||||
filename === file.name ? 'bg-accent text-accent-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<div className="truncate font-medium" title={file.name}>{file.name}</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">{formatFileSize(file.size)}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate font-medium" title={file.display_name || file.name}>
|
||||
{file.display_name || file.name}
|
||||
</div>
|
||||
{file.advanced && <Badge variant="outline" className="shrink-0 text-[10px]">高级</Badge>}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-xs text-muted-foreground">{file.name} · {formatFileSize(file.size)}</div>
|
||||
{file.description && (
|
||||
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{file.description}</div>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
@@ -240,12 +278,18 @@ export function PromptManagementPage() {
|
||||
<Card className="min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0 pb-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate text-sm">{filename || '未选择文件'}</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2 truncate text-sm">
|
||||
<span className="truncate">{selectedFile?.display_name || filename || '未选择文件'}</span>
|
||||
{selectedFile?.advanced && <Badge variant="outline" className="shrink-0">高级</Badge>}
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{language}
|
||||
{selectedFile ? ` · ${formatFileSize(selectedFile.size)}` : ''}
|
||||
{hasUnsavedChanges ? ' · 有未保存修改' : ''}
|
||||
</p>
|
||||
{selectedFile?.description && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{selectedFile.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-0 p-0">
|
||||
|
||||
@@ -22,16 +22,26 @@ import { zhCN } from 'date-fns/locale'
|
||||
|
||||
// 字号配置
|
||||
type FontSize = 'xs' | 'sm' | 'base'
|
||||
type LogLevelFilter = LogEntry['level'] | 'all'
|
||||
|
||||
const fontSizeConfig: Record<FontSize, { label: string; rowHeight: number; class: string }> = {
|
||||
xs: { label: '小', rowHeight: 28, class: 'text-[10px] sm:text-xs' },
|
||||
sm: { label: '中', rowHeight: 36, class: 'text-xs sm:text-sm' },
|
||||
base: { label: '大', rowHeight: 44, class: 'text-sm sm:text-base' },
|
||||
}
|
||||
|
||||
const levelPriority: Record<LogEntry['level'], number> = {
|
||||
DEBUG: 10,
|
||||
INFO: 20,
|
||||
WARNING: 30,
|
||||
ERROR: 40,
|
||||
CRITICAL: 50,
|
||||
}
|
||||
|
||||
export function LogViewerPage() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [levelFilter, setLevelFilter] = useState<string>('all')
|
||||
const [levelFilter, setLevelFilter] = useState<LogLevelFilter>('INFO')
|
||||
const [moduleFilter, setModuleFilter] = useState<string>('all')
|
||||
const [dateFrom, setDateFrom] = useState<Date | undefined>(undefined)
|
||||
const [dateTo, setDateTo] = useState<Date | undefined>(undefined)
|
||||
@@ -154,8 +164,10 @@ export function LogViewerPage() {
|
||||
log.message.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
log.module.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|
||||
// 级别过滤
|
||||
const matchesLevel = levelFilter === 'all' || log.level === levelFilter
|
||||
// 级别过滤:选择某个级别时显示该级别及以上的日志
|
||||
const matchesLevel =
|
||||
levelFilter === 'all' ||
|
||||
levelPriority[log.level] >= levelPriority[levelFilter]
|
||||
|
||||
// 模块过滤
|
||||
const matchesModule = moduleFilter === 'all' || log.module === moduleFilter
|
||||
@@ -355,17 +367,17 @@ export function LogViewerPage() {
|
||||
<CollapsibleContent className="space-y-2">
|
||||
{/* 级别和模块筛选 */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:gap-2">
|
||||
<Select value={levelFilter} onValueChange={setLevelFilter}>
|
||||
<Select value={levelFilter} onValueChange={(value) => setLevelFilter(value as LogLevelFilter)}>
|
||||
<SelectTrigger className="w-full sm:flex-1 h-8 text-xs">
|
||||
<Filter className="h-3.5 w-3.5 mr-1.5" />
|
||||
<SelectValue placeholder="级别" />
|
||||
<SelectValue placeholder="最低级别" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部级别</SelectItem>
|
||||
<SelectItem value="DEBUG">DEBUG</SelectItem>
|
||||
<SelectItem value="INFO">INFO</SelectItem>
|
||||
<SelectItem value="WARNING">WARNING</SelectItem>
|
||||
<SelectItem value="ERROR">ERROR</SelectItem>
|
||||
<SelectItem value="DEBUG">DEBUG 及以上</SelectItem>
|
||||
<SelectItem value="INFO">INFO 及以上</SelectItem>
|
||||
<SelectItem value="WARNING">WARNING 及以上</SelectItem>
|
||||
<SelectItem value="ERROR">ERROR 及以上</SelectItem>
|
||||
<SelectItem value="CRITICAL">CRITICAL</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -43,6 +43,11 @@ export function MarketplaceTab({
|
||||
console.warn('[过滤] 跳过无 manifest 的插件:', plugin.id)
|
||||
return false
|
||||
}
|
||||
|
||||
// 全部插件只展示 plugin-repo 中存在的市场插件,本地独有插件只在“已安装”显示。
|
||||
if (plugin.source === 'local') {
|
||||
return false
|
||||
}
|
||||
|
||||
// 搜索过滤
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
|
||||
@@ -239,6 +239,7 @@ function PluginsPageContent() {
|
||||
review_count: 0,
|
||||
installed: true,
|
||||
installed_version: installedPlugin.manifest.version,
|
||||
source: 'local',
|
||||
published_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
@@ -636,6 +637,7 @@ function PluginsPageContent() {
|
||||
const getFilteredPluginCount = (tab: 'all' | 'installed' | 'updates') => {
|
||||
return plugins.filter(p => {
|
||||
if (!p.manifest) return false
|
||||
if (tab === 'all' && p.source === 'local') return false
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
p.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
|
||||
@@ -63,7 +63,7 @@ export function EmojiManagementPage() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [registeredFilter, setRegisteredFilter] = useState<string>('all')
|
||||
const [registeredFilter, setRegisteredFilter] = useState<string>('registered')
|
||||
const [bannedFilter, setBannedFilter] = useState<string>('all')
|
||||
const [formatFilter, setFormatFilter] = useState<string>('all')
|
||||
const [sortBy, setSortBy] = useState<string>('usage_count')
|
||||
@@ -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>
|
||||
@@ -449,8 +449,8 @@ export function EmojiManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 pt-4 border-t">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{selectedIds.size > 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
已选择 {selectedIds.size} 个表情包
|
||||
@@ -477,8 +477,41 @@ export function EmojiManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadEmojiList}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
刷新
|
||||
</Button>
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setBatchDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
批量删除
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
<div className="flex items-center gap-2 sm:ml-auto">
|
||||
<Label
|
||||
htmlFor="emoji-page-size"
|
||||
className="text-sm whitespace-nowrap"
|
||||
@@ -503,41 +536,8 @@ export function EmojiManagementPage() {
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedIds.size > 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setBatchDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
批量删除
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadEmojiList}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -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">
|
||||
管理麦麦学习到的黑话和俗语
|
||||
|
||||
@@ -44,7 +44,7 @@ function normalizePlatform(raw: string): string {
|
||||
function deriveSelectedPlatform(config: BotBasicConfig): { selected: string; customName: string } {
|
||||
const platform = config.platform
|
||||
// Legacy: no platform set but has QQ account
|
||||
if (!platform && config.qq_account > 0) {
|
||||
if (!platform && config.qq_account.trim()) {
|
||||
return { selected: 'qq', customName: '' }
|
||||
}
|
||||
if (!platform) {
|
||||
@@ -96,9 +96,7 @@ export function BotBasicForm({ config, onChange }: BotBasicFormProps) {
|
||||
const customPlatformName = customPlatformNameOverride ?? derived.customName
|
||||
const primaryAccount =
|
||||
selectedPlatform === 'qq'
|
||||
? config.qq_account > 0
|
||||
? String(config.qq_account)
|
||||
: ''
|
||||
? config.qq_account.trim()
|
||||
: config.platform
|
||||
? getPrimaryAccount(config.platforms, config.platform)
|
||||
: ''
|
||||
@@ -141,7 +139,7 @@ export function BotBasicForm({ config, onChange }: BotBasicFormProps) {
|
||||
if (normalized === 'qq') {
|
||||
onChange({
|
||||
...config,
|
||||
qq_account: Number(accountId) || 0,
|
||||
qq_account: accountId.trim(),
|
||||
platform: 'qq',
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -61,10 +61,11 @@ export async function loadBotBasicConfig(): Promise<BotBasicConfig> {
|
||||
)
|
||||
const data = throwIfError(result)
|
||||
const botConfig = (data.config.bot || {}) as Partial<BotBasicConfig>
|
||||
const qqAccount = String(botConfig.qq_account ?? '').trim()
|
||||
|
||||
return {
|
||||
platform: botConfig.platform || (botConfig.qq_account ? 'qq' : ''),
|
||||
qq_account: botConfig.qq_account || 0,
|
||||
platform: botConfig.platform || (qqAccount ? 'qq' : ''),
|
||||
qq_account: qqAccount,
|
||||
platforms: botConfig.platforms || [],
|
||||
nickname: botConfig.nickname || '',
|
||||
alias_names: botConfig.alias_names || [],
|
||||
|
||||
@@ -106,7 +106,7 @@ function SetupPageContent() {
|
||||
// 步骤1:Bot基础信息
|
||||
const [botBasic, setBotBasic] = useState<BotBasicConfig>({
|
||||
platform: '',
|
||||
qq_account: 0,
|
||||
qq_account: '',
|
||||
platforms: [],
|
||||
nickname: '',
|
||||
alias_names: [],
|
||||
@@ -239,7 +239,7 @@ function SetupPageContent() {
|
||||
if (!config.platform) return t('setupPage.validation.selectPlatform')
|
||||
if (!config.nickname.trim()) return t('setupPage.validation.enterNickname')
|
||||
if (config.platform === 'qq') {
|
||||
if (!config.qq_account || config.qq_account <= 0) {
|
||||
if (!config.qq_account.trim()) {
|
||||
return t('setupPage.validation.enterQqAccount')
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface SetupStep {
|
||||
// 步骤1:Bot基础信息
|
||||
export interface BotBasicConfig {
|
||||
platform: string // Primary platform name (normalized, lowercase)
|
||||
qq_account: number // QQ account (preserved always for webui compat)
|
||||
qq_account: string // QQ account (preserved always for webui compat)
|
||||
platforms: string[] // Other platform accounts "platform:account"
|
||||
nickname: string
|
||||
alias_names: string[]
|
||||
|
||||
@@ -40,6 +40,10 @@ export interface FieldSchema {
|
||||
'x-icon'?: string
|
||||
'x-layout'?: 'inline-right'
|
||||
'x-input-width'?: string
|
||||
'x-option-descriptions'?: Record<string, string>
|
||||
'x-row'?: string
|
||||
'x-textarea-min-height'?: number
|
||||
'x-textarea-rows'?: number
|
||||
advanced?: boolean
|
||||
step?: number
|
||||
}
|
||||
@@ -52,7 +56,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
|
||||
|
||||
@@ -82,6 +82,8 @@ export interface PluginInfo {
|
||||
screenshots?: string[]
|
||||
/** 更新日志 */
|
||||
changelog?: string
|
||||
/** 插件来源:plugin-repo 市场或本地已安装插件 */
|
||||
source?: 'market' | 'local'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user