merge: 同步上游 dev 最新内容

This commit is contained in:
DawnARC
2026-05-06 00:53:11 +08:00
125 changed files with 3069 additions and 1271 deletions

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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}

View File

@@ -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>
)

View File

@@ -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', () => {

View File

@@ -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>

View File

@@ -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": "查看使用统计",

View File

@@ -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)
}

View File

@@ -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}`)

View File

@@ -15,6 +15,7 @@ export interface FieldHookComponentProps {
onChange?: (value: unknown) => void
children?: ReactNode
schema?: ConfigSchema | FieldSchema
parentValues?: Record<string, unknown>
/**
* 如果当前字段是 `List[ConfigBase]` 或嵌套 ConfigBase
* 这里会传入对应子配置类的 ConfigSchema便于自定义编辑器

View File

@@ -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(),
}))

View File

@@ -8,6 +8,9 @@ export interface PromptFileInfo {
name: string
size: number
modified_at: number
display_name: string
advanced: boolean
description: string
}
export interface PromptCatalog {

View File

@@ -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}`

View File

@@ -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">

View File

@@ -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>
)

View File

@@ -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: [],

View File

@@ -11,6 +11,8 @@ export type {
UseAutoSaveReturnGeneric,
} from './useAutoSave'
export {
BotPlatformsHook,
ChatPromptsHook,
ChatTalkValueRulesHook,
ExpressionGroupsHook,
ExpressionLearningListHook,

View File

@@ -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])
}

View File

@@ -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">

View File

@@ -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>

View File

@@ -78,7 +78,6 @@ export interface EmojiConfig {
check_interval: number
steal_emoji: boolean
content_filtration: boolean
filtration_prompt: string
}
export interface MemoryConfig {

View File

@@ -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"

View File

@@ -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}

View File

@@ -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>
)
})
})

View File

@@ -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>
)

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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(() => {

View File

@@ -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>
}

View File

@@ -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

View File

@@ -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">

View File

@@ -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>

View File

@@ -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">

View File

@@ -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 === '' ||

View File

@@ -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()) ||

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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">

View File

@@ -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 {

View File

@@ -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 || [],

View File

@@ -106,7 +106,7 @@ function SetupPageContent() {
// 步骤1Bot基础信息
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 {

View File

@@ -10,7 +10,7 @@ export interface SetupStep {
// 步骤1Bot基础信息
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[]

View File

@@ -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 {

View File

@@ -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

View File

@@ -82,6 +82,8 @@ export interface PluginInfo {
screenshots?: string[]
/** 更新日志 */
changelog?: string
/** 插件来源plugin-repo 市场或本地已安装插件 */
source?: 'market' | 'local'
}
/**