merge: 同步上游 dev 最新内容
This commit is contained in:
@@ -21,3 +21,5 @@ temp/
|
||||
tmp/
|
||||
mai_knowledge/
|
||||
depends-data/
|
||||
!depends-data/
|
||||
!depends-data/char_frequency.json
|
||||
|
||||
@@ -32,8 +32,7 @@
|
||||
|
||||
# 运行/调试/构建/测试/依赖
|
||||
优先使用uv
|
||||
依赖项以 pyproject.toml 为准
|
||||
|
||||
依赖项以 pyproject.toml 为准,要同步更新requirements.txt
|
||||
|
||||
# 语言规范
|
||||
项目的首选语言为简体中文,无论是注释语言,日志展示语言,还是 WebUI 展示语言都首要以简体中文为首要实现目标
|
||||
@@ -45,6 +44,9 @@
|
||||
# 关于 A_memorix 修改
|
||||
如果修改涉及 `src/A_memorix`,请先阅读 `src/A_memorix/MODIFICATION_POLICY.md`。
|
||||
|
||||
# prompt模板、
|
||||
涉及对prompt模板的修改,要同步修改英文和日文的文件,对齐到中文
|
||||
|
||||
默认原则:
|
||||
1. `src/A_memorix` 的实现层改动应优先遵守 `src/A_memorix/MODIFICATION_POLICY.md` 中的归属约束。
|
||||
2. 不要提交无边界的 `ruff`、格式化、导入整理或大面积实现整理。
|
||||
@@ -52,3 +54,6 @@
|
||||
|
||||
# maibot插件开发文档
|
||||
https://github.com/Mai-with-u/maibot-plugin-sdk/blob/main/docs/guide.md
|
||||
|
||||
# 如何提交maibot插件
|
||||
https://github.com/Mai-with-u/plugin-repo/blob/main/CONTRIBUTING.md
|
||||
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'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,7 @@ services:
|
||||
- ./data/MaiMBot/emoji:/data/emoji # 持久化表情包
|
||||
- ./data/MaiMBot/plugins:/MaiMBot/plugins # 插件目录
|
||||
- ./data/MaiMBot/logs:/MaiMBot/logs # 日志目录
|
||||
- ./depends-data:/MaiMBot/depends-data:ro # 运行时资源文件
|
||||
# - site-packages:/usr/local/lib/python3.13/site-packages # 持久化Python包,需要时启用
|
||||
restart: always
|
||||
networks:
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
"startup.event_loop_closed": "[主程序] 事件循环已关闭",
|
||||
"startup.file_not_found": "{file_type} 文件不存在",
|
||||
"startup.graceful_shutdown_error": "优雅关闭时发生错误: {error}",
|
||||
"startup.initialization_completed_banner": "\n--------------------------------\n全部系统初始化完成,{nickname} 已成功唤醒\n--------------------------------\n如果想要自定义 {nickname} 的功能,请查阅:https://docs.mai-mai.org/manual/usage/\n或者遇到了问题,请访问我们的文档:https://docs.mai-mai.org/\n--------------------------------\n如果你想要编写或了解插件相关内容,请访问开发文档 https://docs.mai-mai.org/develop/\n--------------------------------\n如果你需要查阅模型的消耗以及麦麦的统计数据,请访问根目录的 maibot_statistics.html 文件\n",
|
||||
"startup.initialization_completed_banner": "全部系统初始化完成,{nickname} 已成功唤醒",
|
||||
"startup.initialization_completed_cycles": "初始化完成,神经元放电 {init_time} 次",
|
||||
"startup.interrupt_received": "收到中断信号,正在优雅关闭...",
|
||||
"startup.launching_script": "正在启动 {script_file}...",
|
||||
"startup.launching_script": "正在启动MaiBot",
|
||||
"startup.logging_shutdown_error": "关闭日志系统时出错: {error}",
|
||||
"startup.main_error": "主程序发生异常: {error}",
|
||||
"startup.opensource_free_notice": " 本项目是完全免费的开源软件,基于 GPL-3.0 协议发布",
|
||||
@@ -52,7 +52,7 @@
|
||||
"startup.shutdown_failed": "麦麦关闭失败: {error}",
|
||||
"startup.shutdown_started": "正在优雅关闭麦麦...",
|
||||
"startup.waking_up": "正在唤醒 {nickname}......",
|
||||
"startup.webui_access_token": "🔑 WebUI Access Token: {token}",
|
||||
"startup.webui_access_token": "🔑 WebUI 登录 Token: {token}",
|
||||
"startup.webui_access_token_failed": "❌ 获取 Access Token 失败: {error}",
|
||||
"startup.webui_access_token_login_hint": "💡 请使用此 Token 登录 WebUI",
|
||||
"startup.webui_anti_crawler_config_failed": "❌ 配置防爬虫中间件失败: {error}",
|
||||
|
||||
79
prompts/en-US/.meta.toml
Normal file
79
prompts/en-US/.meta.toml
Normal file
@@ -0,0 +1,79 @@
|
||||
[maisaka_chat]
|
||||
display_name = "Planner"
|
||||
advanced = false
|
||||
description = "Maisaka 主规划模板,负责整合身份、时间、工具说明和聊天上下文,驱动主循环进行思考、决策与工具调用。"
|
||||
|
||||
[maisaka_replyer]
|
||||
display_name = "Replyer"
|
||||
advanced = false
|
||||
description = "Maisaka 回复生成模板,负责根据人格、表达风格、群聊注意事项和待回复上下文生成最终回复。"
|
||||
|
||||
[default_expressor]
|
||||
display_name = "默认表达器"
|
||||
advanced = true
|
||||
description = "表达方式生成与整理相关模板,通常只在调试表达系统时需要修改。"
|
||||
|
||||
[emoji_content_analysis]
|
||||
display_name = "表情包内容分析"
|
||||
advanced = true
|
||||
description = "用于分析表情包图片内容的模板。"
|
||||
|
||||
[emoji_content_filtration]
|
||||
display_name = "表情包内容过滤"
|
||||
advanced = true
|
||||
description = "用于判断表情包内容是否符合过滤要求的模板。"
|
||||
|
||||
[emoji_replace]
|
||||
display_name = "表情包替换"
|
||||
advanced = true
|
||||
description = "用于根据文本语境选择或替换表情包的模板。"
|
||||
|
||||
[expression_evaluation]
|
||||
display_name = "表达评价"
|
||||
advanced = true
|
||||
description = "用于评价候选表达方式质量的模板。"
|
||||
|
||||
[expression_select]
|
||||
display_name = "表达选择"
|
||||
advanced = true
|
||||
description = "用于从表达库中选择合适表达方式的模板。"
|
||||
|
||||
[image_description]
|
||||
display_name = "图片描述"
|
||||
advanced = true
|
||||
description = "用于将图片内容转换为文本描述的模板。"
|
||||
|
||||
[jargon_compare_inference]
|
||||
display_name = "术语对比推理"
|
||||
advanced = true
|
||||
description = "用于比较和推理群内术语含义的模板。"
|
||||
|
||||
[jargon_explainer_summarize]
|
||||
display_name = "术语解释总结"
|
||||
advanced = true
|
||||
description = "用于总结术语解释结果的模板。"
|
||||
|
||||
[jargon_inference_content_only]
|
||||
display_name = "术语推理"
|
||||
advanced = true
|
||||
description = "用于仅基于内容推理术语含义的模板。"
|
||||
|
||||
[jargon_inference_with_context]
|
||||
display_name = "术语上下文推理"
|
||||
advanced = true
|
||||
description = "用于结合上下文推理术语含义的模板。"
|
||||
|
||||
[learn_style]
|
||||
display_name = "表达风格学习"
|
||||
advanced = true
|
||||
description = "用于从聊天内容中学习表达风格的模板。"
|
||||
|
||||
[maisaka_timing_gate]
|
||||
display_name = "Timing Gate"
|
||||
advanced = false
|
||||
description = "Maisaka 节奏控制模板,负责在每轮主循环前判断当前应该等待、停止,还是继续进入思考与回复流程。"
|
||||
|
||||
[memory_retrieval_react_prompt_head_memory]
|
||||
display_name = "记忆检索 ReAct 头部"
|
||||
advanced = true
|
||||
description = "ReAct 式记忆查询流程使用的高级记忆检索提示词头部。"
|
||||
@@ -1,5 +1,5 @@
|
||||
This is a sticker. Please review it according to the following criteria:
|
||||
1. It must meet the requirement of "{demand}"
|
||||
1. It must conform to public order and good morals
|
||||
2. It must not contain pornography, violence, or other illegal or non-compliant content, and it must conform to public order and good morals
|
||||
3. It must not be any form of screenshot, chat record, or video screenshot
|
||||
4. It must not contain more than 5 words
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
[Historical Topic Title List] (titles only, no specific content):
|
||||
{history_topics_block}
|
||||
[End of Historical Topic Title List]
|
||||
|
||||
[Current Chat Log] (each message has an index before it for later reference):
|
||||
{messages_block}
|
||||
[End of Current Chat Log]
|
||||
|
||||
Please complete the following tasks:
|
||||
**Identify topics**
|
||||
1. Identify one or more ongoing topics in the [Current Chat Log];
|
||||
2. Messages in the [Current Chat Log] may be related to historical topics, or they may be completely unrelated;
|
||||
3. Determine whether the topics in the [Historical Topic Title List] appear in the [Current Chat Log]. If they do, directly use that historical topic title string;
|
||||
|
||||
**Select messages**
|
||||
1. For each topic (whether new or historical), select a list of message indices from the numbered messages above that are strongly related to that topic;
|
||||
2. For each topic, use one sentence to clearly describe the event that is happening. It must include time (approximate is fine), people, the main event, and the theme, ensuring accuracy and distinction;
|
||||
|
||||
Please first output a short piece of reasoning explaining what topics exist, which are not included in the historical topics, which are included in the historical topics, and why;
|
||||
Then strictly output the topics involved in the [Current Chat Log] in JSON format as follows:
|
||||
[
|
||||
{{
|
||||
"topic": "topic",
|
||||
"message_indices": [1, 2, 5]
|
||||
}},
|
||||
...
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
Please summarize the following chat record segment based on the topic and extract the following information:
|
||||
|
||||
**Topic**: {topic}
|
||||
|
||||
**Requirements**:
|
||||
1. Keywords: extract keywords related to the topic and return them as a list (3-10 keywords)
|
||||
2. Summary: provide a plain-text summary of this segment (50-200 words). Requirements:
|
||||
- Carefully retell the event and the chat content;
|
||||
- Highlight the development process and the result of the event;
|
||||
- Summarize around the central topic;
|
||||
- Extract the key information points in the topic, and keep them concise and clear.
|
||||
|
||||
Please return in JSON format as follows:
|
||||
{{
|
||||
"keywords": ["keyword1", "keyword2", ...],
|
||||
"summary": "summary content"
|
||||
}}
|
||||
|
||||
Chat record:
|
||||
{original_text}
|
||||
|
||||
Please return JSON directly and do not include any other content.
|
||||
@@ -1,18 +0,0 @@
|
||||
You are an expert in analyzing user trait categories. Your task is to analyze the conversation content and determine which personal trait categories are involved.
|
||||
|
||||
Please carefully read the following conversation content and determine which personal trait categories are involved.
|
||||
|
||||
[Personal Trait Category List]
|
||||
{categories_summary}
|
||||
|
||||
[Task Requirements]
|
||||
1. Analyze the conversation content and determine which personal trait categories are involved
|
||||
2. Output only the category numbers involved, separated by spaces
|
||||
3. If the conversation content does not involve any personal trait category, output "none"
|
||||
|
||||
[Output Format Example]
|
||||
1 3 5
|
||||
or
|
||||
none
|
||||
|
||||
Please start analyzing:
|
||||
@@ -1,17 +0,0 @@
|
||||
You are an expert in extracting user trait information. Your task is to extract personal trait information related to the specified category from the conversation content.
|
||||
|
||||
[Target Category]
|
||||
{category_name}
|
||||
|
||||
[Task Requirements]
|
||||
1. Carefully read the conversation content and find all information related to "{category_name}"
|
||||
2. The extracted information should be specific and accurate, avoiding vague descriptions
|
||||
3. If there are multiple relevant pieces of information, merge them into one concise description
|
||||
4. If there is no information related to this category in the conversation, output "none"
|
||||
|
||||
[Output Format Example]
|
||||
The user is rather introverted and does not like speaking when many people are around, but becomes very lively with close friends.
|
||||
or
|
||||
none
|
||||
|
||||
Please start extracting:
|
||||
@@ -1,19 +0,0 @@
|
||||
You are an expert in retrieving user traits. Your task is to determine which categories of personal trait information need to be retrieved based on the current conversation context.
|
||||
|
||||
[Current Conversation Context]
|
||||
{chat_context}
|
||||
|
||||
[Personal Trait Category List]
|
||||
{categories_summary}
|
||||
|
||||
[Task Requirements]
|
||||
1. Analyze the current conversation context and determine which personal trait information is needed to help understand the user
|
||||
2. Output only the needed category numbers, separated by spaces
|
||||
3. If the current conversation does not need any personal trait information, output "none"
|
||||
|
||||
[Output Format Example]
|
||||
2 5 8
|
||||
or
|
||||
none
|
||||
|
||||
Please start analyzing:
|
||||
@@ -1,7 +1,5 @@
|
||||
You are chatting in a QQ group. Below is the content currently being discussed in the group, including chat records and images in the chat.
|
||||
Messages marked with {bot_name} (you) are your own messages, so please distinguish them carefully:
|
||||
|
||||
{time_block}
|
||||
Messages marked as your own messages should be distinguished carefully:
|
||||
|
||||
{identity}
|
||||
You are chatting in the group now. Please read the previous chat records, grasp the current topic, and then give a natural, colloquial reply.
|
||||
|
||||
79
prompts/ja-JP/.meta.toml
Normal file
79
prompts/ja-JP/.meta.toml
Normal file
@@ -0,0 +1,79 @@
|
||||
[maisaka_chat]
|
||||
display_name = "Planner"
|
||||
advanced = false
|
||||
description = "Maisaka 主规划模板,负责整合身份、时间、工具说明和聊天上下文,驱动主循环进行思考、决策与工具调用。"
|
||||
|
||||
[maisaka_replyer]
|
||||
display_name = "Replyer"
|
||||
advanced = false
|
||||
description = "Maisaka 回复生成模板,负责根据人格、表达风格、群聊注意事项和待回复上下文生成最终回复。"
|
||||
|
||||
[default_expressor]
|
||||
display_name = "默认表达器"
|
||||
advanced = true
|
||||
description = "表达方式生成与整理相关模板,通常只在调试表达系统时需要修改。"
|
||||
|
||||
[emoji_content_analysis]
|
||||
display_name = "表情包内容分析"
|
||||
advanced = true
|
||||
description = "用于分析表情包图片内容的模板。"
|
||||
|
||||
[emoji_content_filtration]
|
||||
display_name = "表情包内容过滤"
|
||||
advanced = true
|
||||
description = "用于判断表情包内容是否符合过滤要求的模板。"
|
||||
|
||||
[emoji_replace]
|
||||
display_name = "表情包替换"
|
||||
advanced = true
|
||||
description = "用于根据文本语境选择或替换表情包的模板。"
|
||||
|
||||
[expression_evaluation]
|
||||
display_name = "表达评价"
|
||||
advanced = true
|
||||
description = "用于评价候选表达方式质量的模板。"
|
||||
|
||||
[expression_select]
|
||||
display_name = "表达选择"
|
||||
advanced = true
|
||||
description = "用于从表达库中选择合适表达方式的模板。"
|
||||
|
||||
[image_description]
|
||||
display_name = "图片描述"
|
||||
advanced = true
|
||||
description = "用于将图片内容转换为文本描述的模板。"
|
||||
|
||||
[jargon_compare_inference]
|
||||
display_name = "术语对比推理"
|
||||
advanced = true
|
||||
description = "用于比较和推理群内术语含义的模板。"
|
||||
|
||||
[jargon_explainer_summarize]
|
||||
display_name = "术语解释总结"
|
||||
advanced = true
|
||||
description = "用于总结术语解释结果的模板。"
|
||||
|
||||
[jargon_inference_content_only]
|
||||
display_name = "术语推理"
|
||||
advanced = true
|
||||
description = "用于仅基于内容推理术语含义的模板。"
|
||||
|
||||
[jargon_inference_with_context]
|
||||
display_name = "术语上下文推理"
|
||||
advanced = true
|
||||
description = "用于结合上下文推理术语含义的模板。"
|
||||
|
||||
[learn_style]
|
||||
display_name = "表达风格学习"
|
||||
advanced = true
|
||||
description = "用于从聊天内容中学习表达风格的模板。"
|
||||
|
||||
[maisaka_timing_gate]
|
||||
display_name = "Timing Gate"
|
||||
advanced = false
|
||||
description = "Maisaka 节奏控制模板,负责在每轮主循环前判断当前应该等待、停止,还是继续进入思考与回复流程。"
|
||||
|
||||
[memory_retrieval_react_prompt_head_memory]
|
||||
display_name = "记忆检索 ReAct 头部"
|
||||
advanced = true
|
||||
description = "ReAct 式记忆查询流程使用的高级记忆检索提示词头部。"
|
||||
@@ -1,5 +1,5 @@
|
||||
これはスタンプです。次の基準に従って審査してください:
|
||||
1. "{demand}" の要求を満たしていること
|
||||
1. 公序良俗に反しないこと
|
||||
2. 色情、暴力などの違法・不適切な内容ではなく、公序良俗に反しないこと
|
||||
3. いかなる形式のスクリーンショット、チャット履歴、動画のスクリーンショットでもないこと
|
||||
4. 5文字を超える文字が含まれないこと
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
【過去の話題タイトル一覧】(タイトルのみ、具体的な内容は含まない):
|
||||
{history_topics_block}
|
||||
【過去の話題タイトル一覧ここまで】
|
||||
|
||||
【今回のチャット記録】(各メッセージの前に番号があり、後で参照するために使う):
|
||||
{messages_block}
|
||||
【今回のチャット記録ここまで】
|
||||
|
||||
以下のタスクを完了してください:
|
||||
**話題の識別**
|
||||
1. 【今回のチャット記録】に含まれる進行中の話題を 1 つ以上識別する;
|
||||
2. 【今回のチャット記録】中のメッセージは、過去の話題に関係している場合もあれば、まったく無関係な場合もある;
|
||||
3. 【過去の話題タイトル一覧】の話題が【今回のチャット記録】に現れているか判断し、現れている場合はその過去の話題タイトル文字列をそのまま使う;
|
||||
|
||||
**メッセージの選択**
|
||||
1. 各話題(新規話題または過去話題)について、上記の番号付きメッセージからその話題と強く関係するメッセージ番号一覧を選ぶ;
|
||||
2. 各話題について、何が起きているのかを 1 文で明確に説明すること。時間(おおまかで可)、人物、主な出来事、テーマを必ず含め、正確で区別しやすい内容にする;
|
||||
|
||||
まず短い思考を出力し、どんな話題があるか、どれが過去話題に含まれず、どれが過去話題に含まれているか、そしてその理由を説明してください;
|
||||
その後、【今回のチャット記録】に含まれる話題を次の JSON 形式で厳密に出力してください:
|
||||
[
|
||||
{{
|
||||
"topic": "話題",
|
||||
"message_indices": [1, 2, 5]
|
||||
}},
|
||||
...
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
以下の話題に基づいて、チャット記録の一部を要約し、次の情報を抽出してください:
|
||||
|
||||
**話題**:{topic}
|
||||
|
||||
**要件**:
|
||||
1. キーワード:話題に関連するキーワードを抽出し、リスト形式で返す(3〜10 個)
|
||||
2. 要約:この会話部分の平文要約を行う(50〜200 文字)。要件:
|
||||
- 起こった出来事とチャット内容を丁寧に言い換える;
|
||||
- 出来事の展開過程と結果を重点的に示す;
|
||||
- この話題という中心を軸に要約する;
|
||||
- 話題内の重要情報を抽出し、簡潔で明確にする。
|
||||
|
||||
JSON 形式で次のように返してください:
|
||||
{{
|
||||
"keywords": ["キーワード1", "キーワード2", ...],
|
||||
"summary": "要約内容"
|
||||
}}
|
||||
|
||||
チャット記録:
|
||||
{original_text}
|
||||
|
||||
JSON のみを直接返し、ほかの内容は含めないでください。
|
||||
@@ -1,18 +0,0 @@
|
||||
あなたはユーザー特性カテゴリ分析の専門家です。あなたのタスクは、会話内容を分析し、どの個人特性カテゴリが関係しているかを判断することです。
|
||||
|
||||
以下の会話内容を注意深く読み、どの個人特性カテゴリが関係しているかを判断してください。
|
||||
|
||||
【個人特性カテゴリ一覧】
|
||||
{categories_summary}
|
||||
|
||||
【タスク要件】
|
||||
1. 会話内容を分析し、どの個人特性カテゴリが関係しているかを判断する
|
||||
2. 関係しているカテゴリ番号だけを、スペース区切りで出力する
|
||||
3. 会話内容がどの個人特性カテゴリにも関係しない場合は「無」と出力する
|
||||
|
||||
【出力形式の例】
|
||||
1 3 5
|
||||
または
|
||||
無
|
||||
|
||||
分析を開始してください:
|
||||
@@ -1,17 +0,0 @@
|
||||
あなたはユーザー特性情報抽出の専門家です。あなたのタスクは、会話内容から指定カテゴリに関係する個人特性情報を抽出することです。
|
||||
|
||||
【対象カテゴリ】
|
||||
{category_name}
|
||||
|
||||
【タスク要件】
|
||||
1. 会話内容を注意深く読み、「{category_name}」に関係するすべての情報を見つける
|
||||
2. 抽出する情報は具体的かつ正確で、曖昧な表現を避ける
|
||||
3. 関連情報が複数ある場合は、簡潔な 1 段落にまとめる
|
||||
4. 会話内にこのカテゴリに関係する情報がない場合は「無」と出力する
|
||||
|
||||
【出力形式の例】
|
||||
ユーザーはやや内向的で、人が多い場面では話すのが苦手だが、親しい友人とはとても活発になる。
|
||||
または
|
||||
無
|
||||
|
||||
抽出を開始してください:
|
||||
@@ -1,19 +0,0 @@
|
||||
あなたはユーザー特性検索の専門家です。あなたのタスクは、現在の会話文脈に基づいて、どの個人特性カテゴリ情報を検索する必要があるかを判断することです。
|
||||
|
||||
【現在の会話文脈】
|
||||
{chat_context}
|
||||
|
||||
【個人特性カテゴリ一覧】
|
||||
{categories_summary}
|
||||
|
||||
【タスク要件】
|
||||
1. 現在の会話文脈を分析し、ユーザー理解の助けになる個人特性情報が何かを判断する
|
||||
2. 必要なカテゴリ番号だけを、スペース区切りで出力する
|
||||
3. 現在の会話で個人特性情報がまったく不要な場合は「無」と出力する
|
||||
|
||||
【出力形式の例】
|
||||
2 5 8
|
||||
または
|
||||
無
|
||||
|
||||
分析を開始してください:
|
||||
@@ -1,7 +1,5 @@
|
||||
あなたは QQ グループで会話しています。以下はグループ内で現在話されている内容で、チャット記録とチャット中の画像が含まれます。
|
||||
{bot_name}(あなた) と記された発言はあなた自身の発言です。区別に注意してください:
|
||||
|
||||
{time_block}
|
||||
あなた自身の発言として記された発言は、区別に注意してください:
|
||||
|
||||
{identity}
|
||||
今あなたはグループ内で会話しています。これまでのチャット記録を読み、現在の話題を把握したうえで、日常的で口語的な返信をしてください。
|
||||
|
||||
74
prompts/zh-CN/.meta.toml
Normal file
74
prompts/zh-CN/.meta.toml
Normal file
@@ -0,0 +1,74 @@
|
||||
[maisaka_chat]
|
||||
display_name = "规划器"
|
||||
advanced = false
|
||||
description = "Maisaka 主规划模板,负责整合身份、时间、工具说明和聊天上下文,驱动主循环进行思考、决策与工具调用。"
|
||||
|
||||
[maisaka_replyer]
|
||||
display_name = "回复"
|
||||
advanced = false
|
||||
description = "Maisaka 回复生成模板,负责根据人格、表达风格、群聊注意事项和待回复上下文生成最终回复。"
|
||||
|
||||
[default_expressor]
|
||||
display_name = "改写器"
|
||||
advanced = true
|
||||
description = "表达方式生成与整理相关模板,通常只在调试表达系统时需要修改。"
|
||||
|
||||
[emoji_content_analysis]
|
||||
display_name = "表情包内容分析"
|
||||
advanced = true
|
||||
description = "用于分析表情包图片内容的模板。"
|
||||
|
||||
[emoji_content_filtration]
|
||||
display_name = "表情包内容过滤"
|
||||
advanced = true
|
||||
description = "用于判断表情包内容是否符合过滤要求的模板。"
|
||||
|
||||
[emoji_replace]
|
||||
display_name = "表情包替换"
|
||||
advanced = true
|
||||
description = "用于根据文本语境选择或替换表情包的模板。"
|
||||
|
||||
[expression_evaluation]
|
||||
display_name = "表达评价"
|
||||
advanced = true
|
||||
description = "用于评价候选表达方式质量的模板。"
|
||||
|
||||
[expression_select]
|
||||
display_name = "表达选择"
|
||||
advanced = true
|
||||
description = "用于从表达库中选择合适表达方式的模板。"
|
||||
|
||||
[image_description]
|
||||
display_name = "图片描述"
|
||||
advanced = true
|
||||
description = "用于将图片内容转换为文本描述的模板。"
|
||||
|
||||
[jargon_compare_inference]
|
||||
display_name = "黑话对比推理"
|
||||
advanced = true
|
||||
description = "用于比较和推理群内黑话含义的模板。"
|
||||
|
||||
[jargon_explainer_summarize]
|
||||
display_name = "黑话解释总结"
|
||||
advanced = true
|
||||
description = "用于总结黑话解释结果的模板。"
|
||||
|
||||
[jargon_inference_content_only]
|
||||
display_name = "黑话推理"
|
||||
advanced = true
|
||||
description = "用于仅基于内容推理黑话含义的模板。"
|
||||
|
||||
[jargon_inference_with_context]
|
||||
display_name = "黑话上下文推理"
|
||||
advanced = true
|
||||
description = "用于结合上下文推理黑话含义的模板。"
|
||||
|
||||
[learn_style]
|
||||
display_name = "表达风格学习"
|
||||
advanced = true
|
||||
description = "用于从聊天内容中学习表达风格的模板。"
|
||||
|
||||
[maisaka_timing_gate]
|
||||
display_name = "时机"
|
||||
advanced = false
|
||||
description = "Maisaka 节奏控制模板,负责在每轮主循环前判断当前应该等待、停止,还是继续进入思考与回复流程。"
|
||||
@@ -1,5 +1,5 @@
|
||||
这是一个表情包,请对这个表情包进行审核,标准如下:
|
||||
1. 必须符合"{demand}"的要求
|
||||
1. 必须符合"符合公序良俗"的要求
|
||||
2. 不能是色情、暴力、等违法违规内容,必须符合公序良俗
|
||||
3. 不能是任何形式的截图,聊天记录或视频截图
|
||||
4. 不要出现5个以上文字
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
【历史话题标题列表】(仅标题,不含具体内容):
|
||||
{history_topics_block}
|
||||
【历史话题标题列表结束】
|
||||
|
||||
【本次聊天记录】(每条消息前有编号,用于后续引用):
|
||||
{messages_block}
|
||||
【本次聊天记录结束】
|
||||
|
||||
请完成以下任务:
|
||||
**识别话题**
|
||||
1. 识别【本次聊天记录】中正在进行的一个或多个话题;
|
||||
2. 【本次聊天记录】的中的消息可能与历史话题有关,也可能毫无关联。
|
||||
2. 判断【历史话题标题列表】中的话题是否在【本次聊天记录】中出现,如果出现,则直接使用该历史话题标题字符串;
|
||||
|
||||
**选取消息**
|
||||
1. 对于每个话题(新话题或历史话题),从上述带编号的消息中选出与该话题强相关的消息编号列表;
|
||||
2. 每个话题用一句话清晰地描述正在发生的事件,必须包含时间(大致即可)、人物、主要事件和主题,保证精准且有区分度;
|
||||
|
||||
请先输出一段简短思考,说明有什么话题,哪些是不包含在历史话题中的,哪些是包含在历史话题中的,并说明为什么;
|
||||
然后严格以 JSON 格式输出【本次聊天记录】中涉及的话题,格式如下:
|
||||
[
|
||||
{{
|
||||
"topic": "话题",
|
||||
"message_indices": [1, 2, 5]
|
||||
}},
|
||||
...
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
请基于以下话题,对聊天记录片段进行概括,提取以下信息:
|
||||
|
||||
**话题**:{topic}
|
||||
|
||||
**要求**:
|
||||
1. 关键词:提取与话题相关的关键词,用列表形式返回(3-10个关键词)
|
||||
2. 概括:对这段话的平文本概括(50-200字),要求:
|
||||
- 仔细地转述发生的事件和聊天内容;
|
||||
- 重点突出事件的发展过程和结果;
|
||||
- 围绕话题这个中心进行概括。
|
||||
- 提取话题中的关键信息点,关键信息点应该简洁明了。
|
||||
|
||||
请以JSON格式返回,格式如下:
|
||||
{{
|
||||
"keywords": ["关键词1", "关键词2", ...],
|
||||
"summary": "概括内容"
|
||||
}}
|
||||
|
||||
聊天记录:
|
||||
{original_text}
|
||||
|
||||
请直接返回JSON,不要包含其他内容。
|
||||
@@ -1,18 +0,0 @@
|
||||
你是一个用户特征分类分析专家。你的任务是分析对话内容,判断其中涉及哪些个人特征分类。
|
||||
|
||||
请仔细阅读以下对话内容,判断其中涉及了哪些个人特征分类。
|
||||
|
||||
【个人特征分类列表】
|
||||
{categories_summary}
|
||||
|
||||
【任务要求】
|
||||
1. 分析对话内容,判断涉及哪些个人特征分类
|
||||
2. 只输出涉及到的分类编号,用空格分隔
|
||||
3. 如果对话内容不涉及任何个人特征分类,输出"无"
|
||||
|
||||
【输出格式示例】
|
||||
1 3 5
|
||||
或
|
||||
无
|
||||
|
||||
请开始分析:
|
||||
@@ -1,17 +0,0 @@
|
||||
你是一个用户特征信息提取专家。你的任务是从对话内容中提取与指定分类相关的个人特征信息。
|
||||
|
||||
【目标分类】
|
||||
{category_name}
|
||||
|
||||
【任务要求】
|
||||
1. 仔细阅读对话内容,找出与"{category_name}"相关的所有信息
|
||||
2. 提取的信息应该具体、准确,避免模糊的描述
|
||||
3. 如果有多条相关信息,请整合成一段简洁的描述
|
||||
4. 如果对话中没有与该分类相关的信息,输出"无"
|
||||
|
||||
【输出格式示例】
|
||||
用户性格比较内向,不喜欢在人多的时候说话,但和熟悉的朋友会变得很活跃。
|
||||
或
|
||||
无
|
||||
|
||||
请开始提取:
|
||||
@@ -1,19 +0,0 @@
|
||||
你是一个用户特征检索专家。你的任务是根据当前对话上下文,判断需要检索哪些个人特征分类的信息。
|
||||
|
||||
【当前对话上下文】
|
||||
{chat_context}
|
||||
|
||||
【个人特征分类列表】
|
||||
{categories_summary}
|
||||
|
||||
【任务要求】
|
||||
1. 分析当前对话上下文,判断需要哪些个人特征信息来帮助理解用户
|
||||
2. 只输出需要的分类编号,用空格分隔
|
||||
3. 如果当前对话不需要任何个人特征信息,输出"无"
|
||||
|
||||
【输出格式示例】
|
||||
2 5 8
|
||||
或
|
||||
无
|
||||
|
||||
请开始分析:
|
||||
@@ -19,7 +19,7 @@ dependencies = [
|
||||
"jieba>=0.42.1",
|
||||
"json-repair>=0.47.6",
|
||||
"maim-message>=0.6.2",
|
||||
"maibot-dashboard>=1.0.4",
|
||||
"maibot-dashboard>=1.0.5",
|
||||
"maibot-plugin-sdk>=2.4.0",
|
||||
"matplotlib>=3.10.5",
|
||||
"mcp",
|
||||
|
||||
@@ -193,7 +193,6 @@ def _build_incoming_message(
|
||||
message.is_command = False
|
||||
message.is_notify = False
|
||||
message.processed_plain_text = text
|
||||
message.display_message = text
|
||||
message.initialized = True
|
||||
return message
|
||||
|
||||
|
||||
@@ -754,15 +754,6 @@ def test_default_bootstrapper_can_migrate_legacy_v1_database(tmp_path: Path) ->
|
||||
"""
|
||||
)
|
||||
).mappings().one()
|
||||
action_row = connection.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT session_id, action_name, action_display_prompt
|
||||
FROM action_records
|
||||
WHERE action_id = 'action-1'
|
||||
"""
|
||||
)
|
||||
).mappings().one()
|
||||
tool_row = connection.execute(
|
||||
text(
|
||||
"""
|
||||
@@ -796,6 +787,8 @@ def test_default_bootstrapper_can_migrate_legacy_v1_database(tmp_path: Path) ->
|
||||
assert snapshot.has_table("chat_sessions")
|
||||
assert snapshot.has_table("mai_messages")
|
||||
assert snapshot.has_table("tool_records")
|
||||
assert not snapshot.has_table("action_records")
|
||||
assert not snapshot.has_column("mai_messages", "display_message")
|
||||
|
||||
unpacked_raw_content = msgpack.unpackb(message_row["raw_content"], raw=False)
|
||||
additional_config = json.loads(message_row["additional_config"])
|
||||
@@ -807,9 +800,6 @@ def test_default_bootstrapper_can_migrate_legacy_v1_database(tmp_path: Path) ->
|
||||
assert message_row["processed_plain_text"] == "你好"
|
||||
assert unpacked_raw_content == [{"type": "text", "data": "你好呀"}]
|
||||
assert additional_config == {"priority_mode": "high", "source": "legacy"}
|
||||
assert action_row["session_id"] == "session-1"
|
||||
assert action_row["action_name"] == "search"
|
||||
assert action_row["action_display_prompt"] == "执行搜索"
|
||||
assert tool_row["session_id"] == "session-1"
|
||||
assert tool_row["tool_name"] == "search"
|
||||
assert tool_row["tool_display_prompt"] == "执行搜索"
|
||||
@@ -848,8 +838,8 @@ def test_legacy_v1_migration_reports_table_progress(tmp_path: Path) -> None:
|
||||
|
||||
migration_plan = manager.migrate(target_version=LATEST_SCHEMA_VERSION)
|
||||
|
||||
assert migration_plan.step_count() == 1
|
||||
assert len(reporter_instances) == 1
|
||||
assert migration_plan.step_count() == 3
|
||||
assert len(reporter_instances) == 3
|
||||
reporter_events = reporter_instances[0].events
|
||||
|
||||
assert reporter_events[0] == ("open", None, None, None)
|
||||
@@ -894,10 +884,6 @@ def test_initialize_database_calls_bootstrapper_before_create_all(
|
||||
del bind
|
||||
call_order.append("create_all")
|
||||
|
||||
def _fake_migrate_action_records() -> None:
|
||||
"""记录轻量补迁移调用。"""
|
||||
call_order.append("migrate_action_records")
|
||||
|
||||
def _fake_finalize_database(migration_state: DatabaseMigrationState) -> None:
|
||||
"""记录迁移收尾调用。
|
||||
|
||||
@@ -912,13 +898,11 @@ def test_initialize_database_calls_bootstrapper_before_create_all(
|
||||
monkeypatch.setattr(database_module._migration_bootstrapper, "prepare_database", _fake_prepare_database)
|
||||
monkeypatch.setattr(database_module._migration_bootstrapper, "finalize_database", _fake_finalize_database)
|
||||
monkeypatch.setattr(database_module.SQLModel.metadata, "create_all", _fake_create_all)
|
||||
monkeypatch.setattr(database_module, "_migrate_action_records_to_tool_records", _fake_migrate_action_records)
|
||||
|
||||
database_module.initialize_database()
|
||||
|
||||
assert call_order == [
|
||||
"prepare_database",
|
||||
"create_all",
|
||||
"migrate_action_records",
|
||||
"finalize_database",
|
||||
]
|
||||
|
||||
11
pytests/config_test/test_model_info_normalization.py
Normal file
11
pytests/config_test/test_model_info_normalization.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from src.config.model_configs import ModelInfo
|
||||
|
||||
|
||||
def test_model_identifier_strips_surrounding_whitespace() -> None:
|
||||
model_info = ModelInfo(
|
||||
api_provider="test-provider",
|
||||
model_identifier=" glm-5.1 ",
|
||||
name="test-model",
|
||||
)
|
||||
|
||||
assert model_info.model_identifier == "glm-5.1"
|
||||
@@ -178,7 +178,6 @@ def _install_stub_modules(monkeypatch):
|
||||
class _EmojiConfig:
|
||||
max_reg_num = 20
|
||||
content_filtration = False
|
||||
filtration_prompt = ""
|
||||
steal_emoji = False
|
||||
do_replace = False
|
||||
check_interval = 1
|
||||
@@ -1956,7 +1955,6 @@ async def test_build_emoji_description_content_filtration_reject(monkeypatch):
|
||||
logger = emoji_manager_new.logger
|
||||
|
||||
emoji_manager_new.global_config.emoji.content_filtration = True
|
||||
emoji_manager_new.global_config.emoji.filtration_prompt = "rule"
|
||||
|
||||
def _read_bytes(_path):
|
||||
return b""
|
||||
@@ -1994,13 +1992,15 @@ async def test_build_emoji_description_content_filtration_pass(monkeypatch):
|
||||
logger = emoji_manager_new.logger
|
||||
|
||||
emoji_manager_new.global_config.emoji.content_filtration = True
|
||||
emoji_manager_new.global_config.emoji.filtration_prompt = "rule"
|
||||
|
||||
def _read_bytes(_path):
|
||||
return b""
|
||||
|
||||
async def _vlm_response(prompt, *_args, **_kwargs):
|
||||
if "rule" in str(prompt):
|
||||
call_count = {"n": 0}
|
||||
|
||||
async def _vlm_response(*_args, **_kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 2:
|
||||
return "是", None
|
||||
return "desc", None
|
||||
|
||||
|
||||
@@ -87,7 +87,46 @@ def test_list_prompt_templates_prefers_locale_specific_files(tmp_path: Path) ->
|
||||
|
||||
prompt_templates = list_prompt_templates(prompts_root=prompts_root)
|
||||
|
||||
assert prompt_templates["replyer"].read_text(encoding="utf-8") == "English"
|
||||
assert prompt_templates["replyer"].path.read_text(encoding="utf-8") == "English"
|
||||
|
||||
|
||||
def test_list_prompt_templates_loads_directory_metadata(tmp_path: Path) -> None:
|
||||
prompts_root = tmp_path / "prompts"
|
||||
write_prompt(prompts_root, "zh-CN", "replyer", "中文")
|
||||
metadata_path = prompts_root / "zh-CN" / ".meta.toml"
|
||||
metadata_path.write_text(
|
||||
"""
|
||||
[replyer]
|
||||
display_name = "回复器"
|
||||
advanced = true
|
||||
description = "用于生成回复的主模板"
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
prompt_templates = list_prompt_templates(prompts_root=prompts_root)
|
||||
metadata = prompt_templates["replyer"].metadata
|
||||
|
||||
assert metadata.display_name == "回复器"
|
||||
assert metadata.advanced is True
|
||||
assert metadata.description == "用于生成回复的主模板"
|
||||
|
||||
|
||||
def test_list_prompt_templates_loads_prompt_specific_metadata(tmp_path: Path) -> None:
|
||||
prompts_root = tmp_path / "prompts"
|
||||
write_prompt(prompts_root, "zh-CN", "replyer", "中文")
|
||||
metadata_path = prompts_root / "zh-CN" / "replyer.meta.json"
|
||||
metadata_path.write_text(
|
||||
'{"display_name": "Replyer", "advanced": false, "description": "Prompt specific metadata"}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
prompt_templates = list_prompt_templates(prompts_root=prompts_root)
|
||||
metadata = prompt_templates["replyer"].metadata
|
||||
|
||||
assert metadata.display_name == "Replyer"
|
||||
assert metadata.advanced is False
|
||||
assert metadata.description == "Prompt specific metadata"
|
||||
|
||||
|
||||
def test_list_prompt_templates_reports_duplicate_name_with_custom_root(tmp_path: Path) -> None:
|
||||
|
||||
@@ -41,7 +41,6 @@ def test_build_message_returns_session_message_with_maisaka_metadata() -> None:
|
||||
assert message.message_id == "maisaka-msg-1"
|
||||
assert message.timestamp == timestamp
|
||||
assert message.processed_plain_text == "展示消息内容"
|
||||
assert message.display_message == "展示消息内容"
|
||||
assert message.raw_message is raw_message
|
||||
|
||||
assert get_message_role(message) == "assistant"
|
||||
|
||||
@@ -554,7 +554,6 @@ async def test_inbound_codec_resolves_at_to_group_cardname() -> None:
|
||||
)
|
||||
|
||||
assert message_dict["processed_plain_text"] == "@群昵称"
|
||||
assert message_dict["display_message"] == "@群昵称"
|
||||
assert message_dict["raw_message"] == [
|
||||
{
|
||||
"type": "at",
|
||||
@@ -599,7 +598,6 @@ async def test_inbound_codec_falls_back_to_qq_nickname_when_group_cardname_is_em
|
||||
)
|
||||
|
||||
assert message_dict["processed_plain_text"] == "@QQ昵称"
|
||||
assert message_dict["display_message"] == "@QQ昵称"
|
||||
assert message_dict["raw_message"] == [
|
||||
{
|
||||
"type": "at",
|
||||
@@ -640,7 +638,6 @@ async def test_inbound_codec_falls_back_to_stranger_nickname_when_group_profile_
|
||||
)
|
||||
|
||||
assert message_dict["processed_plain_text"] == "@QQ昵称"
|
||||
assert message_dict["display_message"] == "@QQ昵称"
|
||||
assert message_dict["raw_message"] == [
|
||||
{
|
||||
"type": "at",
|
||||
|
||||
@@ -1,16 +1,59 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from src.config.model_configs import APIProvider, ReasoningParseMode, ToolArgumentParseMode
|
||||
from src.llm_models.model_client.openai_client import (
|
||||
_OpenAIStreamAccumulator,
|
||||
_build_reasoning_key,
|
||||
_default_normal_response_parser,
|
||||
_parse_tool_arguments,
|
||||
_sanitize_messages_for_toolless_request,
|
||||
)
|
||||
from src.llm_models.payload_content.message import Message, RoleType, TextMessagePart
|
||||
from src.llm_models.payload_content.tool_option import ToolCall
|
||||
|
||||
|
||||
@pytest.mark.parametrize("parse_mode", list(ToolArgumentParseMode))
|
||||
def test_parse_tool_arguments_treats_blank_arguments_as_empty_dict(parse_mode: ToolArgumentParseMode) -> None:
|
||||
assert _parse_tool_arguments("", parse_mode, None) == {}
|
||||
assert _parse_tool_arguments(" ", parse_mode, None) == {}
|
||||
|
||||
|
||||
def test_normal_response_parser_accepts_empty_string_arguments_for_parameterless_tool() -> None:
|
||||
response = SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
finish_reason="tool_calls",
|
||||
message=SimpleNamespace(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
SimpleNamespace(
|
||||
id="finish-call",
|
||||
type="function",
|
||||
function=SimpleNamespace(name="finish", arguments=""),
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
usage=None,
|
||||
model="glm-5.1",
|
||||
)
|
||||
|
||||
api_response, usage_record = _default_normal_response_parser(
|
||||
response,
|
||||
reasoning_parse_mode=ReasoningParseMode.AUTO,
|
||||
tool_argument_parse_mode=ToolArgumentParseMode.AUTO,
|
||||
reasoning_key=None,
|
||||
)
|
||||
|
||||
assert len(api_response.tool_calls) == 1
|
||||
assert api_response.tool_calls[0].func_name == "finish"
|
||||
assert api_response.tool_calls[0].args == {}
|
||||
assert usage_record is None
|
||||
|
||||
|
||||
def test_sanitize_messages_for_toolless_request_drops_assistant_tool_call_without_parts() -> None:
|
||||
messages = [
|
||||
Message(
|
||||
|
||||
@@ -31,7 +31,6 @@ def test_plugin_message_utils_preserves_binary_components_and_reply_metadata() -
|
||||
)
|
||||
message.session_id = "qq:20001:10001"
|
||||
message.processed_plain_text = "binary payload"
|
||||
message.display_message = "binary payload"
|
||||
message.raw_message = MessageSequence(
|
||||
components=[
|
||||
TextComponent("hello"),
|
||||
|
||||
@@ -298,7 +298,7 @@ async def test_private_outbound_message_preserves_bot_sender_and_receiver_user(
|
||||
outbound_message = send_service._build_outbound_session_message(
|
||||
message_sequence=MessageSequence(components=[TextComponent(text="你好")]),
|
||||
stream_id="test-session",
|
||||
display_message="你好",
|
||||
processed_plain_text="你好",
|
||||
)
|
||||
|
||||
assert outbound_message is not None
|
||||
@@ -329,7 +329,7 @@ async def test_group_outbound_message_preserves_bot_sender_and_target_group(
|
||||
outbound_message = send_service._build_outbound_session_message(
|
||||
message_sequence=MessageSequence(components=[TextComponent(text="大家好")]),
|
||||
stream_id="group-session",
|
||||
display_message="大家好",
|
||||
processed_plain_text="大家好",
|
||||
)
|
||||
|
||||
assert outbound_message is not None
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"""Expression routes pytest tests"""
|
||||
|
||||
from typing import Generator
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, APIRouter
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
from src.common.database.database_model import Expression
|
||||
from src.common.database.database_model import Expression, ModifiedBy
|
||||
from src.webui.dependencies import require_auth
|
||||
|
||||
|
||||
def create_test_app() -> FastAPI:
|
||||
@@ -63,6 +63,7 @@ def client_fixture(test_session: Session, monkeypatch) -> Generator[TestClient,
|
||||
@contextmanager
|
||||
def get_test_db_session():
|
||||
yield test_session
|
||||
test_session.commit()
|
||||
|
||||
monkeypatch.setattr("src.webui.routers.expression.get_db_session", get_test_db_session)
|
||||
|
||||
@@ -71,10 +72,11 @@ def client_fixture(test_session: Session, monkeypatch) -> Generator[TestClient,
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_auth")
|
||||
def mock_auth_fixture(monkeypatch):
|
||||
def mock_auth_fixture():
|
||||
"""Mock authentication to always return True"""
|
||||
mock_verify = MagicMock(return_value=True)
|
||||
monkeypatch.setattr("src.webui.routers.expression.verify_auth_token_from_cookie_or_header", mock_verify)
|
||||
app.dependency_overrides[require_auth] = lambda: "test-token"
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture(name="sample_expression")
|
||||
@@ -82,8 +84,8 @@ def sample_expression_fixture(test_session: Session) -> Expression:
|
||||
"""Insert a sample expression into test database"""
|
||||
test_session.execute(
|
||||
text(
|
||||
"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) "
|
||||
"VALUES (1, '测试情景', '测试风格', '测试上下文', '测试上文', '[\"测试内容1\", \"测试内容2\"]', 10, '2026-02-17 12:00:00', '2026-02-15 10:00:00', 'test_chat_001')"
|
||||
"INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
|
||||
"VALUES (1, '测试情景', '测试风格', '[\"测试内容1\", \"测试内容2\"]', 10, '2026-02-17 12:00:00', '2026-02-15 10:00:00', 'test_chat_001', 0, 0)"
|
||||
)
|
||||
)
|
||||
test_session.commit()
|
||||
@@ -131,8 +133,8 @@ def test_list_expressions_pagination(client: TestClient, mock_auth, test_session
|
||||
for i in range(5):
|
||||
test_session.execute(
|
||||
text(
|
||||
f"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) "
|
||||
f"VALUES ({i + 1}, '情景{i}', '风格{i}', '', '', '[]', 0, '2026-02-17 12:0{i}:00', '2026-02-15 10:00:00', 'chat_{i}')"
|
||||
f"INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
|
||||
f"VALUES ({i + 1}, '情景{i}', '风格{i}', '[]', 0, '2026-02-17 12:0{i}:00', '2026-02-15 10:00:00', 'chat_{i}', 0, 0)"
|
||||
)
|
||||
)
|
||||
test_session.commit()
|
||||
@@ -158,14 +160,14 @@ def test_list_expressions_search(client: TestClient, mock_auth, test_session: Se
|
||||
"""Test GET /expression/list with search filter"""
|
||||
test_session.execute(
|
||||
text(
|
||||
"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) "
|
||||
"VALUES (1, '找人吃饭', '热情', '', '', '[]', 0, datetime('now'), datetime('now'), 'chat_001')"
|
||||
"INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
|
||||
"VALUES (1, '找人吃饭', '热情', '[]', 0, datetime('now'), datetime('now'), 'chat_001', 0, 0)"
|
||||
)
|
||||
)
|
||||
test_session.execute(
|
||||
text(
|
||||
"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) "
|
||||
"VALUES (2, '拒绝邀请', '礼貌', '', '', '[]', 0, datetime('now'), datetime('now'), 'chat_002')"
|
||||
"INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
|
||||
"VALUES (2, '拒绝邀请', '礼貌', '[]', 0, datetime('now'), datetime('now'), 'chat_002', 0, 0)"
|
||||
)
|
||||
)
|
||||
test_session.commit()
|
||||
@@ -183,14 +185,14 @@ def test_list_expressions_chat_filter(client: TestClient, mock_auth, test_sessio
|
||||
"""Test GET /expression/list with chat_id filter"""
|
||||
test_session.execute(
|
||||
text(
|
||||
"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) "
|
||||
"VALUES (1, '情景A', '风格A', '', '', '[]', 0, datetime('now'), datetime('now'), 'chat_A')"
|
||||
"INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
|
||||
"VALUES (1, '情景A', '风格A', '[]', 0, datetime('now'), datetime('now'), 'chat_A', 0, 0)"
|
||||
)
|
||||
)
|
||||
test_session.execute(
|
||||
text(
|
||||
"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) "
|
||||
"VALUES (2, '情景B', '风格B', '', '', '[]', 0, datetime('now'), datetime('now'), 'chat_B')"
|
||||
"INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
|
||||
"VALUES (2, '情景B', '风格B', '[]', 0, datetime('now'), datetime('now'), 'chat_B', 0, 0)"
|
||||
)
|
||||
)
|
||||
test_session.commit()
|
||||
@@ -378,8 +380,8 @@ def test_batch_delete_expressions_success(client: TestClient, mock_auth, test_se
|
||||
for i in range(3):
|
||||
test_session.execute(
|
||||
text(
|
||||
f"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) "
|
||||
f"VALUES ({i + 1}, '批量删除{i}', '风格{i}', '', '', '[]', 0, datetime('now'), datetime('now'), 'chat_{i}')"
|
||||
f"INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
|
||||
f"VALUES ({i + 1}, '批量删除{i}', '风格{i}', '[]', 0, datetime('now'), datetime('now'), 'chat_{i}', 0, 0)"
|
||||
)
|
||||
)
|
||||
expression_ids.append(i + 1)
|
||||
@@ -416,8 +418,8 @@ def test_get_expression_stats(client: TestClient, mock_auth, test_session: Sessi
|
||||
for i in range(3):
|
||||
test_session.execute(
|
||||
text(
|
||||
f"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) "
|
||||
f"VALUES ({i + 1}, '情景{i}', '风格{i}', '', '', '[]', 0, datetime('now'), datetime('now'), 'chat_{i % 2}')"
|
||||
f"INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
|
||||
f"VALUES ({i + 1}, '情景{i}', '风格{i}', '[]', 0, datetime('now'), datetime('now'), 'chat_{i % 2}', 0, 0)"
|
||||
)
|
||||
)
|
||||
test_session.commit()
|
||||
@@ -432,11 +434,11 @@ def test_get_expression_stats(client: TestClient, mock_auth, test_session: Sessi
|
||||
|
||||
|
||||
def test_get_review_stats(client: TestClient, mock_auth, test_session: Session):
|
||||
"""Test GET /expression/review/stats returns hardcoded 0 counts"""
|
||||
"""Test GET /expression/review/stats returns review status counts"""
|
||||
test_session.execute(
|
||||
text(
|
||||
"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) "
|
||||
"VALUES (1, '待审核', '风格', '', '', '[]', 0, datetime('now'), datetime('now'), 'chat_001')"
|
||||
"INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
|
||||
"VALUES (1, '待审核', '风格', '[]', 0, datetime('now'), datetime('now'), 'chat_001', 0, 0)"
|
||||
)
|
||||
)
|
||||
test_session.commit()
|
||||
@@ -445,9 +447,8 @@ def test_get_review_stats(client: TestClient, mock_auth, test_session: Session):
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
# Verify all review counts are 0 (hardcoded in refactored code)
|
||||
assert data["total"] == 1 # Total expressions exists
|
||||
assert data["unchecked"] == 0
|
||||
assert data["unchecked"] == 1
|
||||
assert data["passed"] == 0
|
||||
assert data["rejected"] == 0
|
||||
assert data["ai_checked"] == 0
|
||||
@@ -455,14 +456,14 @@ def test_get_review_stats(client: TestClient, mock_auth, test_session: Session):
|
||||
|
||||
|
||||
def test_get_review_list_filter_unchecked(client: TestClient, mock_auth, sample_expression: Expression):
|
||||
"""Test GET /expression/review/list with filter_type=unchecked returns empty (legacy behavior)"""
|
||||
# filter_type=unchecked should return no results (legacy removed)
|
||||
"""Test GET /expression/review/list with filter_type=unchecked returns unchecked expressions"""
|
||||
response = client.get("/api/webui/expression/review/list?filter_type=unchecked")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["total"] == 0 # No results (legacy fields removed)
|
||||
assert data["total"] == 1
|
||||
assert len(data["data"]) == 1
|
||||
|
||||
|
||||
def test_get_review_list_filter_all(client: TestClient, mock_auth, sample_expression: Expression):
|
||||
@@ -476,8 +477,8 @@ def test_get_review_list_filter_all(client: TestClient, mock_auth, sample_expres
|
||||
assert len(data["data"]) == 1
|
||||
|
||||
|
||||
def test_batch_review_expressions_unsupported(client: TestClient, mock_auth, sample_expression: Expression):
|
||||
"""Test POST /expression/review/batch returns failure for require_unchecked=True"""
|
||||
def test_batch_review_expressions_with_unchecked_marker(client: TestClient, mock_auth, sample_expression: Expression):
|
||||
"""Test POST /expression/review/batch succeeds with require_unchecked=True"""
|
||||
review_payload = {"items": [{"id": sample_expression.id, "rejected": False, "require_unchecked": True}]}
|
||||
|
||||
response = client.post("/api/webui/expression/review/batch", json=review_payload)
|
||||
@@ -485,8 +486,34 @@ def test_batch_review_expressions_unsupported(client: TestClient, mock_auth, sam
|
||||
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["failed"] == 1 # Should fail because require_unchecked=True
|
||||
assert "不支持审核状态过滤" in data["results"][0]["message"]
|
||||
assert data["succeeded"] == 1
|
||||
assert data["results"][0]["success"] is True
|
||||
|
||||
|
||||
def test_batch_review_expressions_overwrites_ai_checked(
|
||||
client: TestClient, mock_auth, test_session: Session, sample_expression: Expression
|
||||
):
|
||||
"""Test POST /expression/review/batch lets manual review override AI checked state"""
|
||||
sample_expression.checked = True
|
||||
sample_expression.rejected = True
|
||||
sample_expression.modified_by = ModifiedBy.AI
|
||||
test_session.add(sample_expression)
|
||||
test_session.commit()
|
||||
|
||||
review_payload = {"items": [{"id": sample_expression.id, "rejected": False, "require_unchecked": True}]}
|
||||
|
||||
response = client.post("/api/webui/expression/review/batch", json=review_payload)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["succeeded"] == 1
|
||||
test_session.expire_all()
|
||||
reviewed_expression = test_session.exec(select(Expression).where(Expression.id == sample_expression.id)).first()
|
||||
assert reviewed_expression is not None
|
||||
assert reviewed_expression.checked is True
|
||||
assert reviewed_expression.rejected is False
|
||||
assert reviewed_expression.modified_by == ModifiedBy.USER
|
||||
|
||||
|
||||
def test_batch_review_expressions_no_unchecked_check(client: TestClient, mock_auth, sample_expression: Expression):
|
||||
|
||||
@@ -33,4 +33,4 @@ tomlkit>=0.13.3
|
||||
typing-extensions
|
||||
uvicorn>=0.35.0
|
||||
watchfiles>=1.1.1
|
||||
maibot-dashboard>=1.0.4
|
||||
maibot-dashboard>=1.0.5
|
||||
@@ -114,14 +114,10 @@ def _merge_bucket_to_message(bucket: List[DatabaseMessages]) -> DatabaseMessages
|
||||
time=latest.time,
|
||||
chat_id=latest.chat_id,
|
||||
reply_to=latest.reply_to,
|
||||
interest_value=latest.interest_value,
|
||||
key_words=latest.key_words,
|
||||
key_words_lite=latest.key_words_lite,
|
||||
is_mentioned=latest.is_mentioned,
|
||||
is_at=latest.is_at,
|
||||
reply_probability_boost=latest.reply_probability_boost,
|
||||
processed_plain_text="\n".join(merged_texts) if merged_texts else latest.processed_plain_text,
|
||||
display_message=latest.display_message,
|
||||
priority_mode=latest.priority_mode,
|
||||
priority_info=latest.priority_info,
|
||||
additional_config=latest.additional_config,
|
||||
|
||||
@@ -78,7 +78,7 @@ class ChatManager:
|
||||
"""初始化聊天管理器"""
|
||||
try:
|
||||
await self.load_all_sessions_from_db()
|
||||
logger.info(f"已加载 {len(self.sessions)} 个会话记录到内存中")
|
||||
logger.debug(f"已加载 {len(self.sessions)} 个会话记录到内存中")
|
||||
except Exception as e:
|
||||
logger.error(f"初始化聊天管理器出现错误: {e}")
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Literal, Optional, Tuple
|
||||
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
@@ -103,6 +104,24 @@ class BaseMaisakaReplyGenerator:
|
||||
logger.warning(f"构建 Maisaka 人设提示词失败: {exc}")
|
||||
return "你的名字是麦麦。\n是人类。"
|
||||
|
||||
@staticmethod
|
||||
def _select_reply_style() -> str:
|
||||
"""按配置概率选择本次 replyer 使用的表达风格。"""
|
||||
personality_config = global_config.personality
|
||||
reply_style = personality_config.reply_style
|
||||
candidate_styles = [style.strip() for style in personality_config.multiple_reply_style if style.strip()]
|
||||
|
||||
if not candidate_styles:
|
||||
return reply_style
|
||||
|
||||
probability = personality_config.multiple_probability
|
||||
if probability <= 0:
|
||||
return reply_style
|
||||
if random.random() > probability:
|
||||
return reply_style
|
||||
|
||||
return random.choice(candidate_styles)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_content(content: str, limit: int = 500) -> str:
|
||||
normalized = " ".join((content or "").split())
|
||||
@@ -293,7 +312,7 @@ class BaseMaisakaReplyGenerator:
|
||||
group_chat_attention_block=self._build_group_chat_attention_block(session_id),
|
||||
replyer_at_block=self._build_replyer_at_block(),
|
||||
identity=self._personality_prompt,
|
||||
reply_style=global_config.personality.reply_style,
|
||||
reply_style=self._select_reply_style(),
|
||||
)
|
||||
except Exception:
|
||||
system_prompt = "你是一个友好的 AI 助手,请根据聊天记录自然回复。"
|
||||
|
||||
@@ -8,8 +8,6 @@ import random
|
||||
import re
|
||||
import time
|
||||
|
||||
import jieba
|
||||
|
||||
from src.chat.message_receive.chat_manager import chat_manager as _chat_manager
|
||||
from src.chat.message_receive.message import SessionMessage
|
||||
from src.common.logger import get_logger
|
||||
@@ -912,110 +910,3 @@ def parse_keywords_string(keywords_input) -> list[str]:
|
||||
return [keywords_str] if keywords_str else []
|
||||
|
||||
|
||||
def cut_key_words(concept_name: str) -> list[str]:
|
||||
"""对概念名称进行jieba分词,并过滤掉关键词列表中的关键词"""
|
||||
concept_name_tokens = list(jieba.cut(concept_name))
|
||||
|
||||
# 定义常见连词、停用词与标点
|
||||
conjunctions = {"和", "与", "及", "跟", "以及", "并且", "而且", "或", "或者", "并"}
|
||||
stop_words = {
|
||||
"的",
|
||||
"了",
|
||||
"呢",
|
||||
"吗",
|
||||
"吧",
|
||||
"啊",
|
||||
"哦",
|
||||
"恩",
|
||||
"嗯",
|
||||
"呀",
|
||||
"嘛",
|
||||
"哇",
|
||||
"在",
|
||||
"是",
|
||||
"很",
|
||||
"也",
|
||||
"又",
|
||||
"就",
|
||||
"都",
|
||||
"还",
|
||||
"更",
|
||||
"最",
|
||||
"被",
|
||||
"把",
|
||||
"给",
|
||||
"对",
|
||||
"和",
|
||||
"与",
|
||||
"及",
|
||||
"跟",
|
||||
"并",
|
||||
"而且",
|
||||
"或者",
|
||||
"或",
|
||||
"以及",
|
||||
}
|
||||
chinese_punctuations = set(",。!?、;:()【】《》“”‘’—…·-——,.!?;:()[]<>'\"/\\")
|
||||
|
||||
# 清理空白并初步过滤纯标点
|
||||
cleaned_tokens = []
|
||||
for tok in concept_name_tokens:
|
||||
t = tok.strip()
|
||||
if not t:
|
||||
continue
|
||||
# 去除纯标点
|
||||
if all(ch in chinese_punctuations for ch in t):
|
||||
continue
|
||||
cleaned_tokens.append(t)
|
||||
|
||||
# 合并连词两侧的词(仅当两侧都存在且不是标点/停用词时)
|
||||
merged_tokens = []
|
||||
i = 0
|
||||
n = len(cleaned_tokens)
|
||||
while i < n:
|
||||
tok = cleaned_tokens[i]
|
||||
if tok in conjunctions and merged_tokens and i + 1 < n:
|
||||
left = merged_tokens[-1]
|
||||
right = cleaned_tokens[i + 1]
|
||||
# 左右都需要是有效词
|
||||
if (
|
||||
left
|
||||
and right
|
||||
and left not in conjunctions
|
||||
and right not in conjunctions
|
||||
and left not in stop_words
|
||||
and right not in stop_words
|
||||
and not all(ch in chinese_punctuations for ch in left)
|
||||
and not all(ch in chinese_punctuations for ch in right)
|
||||
):
|
||||
# 合并为一个新词,并替换掉左侧与跳过右侧
|
||||
combined = f"{left}{tok}{right}"
|
||||
merged_tokens[-1] = combined
|
||||
i += 2
|
||||
continue
|
||||
# 常规推进
|
||||
merged_tokens.append(tok)
|
||||
i += 1
|
||||
|
||||
# 二次过滤:去除停用词、单字符纯标点与无意义项
|
||||
result_tokens = []
|
||||
seen = set()
|
||||
# ban_words = set(getattr(global_config.memory, "memory_ban_words", []) or [])
|
||||
for tok in merged_tokens:
|
||||
if tok in conjunctions:
|
||||
# 独立连词丢弃
|
||||
continue
|
||||
if tok in stop_words:
|
||||
continue
|
||||
# if tok in ban_words:
|
||||
# continue
|
||||
if all(ch in chinese_punctuations for ch in tok):
|
||||
continue
|
||||
if tok.strip() == "":
|
||||
continue
|
||||
if tok not in seen:
|
||||
seen.add(tok)
|
||||
result_tokens.append(tok)
|
||||
|
||||
filtered_concept_name_tokens = result_tokens
|
||||
return filtered_concept_name_tokens
|
||||
|
||||
@@ -79,7 +79,6 @@ class BufferCLI:
|
||||
)
|
||||
message.raw_message = MessageSequence([TextComponent(text=user_text)])
|
||||
message.processed_plain_text = user_text
|
||||
message.display_message = user_text
|
||||
message.initialized = True
|
||||
return message
|
||||
|
||||
|
||||
@@ -59,7 +59,6 @@ class MaiMessage(BaseDatabaseDataModel[Messages]):
|
||||
self.reply_to: Optional[str] = None
|
||||
|
||||
self.processed_plain_text: Optional[str] = None
|
||||
self.display_message: Optional[str] = None
|
||||
self.raw_message: MessageSequence
|
||||
|
||||
@classmethod
|
||||
@@ -86,7 +85,6 @@ class MaiMessage(BaseDatabaseDataModel[Messages]):
|
||||
obj.reply_to = db_record.reply_to
|
||||
obj.session_id = db_record.session_id
|
||||
obj.processed_plain_text = db_record.processed_plain_text
|
||||
obj.display_message = db_record.display_message
|
||||
obj.raw_message = MessageUtils.from_db_record_msg_to_MaiSeq(db_record.raw_content)
|
||||
return obj
|
||||
|
||||
@@ -113,7 +111,6 @@ class MaiMessage(BaseDatabaseDataModel[Messages]):
|
||||
is_notify=self.is_notify,
|
||||
raw_content=MessageUtils.from_MaiSeq_to_db_record_msg(self.raw_message),
|
||||
processed_plain_text=self.processed_plain_text,
|
||||
display_message=self.display_message,
|
||||
additional_config=additional_config,
|
||||
)
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ class Messages(SQLModel, table=True):
|
||||
# 消息内容
|
||||
raw_content: bytes = Field(sa_column=Column(LargeBinary)) # msgpack后的原始消息内容
|
||||
processed_plain_text: Optional[str] = Field(default=None) # 平面化处理后的纯文本消息
|
||||
display_message: Optional[str] = Field(default=None) # 显示的消息内容(被放入Prompt)
|
||||
|
||||
# 其他配置
|
||||
additional_config: Optional[str] = Field(default=None) # 额外配置,JSON格式存储
|
||||
|
||||
@@ -8,12 +8,14 @@ from .registry import MigrationRegistry
|
||||
from .resolver import BaseSchemaVersionDetector, SchemaVersionResolver
|
||||
from .schema import SQLiteSchemaInspector
|
||||
from .v2_to_v3 import migrate_v2_to_v3
|
||||
from .v3_to_v4 import migrate_v3_to_v4
|
||||
from .version_store import SQLiteUserVersionStore
|
||||
|
||||
EMPTY_SCHEMA_VERSION = 0
|
||||
LEGACY_V1_SCHEMA_VERSION = 1
|
||||
V2_SCHEMA_VERSION = 2
|
||||
LATEST_SCHEMA_VERSION = 3
|
||||
V3_SCHEMA_VERSION = 3
|
||||
LATEST_SCHEMA_VERSION = 4
|
||||
|
||||
_LEGACY_V1_EXCLUSIVE_TABLES = (
|
||||
"chat_streams",
|
||||
@@ -78,9 +80,46 @@ class LatestSchemaVersionDetector(BaseSchemaVersionDetector):
|
||||
return None
|
||||
if not snapshot.has_column("person_info", "user_nickname"):
|
||||
return None
|
||||
if snapshot.has_column("mai_messages", "display_message"):
|
||||
return None
|
||||
return LATEST_SCHEMA_VERSION
|
||||
|
||||
|
||||
class V3SchemaVersionDetector(BaseSchemaVersionDetector):
|
||||
"""v3 schema 结构探测器。"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "v3_schema_detector"
|
||||
|
||||
def detect_version(self, snapshot: DatabaseSchemaSnapshot) -> Optional[int]:
|
||||
"""检测数据库是否为 v3 结构。"""
|
||||
|
||||
if any(snapshot.has_table(table_name) for table_name in _LEGACY_V1_EXCLUSIVE_TABLES):
|
||||
return None
|
||||
if not all(snapshot.has_table(table_name) for table_name in _COMMON_MARKER_TABLES):
|
||||
return None
|
||||
if snapshot.has_table("action_records"):
|
||||
return None
|
||||
if snapshot.has_table("thinking_questions"):
|
||||
return None
|
||||
if snapshot.has_column("images", "emotion"):
|
||||
return None
|
||||
if not snapshot.has_column("images", "image_hash"):
|
||||
return None
|
||||
if not snapshot.has_column("images", "full_path"):
|
||||
return None
|
||||
if not snapshot.has_column("images", "image_type"):
|
||||
return None
|
||||
if not snapshot.has_column("chat_history", "session_id"):
|
||||
return None
|
||||
if not snapshot.has_column("person_info", "user_nickname"):
|
||||
return None
|
||||
if not snapshot.has_column("mai_messages", "display_message"):
|
||||
return None
|
||||
return V3_SCHEMA_VERSION
|
||||
|
||||
|
||||
class V2SchemaVersionDetector(BaseSchemaVersionDetector):
|
||||
"""v2 schema 结构探测器。"""
|
||||
|
||||
@@ -174,6 +213,7 @@ def build_default_schema_version_detectors() -> List[BaseSchemaVersionDetector]:
|
||||
|
||||
return [
|
||||
LatestSchemaVersionDetector(),
|
||||
V3SchemaVersionDetector(),
|
||||
V2SchemaVersionDetector(),
|
||||
LegacyV1SchemaDetector(),
|
||||
]
|
||||
@@ -211,10 +251,17 @@ def build_default_migration_registry() -> MigrationRegistry:
|
||||
),
|
||||
MigrationStep(
|
||||
version_from=V2_SCHEMA_VERSION,
|
||||
version_to=LATEST_SCHEMA_VERSION,
|
||||
version_to=V3_SCHEMA_VERSION,
|
||||
name="v2_to_v3",
|
||||
description="移除废弃表,并将 emoji 标签统一收敛到 description 字段。",
|
||||
handler=migrate_v2_to_v3,
|
||||
),
|
||||
MigrationStep(
|
||||
version_from=V3_SCHEMA_VERSION,
|
||||
version_to=LATEST_SCHEMA_VERSION,
|
||||
name="v3_to_v4",
|
||||
description="移除 mai_messages.display_message 弃用列。",
|
||||
handler=migrate_v3_to_v4,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -489,9 +489,6 @@ def _build_legacy_message_additional_config(row: Mapping[str, Any]) -> Optional[
|
||||
|
||||
legacy_fields = {
|
||||
"intercept_message_level": row.get("intercept_message_level"),
|
||||
"interest_value": row.get("interest_value"),
|
||||
"key_words": row.get("key_words"),
|
||||
"key_words_lite": row.get("key_words_lite"),
|
||||
"priority_info": row.get("priority_info"),
|
||||
"priority_mode": row.get("priority_mode"),
|
||||
"selected_expressions": row.get("selected_expressions"),
|
||||
|
||||
155
src/common/database/migrations/v3_to_v4.py
Normal file
155
src/common/database/migrations/v3_to_v4.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""v3 schema 升级到 v4 的迁移逻辑。"""
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
from src.common.logger import get_logger
|
||||
|
||||
from .exceptions import DatabaseMigrationExecutionError
|
||||
from .models import MigrationExecutionContext
|
||||
from .schema import SQLiteSchemaInspector
|
||||
|
||||
logger = get_logger("database_migration")
|
||||
|
||||
_V3_MESSAGES_BACKUP_TABLE = "__v3_mai_messages_backup"
|
||||
_V4_MESSAGES_CREATE_SQL = """
|
||||
CREATE TABLE mai_messages (
|
||||
id INTEGER NOT NULL,
|
||||
message_id VARCHAR(255) NOT NULL,
|
||||
timestamp DATETIME,
|
||||
platform VARCHAR(100) NOT NULL,
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
user_nickname VARCHAR(255) NOT NULL,
|
||||
user_cardname VARCHAR(255),
|
||||
group_id VARCHAR(255),
|
||||
group_name VARCHAR(255),
|
||||
is_mentioned BOOLEAN NOT NULL,
|
||||
is_at BOOLEAN NOT NULL,
|
||||
session_id VARCHAR(255) NOT NULL,
|
||||
reply_to VARCHAR(255),
|
||||
is_emoji BOOLEAN NOT NULL,
|
||||
is_picture BOOLEAN NOT NULL,
|
||||
is_command BOOLEAN NOT NULL,
|
||||
is_notify BOOLEAN NOT NULL,
|
||||
raw_content BLOB,
|
||||
processed_plain_text VARCHAR,
|
||||
additional_config VARCHAR,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
"""
|
||||
_V4_MESSAGES_INDEX_STATEMENTS = (
|
||||
"CREATE INDEX ix_mai_messages_group_id ON mai_messages (group_id)",
|
||||
"CREATE INDEX ix_mai_messages_message_id ON mai_messages (message_id)",
|
||||
"CREATE INDEX ix_mai_messages_platform ON mai_messages (platform)",
|
||||
"CREATE INDEX ix_mai_messages_session_id ON mai_messages (session_id)",
|
||||
"CREATE INDEX ix_mai_messages_user_id ON mai_messages (user_id)",
|
||||
"CREATE INDEX ix_mai_messages_user_nickname ON mai_messages (user_nickname)",
|
||||
)
|
||||
|
||||
|
||||
def migrate_v3_to_v4(context: MigrationExecutionContext) -> None:
|
||||
"""执行 v3 到 v4 的 schema 迁移。"""
|
||||
|
||||
connection = context.connection
|
||||
total_records = _count_table_rows(connection, "mai_messages")
|
||||
context.start_progress(
|
||||
total_tables=1,
|
||||
total_records=total_records,
|
||||
description="v3 -> v4 迁移进度",
|
||||
table_unit_name="表",
|
||||
record_unit_name="记录",
|
||||
)
|
||||
|
||||
migrated_message_rows = _migrate_messages_table_to_v4(connection)
|
||||
context.advance_progress(
|
||||
records=migrated_message_rows,
|
||||
completed_tables=1,
|
||||
item_name="mai_messages",
|
||||
)
|
||||
|
||||
logger.info(f"v3 -> v4 数据库迁移完成: mai_messages重建={migrated_message_rows}")
|
||||
|
||||
|
||||
def _count_table_rows(connection: Connection, table_name: str) -> int:
|
||||
"""统计表记录数,不存在时返回 0。"""
|
||||
|
||||
schema_inspector = SQLiteSchemaInspector()
|
||||
if not schema_inspector.table_exists(connection, table_name):
|
||||
return 0
|
||||
row = connection.execute(text(f'SELECT COUNT(*) FROM "{table_name}"')).first()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
def _migrate_messages_table_to_v4(connection: Connection) -> int:
|
||||
"""重建 ``mai_messages`` 表并移除弃用的 ``display_message`` 列。"""
|
||||
|
||||
schema_inspector = SQLiteSchemaInspector()
|
||||
if not schema_inspector.table_exists(connection, "mai_messages"):
|
||||
return 0
|
||||
if not schema_inspector.get_table_schema(connection, "mai_messages").has_column("display_message"):
|
||||
return _count_table_rows(connection, "mai_messages")
|
||||
if schema_inspector.table_exists(connection, _V3_MESSAGES_BACKUP_TABLE):
|
||||
raise DatabaseMigrationExecutionError(
|
||||
f"检测到残留备份表 {_V3_MESSAGES_BACKUP_TABLE},无法安全执行 v3 -> v4 mai_messages 迁移。"
|
||||
)
|
||||
|
||||
connection.exec_driver_sql(f'ALTER TABLE "mai_messages" RENAME TO "{_V3_MESSAGES_BACKUP_TABLE}"')
|
||||
connection.exec_driver_sql(_V4_MESSAGES_CREATE_SQL)
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
f"""
|
||||
INSERT INTO mai_messages (
|
||||
id,
|
||||
message_id,
|
||||
timestamp,
|
||||
platform,
|
||||
user_id,
|
||||
user_nickname,
|
||||
user_cardname,
|
||||
group_id,
|
||||
group_name,
|
||||
is_mentioned,
|
||||
is_at,
|
||||
session_id,
|
||||
reply_to,
|
||||
is_emoji,
|
||||
is_picture,
|
||||
is_command,
|
||||
is_notify,
|
||||
raw_content,
|
||||
processed_plain_text,
|
||||
additional_config
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
message_id,
|
||||
timestamp,
|
||||
platform,
|
||||
user_id,
|
||||
user_nickname,
|
||||
user_cardname,
|
||||
group_id,
|
||||
group_name,
|
||||
is_mentioned,
|
||||
is_at,
|
||||
session_id,
|
||||
reply_to,
|
||||
is_emoji,
|
||||
is_picture,
|
||||
is_command,
|
||||
is_notify,
|
||||
raw_content,
|
||||
COALESCE(NULLIF(processed_plain_text, ''), display_message),
|
||||
additional_config
|
||||
FROM "{_V3_MESSAGES_BACKUP_TABLE}"
|
||||
ORDER BY id
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
migrated_rows = _count_table_rows(connection, "mai_messages")
|
||||
connection.exec_driver_sql(f'DROP TABLE "{_V3_MESSAGES_BACKUP_TABLE}"')
|
||||
for statement in _V4_MESSAGES_INDEX_STATEMENTS:
|
||||
connection.exec_driver_sql(statement)
|
||||
return migrated_rows
|
||||
@@ -829,20 +829,19 @@ def initialize_logging(verbose: bool = True):
|
||||
reconfigure_existing_loggers()
|
||||
|
||||
# 启动日志清理任务
|
||||
start_log_cleanup_task(verbose=verbose)
|
||||
start_log_cleanup_task()
|
||||
|
||||
# 只在 verbose=True 时输出详细的初始化信息
|
||||
if verbose:
|
||||
logger = get_logger("logger")
|
||||
console_level = LOG_CONFIG.get("console_log_level", LOG_CONFIG.get("log_level", "INFO"))
|
||||
file_level = LOG_CONFIG.get("file_log_level", LOG_CONFIG.get("log_level", "INFO"))
|
||||
|
||||
logger.info("日志系统已初始化:")
|
||||
logger.info(f" - 控制台级别: {console_level}")
|
||||
logger.info(f" - 文件级别: {file_level}")
|
||||
max_log_files = max(1, int(LOG_CONFIG.get("max_log_files", 30) or 30))
|
||||
log_cleanup_days = max(1, int(LOG_CONFIG.get("log_cleanup_days", 30) or 30))
|
||||
logger.info(f" - 轮转份数: {max_log_files}个文件|自动清理: {log_cleanup_days}天前的日志")
|
||||
logger.info(
|
||||
f"日志系统已初始化:控制台={console_level},文件={file_level},"
|
||||
f"轮转={max_log_files}个文件,清理={log_cleanup_days}天前"
|
||||
)
|
||||
|
||||
|
||||
def cleanup_old_logs():
|
||||
@@ -875,12 +874,8 @@ def cleanup_old_logs():
|
||||
logger.error(f"清理旧日志文件时出错: {e}")
|
||||
|
||||
|
||||
def start_log_cleanup_task(verbose: bool = True):
|
||||
"""启动日志清理任务
|
||||
|
||||
Args:
|
||||
verbose: 是否输出启动信息。默认为 True。
|
||||
"""
|
||||
def start_log_cleanup_task():
|
||||
"""启动日志清理任务"""
|
||||
global _cleanup_task_started
|
||||
|
||||
# 防止重复启动清理任务
|
||||
@@ -897,12 +892,6 @@ def start_log_cleanup_task(verbose: bool = True):
|
||||
cleanup_thread = threading.Thread(target=cleanup_task, daemon=True)
|
||||
cleanup_thread.start()
|
||||
|
||||
if verbose:
|
||||
logger = get_logger("logger")
|
||||
max_log_files = max(1, int(LOG_CONFIG.get("max_log_files", 30) or 30))
|
||||
log_cleanup_days = max(1, int(LOG_CONFIG.get("log_cleanup_days", 30) or 30))
|
||||
logger.info(f"已启动日志清理任务,将自动清理{log_cleanup_days}天前的日志文件(轮转份数限制: {max_log_files}个文件)")
|
||||
|
||||
|
||||
def shutdown_logging():
|
||||
"""优雅关闭日志系统,释放所有文件句柄"""
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from tomlkit import parse as parse_toml
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -22,6 +26,19 @@ STRICT_ENV_VALUES = {"1", "true", "yes", "on"}
|
||||
extract_prompt_placeholders = extract_placeholders
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromptMetadata:
|
||||
display_name: str = ""
|
||||
advanced: bool = False
|
||||
description: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromptTemplateInfo:
|
||||
path: Path
|
||||
metadata: PromptMetadata
|
||||
|
||||
|
||||
def get_prompts_root(prompts_root: Path | None = None) -> Path:
|
||||
return (prompts_root or PROMPTS_ROOT).resolve()
|
||||
|
||||
@@ -80,24 +97,86 @@ def iter_prompt_files(directory: Path, recursive: bool = True) -> list[Path]:
|
||||
|
||||
|
||||
def _raise_duplicate_prompt_name(name: str, first_path: Path, second_path: Path, prompts_root: Path) -> None:
|
||||
path_a = first_path.relative_to(prompts_root).as_posix()
|
||||
path_b = second_path.relative_to(prompts_root).as_posix()
|
||||
raise ValueError(
|
||||
t(
|
||||
"prompt.duplicate_template_name",
|
||||
name=name,
|
||||
path_a=first_path.relative_to(prompts_root),
|
||||
path_b=second_path.relative_to(prompts_root),
|
||||
path_a=path_a,
|
||||
path_b=path_b,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _scan_prompt_directory(directory: Path, prompts_root: Path) -> dict[str, Path]:
|
||||
prompt_paths: dict[str, Path] = {}
|
||||
def _coerce_metadata(raw_metadata: Any) -> PromptMetadata:
|
||||
if not isinstance(raw_metadata, dict):
|
||||
return PromptMetadata()
|
||||
|
||||
display_name = raw_metadata.get("display_name", "")
|
||||
advanced = raw_metadata.get("advanced", False)
|
||||
description = raw_metadata.get("description", "")
|
||||
|
||||
return PromptMetadata(
|
||||
display_name=display_name if isinstance(display_name, str) else "",
|
||||
advanced=advanced if isinstance(advanced, bool) else False,
|
||||
description=description if isinstance(description, str) else "",
|
||||
)
|
||||
|
||||
|
||||
def _read_metadata_file(metadata_path: Path) -> dict[str, Any]:
|
||||
if not metadata_path.is_file():
|
||||
return {}
|
||||
|
||||
try:
|
||||
if metadata_path.suffix == ".json":
|
||||
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
metadata = parse_toml(metadata_path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
logger.warning("读取 Prompt 元信息文件 %s 失败:%s", metadata_path, exc)
|
||||
return {}
|
||||
|
||||
return dict(metadata) if isinstance(metadata, dict) else {}
|
||||
|
||||
|
||||
def _extract_template_metadata(metadata: dict[str, Any], prompt_name: str) -> dict[str, Any]:
|
||||
templates = metadata.get("templates")
|
||||
if isinstance(templates, dict) and isinstance(templates.get(prompt_name), dict):
|
||||
return dict(templates[prompt_name])
|
||||
|
||||
prompt_metadata = metadata.get(prompt_name)
|
||||
if isinstance(prompt_metadata, dict):
|
||||
return dict(prompt_metadata)
|
||||
|
||||
return metadata if any(key in metadata for key in ("display_name", "advanced", "description")) else {}
|
||||
|
||||
|
||||
def _load_prompt_metadata(prompt_path: Path) -> PromptMetadata:
|
||||
prompt_name = prompt_path.stem
|
||||
metadata_sources = (
|
||||
prompt_path.with_name(f"{prompt_name}.meta.toml"),
|
||||
prompt_path.with_name(f"{prompt_name}.meta.json"),
|
||||
prompt_path.parent / ".meta.toml",
|
||||
prompt_path.parent / ".meta.json",
|
||||
)
|
||||
|
||||
merged_metadata: dict[str, Any] = {}
|
||||
for metadata_path in reversed(metadata_sources):
|
||||
raw_metadata = _read_metadata_file(metadata_path)
|
||||
merged_metadata.update(_extract_template_metadata(raw_metadata, prompt_name))
|
||||
|
||||
return _coerce_metadata(merged_metadata)
|
||||
|
||||
|
||||
def _scan_prompt_directory(directory: Path, prompts_root: Path) -> dict[str, PromptTemplateInfo]:
|
||||
prompt_paths: dict[str, PromptTemplateInfo] = {}
|
||||
for prompt_path in iter_prompt_files(directory):
|
||||
prompt_name = prompt_path.stem
|
||||
existing_path = prompt_paths.get(prompt_name)
|
||||
if existing_path is not None:
|
||||
_raise_duplicate_prompt_name(prompt_name, existing_path, prompt_path, prompts_root)
|
||||
prompt_paths[prompt_name] = prompt_path
|
||||
existing_info = prompt_paths.get(prompt_name)
|
||||
if existing_info is not None:
|
||||
_raise_duplicate_prompt_name(prompt_name, existing_info.path, prompt_path, prompts_root)
|
||||
prompt_paths[prompt_name] = PromptTemplateInfo(path=prompt_path, metadata=_load_prompt_metadata(prompt_path))
|
||||
return prompt_paths
|
||||
|
||||
|
||||
@@ -115,11 +194,11 @@ def _iter_locale_candidates(requested_locale: str) -> list[str]:
|
||||
return locale_candidates
|
||||
|
||||
|
||||
def list_prompt_templates(locale: str | None = None, prompts_root: Path | None = None) -> dict[str, Path]:
|
||||
def list_prompt_templates(locale: str | None = None, prompts_root: Path | None = None) -> dict[str, PromptTemplateInfo]:
|
||||
resolved_prompts_root = get_prompts_root(prompts_root)
|
||||
requested_locale = normalize_locale(locale or get_locale())
|
||||
|
||||
prompt_paths: dict[str, Path] = {}
|
||||
prompt_paths: dict[str, PromptTemplateInfo] = {}
|
||||
for directory in _iter_prompt_template_layers(resolved_prompts_root, requested_locale):
|
||||
prompt_paths.update(_scan_prompt_directory(directory, resolved_prompts_root))
|
||||
|
||||
@@ -149,7 +228,7 @@ def resolve_prompt_path(
|
||||
else:
|
||||
prompt_paths = list_prompt_templates(locale=requested_locale, prompts_root=resolved_prompts_root)
|
||||
if normalized_name in prompt_paths:
|
||||
return prompt_paths[normalized_name]
|
||||
return prompt_paths[normalized_name].path
|
||||
|
||||
raise FileNotFoundError(t("prompt.template_not_found", locale=requested_locale, name=normalized_name))
|
||||
|
||||
|
||||
@@ -56,9 +56,9 @@ BOT_CONFIG_PATH: Path = (CONFIG_DIR / "bot_config.toml").resolve().absolute()
|
||||
MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute()
|
||||
LEGACY_ENV_PATH: Path = (PROJECT_ROOT / ".env").resolve().absolute()
|
||||
A_MEMORIX_LEGACY_CONFIG_PATH: Path = (CONFIG_DIR / "a_memorix.toml").resolve().absolute()
|
||||
MMC_VERSION: str = "1.0.0-pre.10"
|
||||
CONFIG_VERSION: str = "8.10.6"
|
||||
MODEL_CONFIG_VERSION: str = "1.14.8"
|
||||
MMC_VERSION: str = "1.0.0-pre.11"
|
||||
CONFIG_VERSION: str = "8.10.7"
|
||||
MODEL_CONFIG_VERSION: str = "1.15.3"
|
||||
|
||||
logger = get_logger("config")
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user