merge: 同步上游 dev 并增强人物画像查询
This commit is contained in:
2502
dashboard/bun.lock
Normal file
2502
dashboard/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
73
dashboard/package-lock.json
generated
73
dashboard/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "maibot-dashboard",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "maibot-dashboard",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.3",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
@@ -62,6 +62,7 @@
|
||||
"idb": "^8.0.3",
|
||||
"katex": "^0.16.27",
|
||||
"lucide-react": "^0.556.0",
|
||||
"motion": "^12.38.0",
|
||||
"react": "^19.2.1",
|
||||
"react-day-picker": "^9.12.0",
|
||||
"react-dom": "^19.2.1",
|
||||
@@ -9794,6 +9795,33 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.38.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
|
||||
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.38.0",
|
||||
"motion-utils": "^12.36.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
||||
@@ -13187,6 +13215,47 @@
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/motion": {
|
||||
"version": "12.38.0",
|
||||
"resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz",
|
||||
"integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.38.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.38.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
|
||||
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.36.0"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.36.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
|
||||
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "maibot-dashboard",
|
||||
"private": true,
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.4",
|
||||
"type": "module",
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import * as LucideIcons from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -9,8 +10,8 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import type { ConfigSchema, FieldSchema } from '@/types/config-schema'
|
||||
import { fieldHooks, type FieldHookRegistry } from '@/lib/field-hooks'
|
||||
import type { ConfigSchema, FieldSchema } from '@/types/config-schema'
|
||||
|
||||
import { DynamicField } from './DynamicField'
|
||||
|
||||
@@ -20,53 +21,204 @@ export interface DynamicConfigFormProps {
|
||||
onChange: (field: string, value: unknown) => void
|
||||
basePath?: string
|
||||
hooks?: FieldHookRegistry
|
||||
/** 嵌套层级:0 = tab 内容层, 1 = section 内容层, 2+ = 更深嵌套 */
|
||||
/** 嵌套层级:0 = tab 内容层,1 = section 内容层,2+ = 更深嵌套 */
|
||||
level?: number
|
||||
advancedVisible?: boolean
|
||||
}
|
||||
|
||||
function buildFieldPath(basePath: string, fieldName: string) {
|
||||
return basePath ? `${basePath}.${fieldName}` : fieldName
|
||||
}
|
||||
|
||||
function hasTopLevelAdvancedFields(schema: ConfigSchema) {
|
||||
return schema.fields.some((field) => field.advanced && !schema.nested?.[field.name])
|
||||
}
|
||||
|
||||
function resolveSectionTitle(schema: ConfigSchema) {
|
||||
return schema.uiLabel || schema.classDoc || schema.className
|
||||
}
|
||||
|
||||
function resolveSectionDescription(schema: ConfigSchema, sectionTitle: string) {
|
||||
return schema.classDoc && schema.classDoc !== sectionTitle
|
||||
? schema.classDoc
|
||||
: undefined
|
||||
}
|
||||
|
||||
function SectionIcon({ iconName }: { iconName?: string }) {
|
||||
if (!iconName) return null
|
||||
const IconComponent = LucideIcons[iconName as keyof typeof LucideIcons] as
|
||||
| React.ComponentType<{ className?: string }>
|
||||
| undefined
|
||||
if (!IconComponent) return null
|
||||
return <IconComponent className="h-5 w-5 text-muted-foreground" />
|
||||
}
|
||||
|
||||
function AdvancedSettingsButton({
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={active ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
>
|
||||
高级设置
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function DynamicConfigSection({
|
||||
basePath,
|
||||
hooks,
|
||||
level,
|
||||
mergedChildren = [],
|
||||
nestedSchema,
|
||||
onChange,
|
||||
sectionDescription,
|
||||
sectionKey,
|
||||
sectionTitle,
|
||||
values,
|
||||
}: {
|
||||
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
|
||||
sectionKey: string
|
||||
sectionTitle: string
|
||||
values: Record<string, unknown>
|
||||
}) {
|
||||
const [advancedVisible, setAdvancedVisible] = React.useState(false)
|
||||
const hasAdvanced =
|
||||
hasTopLevelAdvancedFields(nestedSchema) ||
|
||||
mergedChildren.some((child) => hasTopLevelAdvancedFields(child.schema))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<SectionIcon iconName={nestedSchema.uiIcon} />
|
||||
<CardTitle className="text-lg">{sectionTitle}</CardTitle>
|
||||
</div>
|
||||
{sectionDescription && (
|
||||
<CardDescription>{sectionDescription}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
{hasAdvanced && (
|
||||
<AdvancedSettingsButton
|
||||
active={advancedVisible}
|
||||
onClick={() => setAdvancedVisible((current) => !current)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DynamicConfigForm
|
||||
schema={nestedSchema}
|
||||
values={values}
|
||||
onChange={(field, value) => onChange(`${sectionKey}.${field}`, value)}
|
||||
basePath={basePath}
|
||||
hooks={hooks}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* DynamicConfigForm - 动态配置表单组件
|
||||
*
|
||||
*
|
||||
* 根据 ConfigSchema 渲染表单字段,支持:
|
||||
* 1. Hook 系统:通过 FieldHookRegistry 自定义字段渲染
|
||||
* - replace 模式:完全替换默认渲染
|
||||
* - wrapper 模式:包装默认渲染(通过 children 传递)
|
||||
* 2. 嵌套 schema:递归渲染 schema.nested 中的子配置,使用 Card 容器区分层级
|
||||
* 3. 默认渲染:使用 DynamicField 组件
|
||||
* 2. 嵌套 schema:递归渲染 schema.nested 中的子配置
|
||||
* 3. 高级设置:由栏目标题右侧按钮控制显示
|
||||
*/
|
||||
export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
schema,
|
||||
values,
|
||||
onChange,
|
||||
basePath = '',
|
||||
hooks = fieldHooks, // 默认使用全局单例
|
||||
hooks = fieldHooks,
|
||||
level = 0,
|
||||
advancedVisible,
|
||||
}) => {
|
||||
const [localAdvancedVisible, setLocalAdvancedVisible] = React.useState(false)
|
||||
const resolvedAdvancedVisible = advancedVisible ?? localAdvancedVisible
|
||||
|
||||
const fieldMap = React.useMemo(
|
||||
() => new Map(schema.fields.map((field) => [field.name, field])),
|
||||
[schema.fields]
|
||||
[schema.fields],
|
||||
)
|
||||
const mergedChildKeys = React.useMemo(() => {
|
||||
const keys = new Set<string>()
|
||||
for (const nestedSchema of Object.values(schema.nested ?? {})) {
|
||||
for (const childKey of nestedSchema.uiMergeChildren ?? []) {
|
||||
if (schema.nested?.[childKey]) {
|
||||
keys.add(childKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}, [schema.nested])
|
||||
|
||||
const buildFieldPath = (fieldName: string) => {
|
||||
return basePath ? `${basePath}.${fieldName}` : fieldName
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染单个字段
|
||||
* 检查是否有注册的 Hook,根据 Hook 类型选择渲染方式
|
||||
*/
|
||||
const renderField = (field: FieldSchema) => {
|
||||
const fieldPath = buildFieldPath(field.name)
|
||||
const fieldPath = buildFieldPath(basePath, field.name)
|
||||
|
||||
// 检查是否有注册的 Hook
|
||||
if (hooks.has(fieldPath)) {
|
||||
const hookEntry = hooks.get(fieldPath)
|
||||
if (!hookEntry) return null // Type guard(理论上不会发生)
|
||||
if (!hookEntry) return null
|
||||
|
||||
const HookComponent = hookEntry.component
|
||||
|
||||
if (hookEntry.type === 'replace') {
|
||||
// replace 模式:完全替换默认渲染
|
||||
return (
|
||||
<HookComponent
|
||||
fieldPath={fieldPath}
|
||||
@@ -75,27 +227,25 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
schema={field}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
// wrapper 模式:包装默认渲染
|
||||
return (
|
||||
<HookComponent
|
||||
fieldPath={fieldPath}
|
||||
}
|
||||
|
||||
return (
|
||||
<HookComponent
|
||||
fieldPath={fieldPath}
|
||||
value={values[field.name]}
|
||||
onChange={(v) => onChange(field.name, v)}
|
||||
schema={field}
|
||||
>
|
||||
<DynamicField
|
||||
schema={field}
|
||||
value={values[field.name]}
|
||||
onChange={(v) => onChange(field.name, v)}
|
||||
schema={field}
|
||||
>
|
||||
<DynamicField
|
||||
schema={field}
|
||||
value={values[field.name]}
|
||||
onChange={(v) => onChange(field.name, v)}
|
||||
fieldPath={fieldPath}
|
||||
/>
|
||||
</HookComponent>
|
||||
)
|
||||
}
|
||||
fieldPath={fieldPath}
|
||||
/>
|
||||
</HookComponent>
|
||||
)
|
||||
}
|
||||
|
||||
// 无 Hook,使用默认渲染
|
||||
return (
|
||||
<DynamicField
|
||||
schema={field}
|
||||
@@ -106,44 +256,49 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
/** 渲染 section 图标 */
|
||||
const renderSectionIcon = (iconName?: string) => {
|
||||
if (!iconName) return null
|
||||
const IconComponent = LucideIcons[iconName as keyof typeof LucideIcons] as
|
||||
| React.ComponentType<{ className?: string }>
|
||||
| undefined
|
||||
if (!IconComponent) return null
|
||||
return <IconComponent className="h-5 w-5 text-muted-foreground" />
|
||||
}
|
||||
|
||||
// 过滤出不属于 nested 的顶层字段
|
||||
const topLevelFields = schema.fields.filter(
|
||||
(field) => !schema.nested?.[field.name]
|
||||
(field) => !schema.nested?.[field.name],
|
||||
)
|
||||
const normalFields = topLevelFields.filter((field) => !field.advanced)
|
||||
const advancedFields = topLevelFields.filter((field) => field.advanced)
|
||||
const visibleFields = resolvedAdvancedVisible
|
||||
? [...normalFields, ...advancedFields]
|
||||
: normalFields
|
||||
|
||||
const renderFieldList = (fields: FieldSchema[]) => (
|
||||
<>
|
||||
{fields.map((field, index) => (
|
||||
<React.Fragment key={field.name}>
|
||||
{index > 0 && <Separator className="my-2 bg-border/50" />}
|
||||
<div className="py-1">{renderField(field)}</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 渲染顶层字段 */}
|
||||
{topLevelFields.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{topLevelFields.map((field, index) => (
|
||||
<React.Fragment key={field.name}>
|
||||
{index > 0 && field.type !== 'boolean' && topLevelFields[index - 1]?.type !== 'boolean' && (
|
||||
<Separator className="my-1" />
|
||||
)}
|
||||
<div>{renderField(field)}</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div>
|
||||
{advancedVisible === undefined && advancedFields.length > 0 && (
|
||||
<div className="flex justify-end pb-2">
|
||||
<AdvancedSettingsButton
|
||||
active={localAdvancedVisible}
|
||||
onClick={() => setLocalAdvancedVisible((current) => !current)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{renderFieldList(visibleFields)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 渲染嵌套 schema */}
|
||||
{schema.nested &&
|
||||
Object.entries(schema.nested).map(([key, nestedSchema]) => {
|
||||
Object.entries(schema.nested)
|
||||
.filter(([key]) => !mergedChildKeys.has(key))
|
||||
.map(([key, nestedSchema]) => {
|
||||
const nestedField = fieldMap.get(key)
|
||||
const nestedFieldPath = buildFieldPath(key)
|
||||
const nestedFieldPath = buildFieldPath(basePath, key)
|
||||
|
||||
// Hook 系统处理
|
||||
if (hooks.has(nestedFieldPath)) {
|
||||
const hookEntry = hooks.get(nestedFieldPath)
|
||||
if (!hookEntry) return null
|
||||
@@ -185,67 +340,77 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
const sectionTitle =
|
||||
nestedSchema.uiLabel || nestedSchema.classDoc || nestedSchema.className
|
||||
const sectionDescription =
|
||||
nestedSchema.classDoc && nestedSchema.classDoc !== sectionTitle
|
||||
? nestedSchema.classDoc
|
||||
: undefined
|
||||
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),
|
||||
)
|
||||
|
||||
// 一级嵌套:使用 Card 包裹,清晰的 section 边界
|
||||
if (level === 0) {
|
||||
return (
|
||||
<Card key={key}>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{renderSectionIcon(nestedSchema.uiIcon)}
|
||||
<CardTitle className="text-lg">{sectionTitle}</CardTitle>
|
||||
</div>
|
||||
{sectionDescription && (
|
||||
<CardDescription>{sectionDescription}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DynamicConfigForm
|
||||
schema={nestedSchema}
|
||||
values={(values[key] as Record<string, unknown>) || {}}
|
||||
onChange={(field, value) => onChange(`${key}.${field}`, value)}
|
||||
basePath={nestedFieldPath}
|
||||
hooks={hooks}
|
||||
level={level + 1}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 二级及更深嵌套:使用左侧指示条 + 轻量分组
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="relative space-y-4 rounded-lg border-l-2 border-muted-foreground/20 pl-4 pt-1 pb-1"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{renderSectionIcon(nestedSchema.uiIcon)}
|
||||
<h4 className="text-sm font-semibold">{sectionTitle}</h4>
|
||||
</div>
|
||||
{sectionDescription && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{sectionDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DynamicConfigForm
|
||||
schema={nestedSchema}
|
||||
<DynamicConfigSection
|
||||
key={key}
|
||||
mergedChildren={mergedChildren}
|
||||
nestedSchema={nestedSchema}
|
||||
values={(values[key] as Record<string, unknown>) || {}}
|
||||
onChange={(field, value) => onChange(`${key}.${field}`, value)}
|
||||
onChange={onChange}
|
||||
basePath={nestedFieldPath}
|
||||
hooks={hooks}
|
||||
level={level + 1}
|
||||
sectionKey={key}
|
||||
sectionTitle={sectionTitle}
|
||||
sectionDescription={sectionDescription}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card key={key} className="border-border/70 bg-muted/20 shadow-none">
|
||||
<CardHeader className="px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<SectionIcon iconName={nestedSchema.uiIcon} />
|
||||
<CardTitle className="text-sm">{sectionTitle}</CardTitle>
|
||||
</div>
|
||||
{sectionDescription && (
|
||||
<CardDescription className="text-xs">
|
||||
{sectionDescription}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 pt-0">
|
||||
<DynamicConfigForm
|
||||
schema={nestedSchema}
|
||||
values={(values[key] as Record<string, unknown>) || {}}
|
||||
onChange={(field, value) => onChange(`${key}.${field}`, value)}
|
||||
basePath={nestedFieldPath}
|
||||
hooks={hooks}
|
||||
level={level + 1}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 { cn } from "@/lib/utils"
|
||||
import type { FieldSchema } from "@/types/config-schema"
|
||||
|
||||
export interface DynamicFieldProps {
|
||||
@@ -93,6 +94,28 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
return <IconComponent className="h-4 w-4" />
|
||||
}
|
||||
|
||||
const renderFieldHeader = () => (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Label
|
||||
className={cn(
|
||||
"inline-flex min-h-7 items-center gap-1.5 rounded-md border px-2 py-1 text-sm font-medium shadow-sm",
|
||||
schema.advanced
|
||||
? "border-amber-300 bg-amber-50 text-amber-950 dark:border-amber-500/60 dark:bg-amber-500/15 dark:text-amber-100"
|
||||
: "bg-muted/60 text-foreground",
|
||||
)}
|
||||
>
|
||||
{renderIcon()}
|
||||
<span className="break-all">{schema.label}</span>
|
||||
{schema.required && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
{schema.description && (
|
||||
<span className="text-[13px] leading-6 text-muted-foreground whitespace-pre-line">
|
||||
{schema.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* 根据 x-widget 或 type 选择并渲染对应的输入组件
|
||||
*/
|
||||
@@ -175,16 +198,9 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
const renderSwitch = () => {
|
||||
const checked = Boolean(value)
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border p-3 sm:p-4">
|
||||
<div className="space-y-0.5 pr-4">
|
||||
<Label className="text-sm font-medium flex items-center gap-2">
|
||||
{renderIcon()}
|
||||
{schema.label}
|
||||
{schema.required && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
{schema.description && (
|
||||
<p className="text-[13px] text-muted-foreground whitespace-pre-line">{schema.description}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
<div className="pr-4">
|
||||
{renderFieldHeader()}
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
@@ -305,28 +321,38 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
const isBoolean =
|
||||
schema['x-widget'] === 'switch' ||
|
||||
(!schema['x-widget'] && schema.type === 'boolean')
|
||||
const supportsInlineRight =
|
||||
schema['x-layout'] === 'inline-right' &&
|
||||
['input', 'number', 'password', 'select', undefined].includes(schema['x-widget']) &&
|
||||
['string', 'number', 'integer', 'select'].includes(schema.type)
|
||||
|
||||
// Switch/Boolean 字段自带完整布局,直接返回
|
||||
if (isBoolean) {
|
||||
return renderInputComponent()
|
||||
}
|
||||
|
||||
if (supportsInlineRight) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-2 py-2 sm:flex-row sm:items-center sm:justify-between"
|
||||
style={{ '--field-input-width': schema['x-input-width'] ?? '12rem' } as React.CSSProperties}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
{renderFieldHeader()}
|
||||
</div>
|
||||
<div className="w-full shrink-0 sm:w-[var(--field-input-width)]">
|
||||
{renderInputComponent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Label with icon */}
|
||||
<Label className="text-sm font-medium flex items-center gap-2">
|
||||
{renderIcon()}
|
||||
{schema.label}
|
||||
{schema.required && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
{renderFieldHeader()}
|
||||
|
||||
{/* Input component */}
|
||||
{renderInputComponent()}
|
||||
|
||||
{/* Description */}
|
||||
{schema.description && (
|
||||
<p className="text-[13px] text-muted-foreground whitespace-pre-line">{schema.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Activity, Boxes, Database, FileSearch, FileText, Hash, Home, LayoutGrid, MessageSquare, Network, Package, Server, Settings, Sliders, Smile, UserCircle } from 'lucide-react'
|
||||
import { Activity, Boxes, Database, FileSearch, FileText, Hash, Home, MessageSquare, Network, Package, ScrollText, Server, Settings, Sliders, Smile } from 'lucide-react'
|
||||
|
||||
import type { MenuSection } from './types'
|
||||
|
||||
@@ -7,6 +7,7 @@ export const menuSections: MenuSection[] = [
|
||||
title: 'sidebar.groups.overview',
|
||||
items: [
|
||||
{ icon: Home, label: 'sidebar.menu.home', path: '/', searchDescription: 'search.items.homeDesc' },
|
||||
{ icon: Activity, label: 'sidebar.menu.maisakaMonitor', path: '/planner-monitor' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -15,7 +16,7 @@ export const menuSections: MenuSection[] = [
|
||||
{ icon: FileText, label: 'sidebar.menu.botMainConfig', path: '/config/bot', searchDescription: 'search.items.botConfigDesc' },
|
||||
{ icon: Server, label: 'sidebar.menu.aiModelProvider', path: '/config/modelProvider', searchDescription: 'search.items.modelProviderDesc', tourId: 'sidebar-model-provider' },
|
||||
{ icon: Boxes, label: 'sidebar.menu.modelManagement', path: '/config/model', searchDescription: 'search.items.modelDesc', tourId: 'sidebar-model-management' },
|
||||
{ icon: Sliders, label: 'sidebar.menu.adapterConfig', path: '/config/adapter' },
|
||||
{ icon: ScrollText, label: 'sidebar.menu.promptManagement', path: '/config/prompts' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -24,24 +25,21 @@ export const menuSections: MenuSection[] = [
|
||||
{ icon: Smile, label: 'sidebar.menu.emojiManagement', path: '/resource/emoji', searchDescription: 'search.items.emojiDesc' },
|
||||
{ icon: MessageSquare, label: 'sidebar.menu.expressionManagement', path: '/resource/expression', searchDescription: 'search.items.expressionDesc' },
|
||||
{ icon: Hash, label: 'sidebar.menu.slangManagement', path: '/resource/jargon', searchDescription: 'search.items.jargonDesc' },
|
||||
{ icon: UserCircle, label: 'sidebar.menu.personInfo', path: '/resource/person', searchDescription: 'search.items.personDesc' },
|
||||
{ icon: Network, label: 'sidebar.menu.knowledgeGraph', path: '/resource/knowledge-graph' },
|
||||
{ icon: Database, label: 'sidebar.menu.knowledgeBase', path: '/resource/knowledge-base' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'sidebar.groups.extensionsMonitor',
|
||||
items: [
|
||||
{ icon: Package, label: 'sidebar.menu.pluginMarket', path: '/plugins', searchDescription: 'search.items.pluginsDesc' },
|
||||
{ icon: LayoutGrid, label: 'sidebar.menu.configTemplate', path: '/config/pack-market' },
|
||||
{ icon: Sliders, label: 'sidebar.menu.pluginConfig', path: '/plugin-config' },
|
||||
{ icon: FileSearch, label: 'sidebar.menu.logViewer', path: '/logs', searchDescription: 'search.items.logsDesc' },
|
||||
{ icon: Activity, label: 'sidebar.menu.maisakaMonitor', path: '/planner-monitor' },
|
||||
{ icon: Package, label: 'sidebar.menu.pluginMarket', path: '/plugins', searchDescription: 'search.items.pluginsDesc' },
|
||||
{ icon: Network, label: 'sidebar.menu.mcpSettings', path: '/mcp-settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'sidebar.groups.system',
|
||||
items: [
|
||||
{ icon: FileSearch, label: 'sidebar.menu.logViewer', path: '/logs', searchDescription: 'search.items.logsDesc' },
|
||||
{ icon: Settings, label: 'sidebar.menu.settings', path: '/settings', searchDescription: 'search.items.settingsDesc' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -80,6 +80,8 @@ export function MemoryProfileManager() {
|
||||
const [selectedPersonId, setSelectedPersonId] = useState('')
|
||||
const [queryPersonId, setQueryPersonId] = useState('')
|
||||
const [queryKeyword, setQueryKeyword] = useState('')
|
||||
const [queryPlatform, setQueryPlatform] = useState('')
|
||||
const [queryUserId, setQueryUserId] = useState('')
|
||||
const [queryLimit, setQueryLimit] = useState('12')
|
||||
const [forceRefresh, setForceRefresh] = useState(false)
|
||||
const [overrideText, setOverrideText] = useState('')
|
||||
@@ -131,10 +133,11 @@ export function MemoryProfileManager() {
|
||||
}, [selectedProfile])
|
||||
|
||||
const submitQuery = useCallback(async () => {
|
||||
if (!queryPersonId.trim() && !queryKeyword.trim()) {
|
||||
const hasAccountLocator = queryPlatform.trim() && queryUserId.trim()
|
||||
if (!queryPersonId.trim() && !queryKeyword.trim() && !hasAccountLocator) {
|
||||
toast({
|
||||
title: '请输入查询条件',
|
||||
description: 'person_id 和关键词至少填写一个。',
|
||||
description: 'person_id、关键词、或平台与账号至少填写一种。',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
@@ -144,6 +147,8 @@ export function MemoryProfileManager() {
|
||||
const payload = await queryMemoryProfile({
|
||||
personId: queryPersonId.trim(),
|
||||
personKeyword: queryKeyword.trim(),
|
||||
platform: queryPlatform.trim(),
|
||||
userId: queryUserId.trim(),
|
||||
limit: parsePositiveInt(queryLimit, 12),
|
||||
forceRefresh,
|
||||
})
|
||||
@@ -151,6 +156,7 @@ export function MemoryProfileManager() {
|
||||
const nextPersonId = String(payload.person_id ?? payload.profile?.person_id ?? queryPersonId ?? '')
|
||||
if (nextPersonId) {
|
||||
setSelectedPersonId(nextPersonId)
|
||||
setQueryPersonId(nextPersonId)
|
||||
}
|
||||
toast({
|
||||
title: '人物画像查询完成',
|
||||
@@ -166,7 +172,7 @@ export function MemoryProfileManager() {
|
||||
} finally {
|
||||
setQuerying(false)
|
||||
}
|
||||
}, [forceRefresh, loadProfiles, queryKeyword, queryLimit, queryPersonId, toast])
|
||||
}, [forceRefresh, loadProfiles, queryKeyword, queryLimit, queryPersonId, queryPlatform, queryUserId, toast])
|
||||
|
||||
const saveOverride = useCallback(async () => {
|
||||
const personId = selectedPersonId || queryPersonId.trim()
|
||||
@@ -232,7 +238,7 @@ export function MemoryProfileManager() {
|
||||
<Search className="h-4 w-4" />
|
||||
人物画像查询
|
||||
</CardTitle>
|
||||
<CardDescription>查看最近画像快照,或按 person_id / 关键词触发查询与刷新。</CardDescription>
|
||||
<CardDescription>查看最近画像快照,或按 person_id、关键词、平台账号触发查询与刷新。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
@@ -244,6 +250,24 @@ export function MemoryProfileManager() {
|
||||
<Label htmlFor="profile-keyword">人物关键词</Label>
|
||||
<Input id="profile-keyword" value={queryKeyword} onChange={(event) => setQueryKeyword(event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-platform">平台</Label>
|
||||
<Input
|
||||
id="profile-platform"
|
||||
value={queryPlatform}
|
||||
onChange={(event) => setQueryPlatform(event.target.value)}
|
||||
placeholder="例如 qq、telegram、webui"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-user-id">平台账号</Label>
|
||||
<Input
|
||||
id="profile-user-id"
|
||||
value={queryUserId}
|
||||
onChange={(event) => setQueryUserId(event.target.value)}
|
||||
placeholder="输入平台侧 user_id"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-limit">证据数量</Label>
|
||||
<Input id="profile-limit" type="number" value={queryLimit} onChange={(event) => setQueryLimit(event.target.value)} />
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"overview": "Overview",
|
||||
"botConfig": "Bot Configuration",
|
||||
"botResources": "Bot Resources",
|
||||
"extensionsMonitor": "Extensions & Monitor",
|
||||
"extensionsMonitor": "Plugins & Extensions",
|
||||
"system": "System"
|
||||
},
|
||||
"menu": {
|
||||
@@ -27,16 +27,18 @@
|
||||
"botMainConfig": "Bot Main Config",
|
||||
"aiModelProvider": "AI Model Providers",
|
||||
"modelManagement": "Model Management",
|
||||
"promptManagement": "Prompt Management",
|
||||
"adapterConfig": "Adapter Config",
|
||||
"emojiManagement": "Emoji Management",
|
||||
"expressionManagement": "Expression Management",
|
||||
"slangManagement": "Slang Management",
|
||||
"personInfo": "Person Info",
|
||||
"knowledgeGraph": "Long-Term Memory Graph",
|
||||
"knowledgeBase": "Long-Term Memory Console",
|
||||
"knowledgeBase": "Long-Term Memory",
|
||||
"pluginMarket": "Plugin Market",
|
||||
"configTemplate": "Config Templates",
|
||||
"pluginConfig": "Plugin Config",
|
||||
"pluginConfig": "Plugin Management",
|
||||
"mcpSettings": "MCP Settings",
|
||||
"logViewer": "Log Viewer",
|
||||
"maisakaMonitor": "MaiSaka Chat Monitor",
|
||||
"localChat": "Local Chat",
|
||||
@@ -500,17 +502,13 @@
|
||||
"title": "Personality",
|
||||
"description": "Define the bot's personality and speaking style"
|
||||
},
|
||||
"emoji": {
|
||||
"title": "Emoji",
|
||||
"description": "Configure emoji-related settings"
|
||||
},
|
||||
"other": {
|
||||
"title": "Other Settings",
|
||||
"description": "Configure global slang and other basic options"
|
||||
},
|
||||
"siliconFlow": {
|
||||
"apiProvider": {
|
||||
"title": "API Setup",
|
||||
"description": "Configure the SiliconFlow API key"
|
||||
"description": "Configure the API provider"
|
||||
},
|
||||
"modelSetup": {
|
||||
"title": "Model Setup",
|
||||
"description": "Configure planner and replyer models"
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
@@ -528,7 +526,12 @@
|
||||
"selectPlatform": "Please select a platform",
|
||||
"enterNickname": "Please enter a nickname",
|
||||
"enterQqAccount": "Please enter a QQ account",
|
||||
"enterAccountId": "Please enter an account ID"
|
||||
"enterAccountId": "Please enter an account ID",
|
||||
"enterProviderName": "Please enter an API provider name",
|
||||
"enterBaseUrl": "Please enter the API base URL",
|
||||
"enterApiKey": "Please enter the API key",
|
||||
"enterPlannerModelIdentifier": "Please enter the planner model identifier",
|
||||
"enterReplyerModelIdentifier": "Please enter the replyer model identifier"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailedTitle": "Failed to load configuration",
|
||||
@@ -667,33 +670,43 @@
|
||||
"description": "Allow the bot to learn and use group-specific slang"
|
||||
}
|
||||
},
|
||||
"siliconFlow": {
|
||||
"about": {
|
||||
"title": "About SiliconFlow",
|
||||
"description": "SiliconFlow provides broad model coverage, including DeepSeek V3, Qwen, vision models, speech recognition, and embedding models. A single API key unlocks all MaiBot features.",
|
||||
"link": "Get an API key from SiliconFlow"
|
||||
"apiProvider": {
|
||||
"providerName": {
|
||||
"label": "API Provider Name *",
|
||||
"placeholder": "For example OpenAI, DeepSeek, or self-hosted",
|
||||
"description": "This name is written to model_config.toml and referenced by the models below"
|
||||
},
|
||||
"baseUrl": {
|
||||
"label": "API Base URL *",
|
||||
"description": "Enter an OpenAI-compatible endpoint, for example https://api.example.com/v1"
|
||||
},
|
||||
"apiKey": {
|
||||
"label": "SiliconFlow API Key *",
|
||||
"description": "Enter your SiliconFlow API key. Once provided, MaiBot will automatically configure all required models.",
|
||||
"label": "API Key *",
|
||||
"description": "Enter the API key for this provider",
|
||||
"show": "Show API key",
|
||||
"hide": "Hide API key"
|
||||
},
|
||||
"autoConfig": {
|
||||
"title": "The following models will be configured automatically:",
|
||||
"items": {
|
||||
"deepseek": "DeepSeek V3 - primary chat and tool model",
|
||||
"qwen3": "Qwen3 30B - frequent small tasks and tool calls",
|
||||
"qwen3Vl": "Qwen3 VL 30B - image recognition",
|
||||
"senseVoice": "SenseVoice - speech recognition",
|
||||
"bgeM3": "BGE-M3 - text embeddings",
|
||||
"lpmm": "Knowledge-base-related models (LPMM)"
|
||||
}
|
||||
},
|
||||
"modelSetup": {
|
||||
"planner": {
|
||||
"identifier": {
|
||||
"label": "planner Model Identifier *",
|
||||
"description": "The real model ID provided by the API service; the model name will be initialized from it"
|
||||
},
|
||||
"visual": {
|
||||
"label": "Enable vision"
|
||||
}
|
||||
},
|
||||
"hint": {
|
||||
"title": "Tip: ",
|
||||
"description": "After finishing the wizard, you can add more API providers and models in \"System Settings -> Model Config\"."
|
||||
}
|
||||
"replyer": {
|
||||
"identifier": {
|
||||
"label": "replyer Model Identifier *",
|
||||
"description": "The real model ID provided by the API service; the model name will be initialized from it"
|
||||
},
|
||||
"visual": {
|
||||
"label": "Enable vision"
|
||||
}
|
||||
},
|
||||
"saveHint": "You can configure more detailed task assignment later."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"overview": "概要",
|
||||
"botConfig": "ボット設定",
|
||||
"botResources": "ボットリソース",
|
||||
"extensionsMonitor": "拡張機能 & 監視",
|
||||
"extensionsMonitor": "プラグインと拡張",
|
||||
"system": "システム"
|
||||
},
|
||||
"menu": {
|
||||
@@ -27,16 +27,18 @@
|
||||
"botMainConfig": "ボットメイン設定",
|
||||
"aiModelProvider": "AIモデルプロバイダー",
|
||||
"modelManagement": "モデル管理",
|
||||
"promptManagement": "Prompt 管理",
|
||||
"adapterConfig": "アダプター設定",
|
||||
"emojiManagement": "絵文字管理",
|
||||
"expressionManagement": "表現管理",
|
||||
"slangManagement": "スラング管理",
|
||||
"personInfo": "人物情報",
|
||||
"knowledgeGraph": "長期記憶グラフ",
|
||||
"knowledgeBase": "長期記憶コンソール",
|
||||
"knowledgeBase": "長期記憶",
|
||||
"pluginMarket": "プラグインマーケット",
|
||||
"configTemplate": "設定テンプレート",
|
||||
"pluginConfig": "プラグイン設定",
|
||||
"pluginConfig": "プラグイン管理",
|
||||
"mcpSettings": "MCP 設定",
|
||||
"logViewer": "ログビューア",
|
||||
"maisakaMonitor": "MaiSaka チャット監視",
|
||||
"localChat": "ローカルチャット",
|
||||
@@ -500,17 +502,13 @@
|
||||
"title": "人格設定",
|
||||
"description": "ボットの性格や話し方を定義します"
|
||||
},
|
||||
"emoji": {
|
||||
"title": "絵文字パック",
|
||||
"description": "絵文字パック関連の設定を行います"
|
||||
},
|
||||
"other": {
|
||||
"title": "その他の設定",
|
||||
"description": "グローバルスラングなどの基本オプションを設定します"
|
||||
},
|
||||
"siliconFlow": {
|
||||
"apiProvider": {
|
||||
"title": "API設定",
|
||||
"description": "SiliconFlow API キーを設定します"
|
||||
"description": "APIプロバイダーを設定します"
|
||||
},
|
||||
"modelSetup": {
|
||||
"title": "モデル設定",
|
||||
"description": "planner と replyer モデルを設定します"
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
@@ -528,7 +526,12 @@
|
||||
"selectPlatform": "プラットフォームを選択してください",
|
||||
"enterNickname": "ニックネームを入力してください",
|
||||
"enterQqAccount": "QQ アカウントを入力してください",
|
||||
"enterAccountId": "アカウント ID を入力してください"
|
||||
"enterAccountId": "アカウント ID を入力してください",
|
||||
"enterProviderName": "APIプロバイダー名を入力してください",
|
||||
"enterBaseUrl": "API Base URL を入力してください",
|
||||
"enterApiKey": "API Key を入力してください",
|
||||
"enterPlannerModelIdentifier": "planner モデル識別子を入力してください",
|
||||
"enterReplyerModelIdentifier": "replyer モデル識別子を入力してください"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailedTitle": "設定の読み込みに失敗しました",
|
||||
@@ -667,33 +670,43 @@
|
||||
"description": "グループ内のスラングを学習して使えるようにします"
|
||||
}
|
||||
},
|
||||
"siliconFlow": {
|
||||
"about": {
|
||||
"title": "SiliconFlow について",
|
||||
"description": "SiliconFlow は DeepSeek V3、Qwen、ビジョンモデル、音声認識、埋め込みモデルなど幅広いモデルを提供します。API Key が1つあれば MaiBot の全機能を利用できます。",
|
||||
"link": "SiliconFlow で API Key を取得する"
|
||||
"apiProvider": {
|
||||
"providerName": {
|
||||
"label": "APIプロバイダー名 *",
|
||||
"placeholder": "例: OpenAI、DeepSeek、自ホストサービス",
|
||||
"description": "この名前は model_config.toml に保存され、下のモデルから参照されます"
|
||||
},
|
||||
"baseUrl": {
|
||||
"label": "API Base URL *",
|
||||
"description": "OpenAI互換エンドポイントを入力してください。例: https://api.example.com/v1"
|
||||
},
|
||||
"apiKey": {
|
||||
"label": "SiliconFlow API Key *",
|
||||
"description": "SiliconFlow の API Key を入力してください。入力後、MaiBot が必要なモデルを自動設定します。",
|
||||
"label": "API Key *",
|
||||
"description": "このプロバイダーの API Key を入力してください",
|
||||
"show": "API Key を表示",
|
||||
"hide": "API Key を隠す"
|
||||
},
|
||||
"autoConfig": {
|
||||
"title": "以下のモデルが自動設定されます:",
|
||||
"items": {
|
||||
"deepseek": "DeepSeek V3 - メインの会話・ツールモデル",
|
||||
"qwen3": "Qwen3 30B - 頻繁な小タスクとツール呼び出し",
|
||||
"qwen3Vl": "Qwen3 VL 30B - 画像認識",
|
||||
"senseVoice": "SenseVoice - 音声認識",
|
||||
"bgeM3": "BGE-M3 - テキスト埋め込み",
|
||||
"lpmm": "知識ベース関連モデル (LPMM)"
|
||||
"hide": "API Key を非表示"
|
||||
}
|
||||
},
|
||||
"modelSetup": {
|
||||
"planner": {
|
||||
"identifier": {
|
||||
"label": "planner モデル識別子 *",
|
||||
"description": "APIサービスが提供する実際のモデルID。モデル名はこの識別子で初期化されます"
|
||||
},
|
||||
"visual": {
|
||||
"label": "ビジョンを有効化"
|
||||
}
|
||||
},
|
||||
"hint": {
|
||||
"title": "ヒント:",
|
||||
"description": "ウィザード完了後は、「システム設定 -> モデル設定」でさらに API プロバイダーやモデルを追加できます。"
|
||||
}
|
||||
"replyer": {
|
||||
"identifier": {
|
||||
"label": "replyer モデル識別子 *",
|
||||
"description": "APIサービスが提供する実際のモデルID。モデル名はこの識別子で初期化されます"
|
||||
},
|
||||
"visual": {
|
||||
"label": "ビジョンを有効化"
|
||||
}
|
||||
},
|
||||
"saveHint": "より詳細なタスク割り当ては後で設定できます。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"overview": "개요",
|
||||
"botConfig": "봇 설정",
|
||||
"botResources": "봇 리소스",
|
||||
"extensionsMonitor": "확장 기능 & 모니터",
|
||||
"extensionsMonitor": "플러그인 및 확장",
|
||||
"system": "시스템"
|
||||
},
|
||||
"menu": {
|
||||
@@ -27,16 +27,18 @@
|
||||
"botMainConfig": "봇 메인 설정",
|
||||
"aiModelProvider": "AI 모델 공급자",
|
||||
"modelManagement": "모델 관리",
|
||||
"promptManagement": "Prompt 관리",
|
||||
"adapterConfig": "어댑터 설정",
|
||||
"emojiManagement": "이모티콘 관리",
|
||||
"expressionManagement": "표현 관리",
|
||||
"slangManagement": "슬랭 관리",
|
||||
"personInfo": "인물 정보",
|
||||
"knowledgeGraph": "장기 기억 그래프",
|
||||
"knowledgeBase": "장기 기억 콘솔",
|
||||
"knowledgeBase": "장기 기억",
|
||||
"pluginMarket": "플러그인 마켓",
|
||||
"configTemplate": "설정 템플릿",
|
||||
"pluginConfig": "플러그인 설정",
|
||||
"pluginConfig": "플러그인 관리",
|
||||
"mcpSettings": "MCP 설정",
|
||||
"logViewer": "로그 뷰어",
|
||||
"maisakaMonitor": "MaiSaka 채팅 모니터",
|
||||
"localChat": "로컬 채팅",
|
||||
@@ -500,17 +502,13 @@
|
||||
"title": "성격 설정",
|
||||
"description": "봇의 성격과 말투를 정의합니다"
|
||||
},
|
||||
"emoji": {
|
||||
"title": "이모지 팩",
|
||||
"description": "이모지 관련 설정을 구성합니다"
|
||||
},
|
||||
"other": {
|
||||
"title": "기타 설정",
|
||||
"description": "전역 슬랭 등 기본 옵션을 설정합니다"
|
||||
},
|
||||
"siliconFlow": {
|
||||
"apiProvider": {
|
||||
"title": "API 설정",
|
||||
"description": "SiliconFlow API 키를 설정합니다"
|
||||
"description": "API 제공자를 설정합니다"
|
||||
},
|
||||
"modelSetup": {
|
||||
"title": "모델 설정",
|
||||
"description": "planner와 replyer 모델을 설정합니다"
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
@@ -528,7 +526,12 @@
|
||||
"selectPlatform": "플랫폼을 선택해 주세요",
|
||||
"enterNickname": "닉네임을 입력해 주세요",
|
||||
"enterQqAccount": "QQ 계정을 입력해 주세요",
|
||||
"enterAccountId": "계정 ID를 입력해 주세요"
|
||||
"enterAccountId": "계정 ID를 입력해 주세요",
|
||||
"enterProviderName": "API 제공자 이름을 입력해 주세요",
|
||||
"enterBaseUrl": "API Base URL을 입력해 주세요",
|
||||
"enterApiKey": "API Key를 입력해 주세요",
|
||||
"enterPlannerModelIdentifier": "planner 모델 식별자를 입력해 주세요",
|
||||
"enterReplyerModelIdentifier": "replyer 모델 식별자를 입력해 주세요"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailedTitle": "설정 불러오기에 실패했습니다",
|
||||
@@ -667,33 +670,43 @@
|
||||
"description": "봇이 그룹 슬랭을 학습하고 사용할 수 있게 합니다"
|
||||
}
|
||||
},
|
||||
"siliconFlow": {
|
||||
"about": {
|
||||
"title": "SiliconFlow 소개",
|
||||
"description": "SiliconFlow 는 DeepSeek V3, Qwen, 비전 모델, 음성 인식, 임베딩 모델 등 폭넓은 모델을 제공합니다. API Key 하나로 MaiBot 의 모든 기능을 사용할 수 있습니다.",
|
||||
"link": "SiliconFlow 에서 API Key 받기"
|
||||
"apiProvider": {
|
||||
"providerName": {
|
||||
"label": "API 제공자 이름 *",
|
||||
"placeholder": "예: OpenAI, DeepSeek, 자체 호스팅",
|
||||
"description": "이 이름은 model_config.toml에 저장되며 아래 모델에서 참조됩니다"
|
||||
},
|
||||
"baseUrl": {
|
||||
"label": "API Base URL *",
|
||||
"description": "OpenAI 호환 엔드포인트를 입력해 주세요. 예: https://api.example.com/v1"
|
||||
},
|
||||
"apiKey": {
|
||||
"label": "SiliconFlow API Key *",
|
||||
"description": "SiliconFlow API Key를 입력해 주세요. 입력하면 MaiBot 이 필요한 모델을 자동으로 구성합니다.",
|
||||
"label": "API Key *",
|
||||
"description": "이 제공자의 API Key를 입력해 주세요",
|
||||
"show": "API Key 표시",
|
||||
"hide": "API Key 숨기기"
|
||||
},
|
||||
"autoConfig": {
|
||||
"title": "다음 모델이 자동으로 구성됩니다:",
|
||||
"items": {
|
||||
"deepseek": "DeepSeek V3 - 주요 대화 및 도구 모델",
|
||||
"qwen3": "Qwen3 30B - 잦은 소규모 작업과 도구 호출",
|
||||
"qwen3Vl": "Qwen3 VL 30B - 이미지 인식",
|
||||
"senseVoice": "SenseVoice - 음성 인식",
|
||||
"bgeM3": "BGE-M3 - 텍스트 임베딩",
|
||||
"lpmm": "지식 베이스 관련 모델 (LPMM)"
|
||||
}
|
||||
},
|
||||
"modelSetup": {
|
||||
"planner": {
|
||||
"identifier": {
|
||||
"label": "planner 모델 식별자 *",
|
||||
"description": "API 서비스가 제공하는 실제 모델 ID입니다. 모델 이름은 이 식별자로 초기화됩니다"
|
||||
},
|
||||
"visual": {
|
||||
"label": "비전 사용"
|
||||
}
|
||||
},
|
||||
"hint": {
|
||||
"title": "팁: ",
|
||||
"description": "마법사를 마친 뒤에는 \"시스템 설정 -> 모델 설정\"에서 더 많은 API 제공자와 모델을 추가할 수 있습니다."
|
||||
}
|
||||
"replyer": {
|
||||
"identifier": {
|
||||
"label": "replyer 모델 식별자 *",
|
||||
"description": "API 서비스가 제공하는 실제 모델 ID입니다. 모델 이름은 이 식별자로 초기화됩니다"
|
||||
},
|
||||
"visual": {
|
||||
"label": "비전 사용"
|
||||
}
|
||||
},
|
||||
"saveHint": "더 자세한 작업 할당은 나중에 설정할 수 있습니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"overview": "概览",
|
||||
"botConfig": "麦麦配置编辑",
|
||||
"botResources": "麦麦资源管理",
|
||||
"extensionsMonitor": "扩展与监控",
|
||||
"extensionsMonitor": "插件与扩展",
|
||||
"system": "系统"
|
||||
},
|
||||
"menu": {
|
||||
@@ -27,16 +27,18 @@
|
||||
"botMainConfig": "麦麦主程序配置",
|
||||
"aiModelProvider": "AI模型厂商配置",
|
||||
"modelManagement": "模型管理与分配",
|
||||
"promptManagement": "Prompt 管理",
|
||||
"adapterConfig": "麦麦适配器配置",
|
||||
"emojiManagement": "表情包管理",
|
||||
"expressionManagement": "表达方式管理",
|
||||
"slangManagement": "黑话管理",
|
||||
"personInfo": "人物信息管理",
|
||||
"knowledgeGraph": "长期记忆图谱",
|
||||
"knowledgeBase": "长期记忆控制台",
|
||||
"knowledgeBase": "长期记忆",
|
||||
"pluginMarket": "插件市场",
|
||||
"configTemplate": "配置模板市场",
|
||||
"pluginConfig": "插件配置",
|
||||
"pluginConfig": "插件管理",
|
||||
"mcpSettings": "MCP 设置",
|
||||
"logViewer": "日志查看器",
|
||||
"maisakaMonitor": "MaiSaka 聊天流监控",
|
||||
"localChat": "本地聊天室",
|
||||
@@ -500,17 +502,13 @@
|
||||
"title": "人格配置",
|
||||
"description": "定义机器人的性格和说话风格"
|
||||
},
|
||||
"emoji": {
|
||||
"title": "表情包",
|
||||
"description": "配置表情包相关设置"
|
||||
},
|
||||
"other": {
|
||||
"title": "其他设置",
|
||||
"description": "配置全局黑话等基础选项"
|
||||
},
|
||||
"siliconFlow": {
|
||||
"apiProvider": {
|
||||
"title": "API配置",
|
||||
"description": "配置硅基流动 API 密钥"
|
||||
"description": "配置 API 提供商"
|
||||
},
|
||||
"modelSetup": {
|
||||
"title": "模型配置",
|
||||
"description": "配置 planner 和 replyer 模型"
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
@@ -528,7 +526,12 @@
|
||||
"selectPlatform": "请选择平台",
|
||||
"enterNickname": "请输入昵称",
|
||||
"enterQqAccount": "请输入 QQ 账号",
|
||||
"enterAccountId": "请输入账号 ID"
|
||||
"enterAccountId": "请输入账号 ID",
|
||||
"enterProviderName": "请输入 API 提供商名称",
|
||||
"enterBaseUrl": "请输入 API Base URL",
|
||||
"enterApiKey": "请输入 API Key",
|
||||
"enterPlannerModelIdentifier": "请输入 planner 模型标识符",
|
||||
"enterReplyerModelIdentifier": "请输入 replyer 模型标识符"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailedTitle": "加载配置失败",
|
||||
@@ -559,8 +562,8 @@
|
||||
"footer": "您可以随时在设置中修改这些配置",
|
||||
"defaults": {
|
||||
"personality": {
|
||||
"personality": "是一个女大学生,现在在读大二,会刷贴吧。",
|
||||
"replyStyle": "请回复得平淡一些,简短一些,说中文,不要刻意突出自身学科背景。可以参考贴吧、知乎和微博的回复风格。",
|
||||
"personality": "是一个大二女大学生,现在正在上网和群友聊天。有轻微攻击性",
|
||||
"replyStyle": "你的风格平淡简短。可以参考贴吧,知乎和微博的回复风格。不浮夸不长篇大论,不要过分修辞和复杂句。",
|
||||
"multipleReplyStyles": {
|
||||
"plain": "你的风格平淡但不失讽刺,很简短,很白话。可以参考贴吧、微博的回复风格。",
|
||||
"shortText": "用1-2个字进行回复",
|
||||
@@ -577,7 +580,7 @@
|
||||
"platform": {
|
||||
"label": "平台 *",
|
||||
"placeholder": "请选择平台",
|
||||
"description": "选择机器人运行的平台",
|
||||
"description": "选择麦麦Bot运行的平台",
|
||||
"options": {
|
||||
"custom": "其他平台"
|
||||
}
|
||||
@@ -589,7 +592,7 @@
|
||||
"qqAccount": {
|
||||
"label": "QQ账号 *",
|
||||
"placeholder": "请输入机器人的 QQ 账号",
|
||||
"description": "机器人登录使用的 QQ 账号"
|
||||
"description": "运行麦麦Bot的 QQ 账号"
|
||||
},
|
||||
"primaryAccount": {
|
||||
"label": "账号 ID *",
|
||||
@@ -599,7 +602,7 @@
|
||||
"nickname": {
|
||||
"label": "昵称 *",
|
||||
"placeholder": "请输入机器人的昵称",
|
||||
"description": "机器人的主要称呼名称"
|
||||
"description": "麦麦Bot的名称"
|
||||
},
|
||||
"alias": {
|
||||
"label": "别名",
|
||||
@@ -667,33 +670,43 @@
|
||||
"description": "允许机器人学习和使用群组黑话"
|
||||
}
|
||||
},
|
||||
"siliconFlow": {
|
||||
"about": {
|
||||
"title": "关于硅基流动 (SiliconFlow)",
|
||||
"description": "硅基流动提供了完整的模型覆盖,包括 DeepSeek V3、Qwen、视觉模型、语音识别和嵌入模型。只需一个 API Key 即可使用麦麦的所有功能!",
|
||||
"link": "前往硅基流动获取 API Key"
|
||||
"apiProvider": {
|
||||
"providerName": {
|
||||
"label": "API 提供商名称 *",
|
||||
"placeholder": "例如 OpenAI、DeepSeek、自建服务",
|
||||
"description": "为api提供商命名"
|
||||
},
|
||||
"baseUrl": {
|
||||
"label": "API Base URL *",
|
||||
"description": "请填写 OpenAI 兼容接口地址,例如 https://api.example.com/v1"
|
||||
},
|
||||
"apiKey": {
|
||||
"label": "SiliconFlow API Key *",
|
||||
"description": "请输入您的硅基流动 API 密钥。获取后,麦麦将自动配置所有必需的模型。",
|
||||
"label": "API Key *",
|
||||
"description": "请填写该提供商的 API Key",
|
||||
"show": "显示 API Key",
|
||||
"hide": "隐藏 API Key"
|
||||
},
|
||||
"autoConfig": {
|
||||
"title": "将自动配置以下模型:",
|
||||
"items": {
|
||||
"deepseek": "DeepSeek V3 - 主要对话和工具模型",
|
||||
"qwen3": "Qwen3 30B - 高频小任务和工具调用",
|
||||
"qwen3Vl": "Qwen3 VL 30B - 图像识别",
|
||||
"senseVoice": "SenseVoice - 语音识别",
|
||||
"bgeM3": "BGE-M3 - 文本嵌入",
|
||||
"lpmm": "知识库相关模型 (LPMM)"
|
||||
}
|
||||
},
|
||||
"modelSetup": {
|
||||
"planner": {
|
||||
"identifier": {
|
||||
"label": "planner 模型标识符 *",
|
||||
"description": "API 服务商提供的真实模型 ID,模型名称会自动初始化为该标识符"
|
||||
},
|
||||
"visual": {
|
||||
"label": "启用视觉"
|
||||
}
|
||||
},
|
||||
"hint": {
|
||||
"title": "💡 提示:",
|
||||
"description": "完成向导后,您可以在“系统设置 -> 模型配置”中添加更多 API 提供商和模型。"
|
||||
}
|
||||
"replyer": {
|
||||
"identifier": {
|
||||
"label": "replyer 模型标识符 *",
|
||||
"description": "API 服务商提供的真实模型 ID,模型名称会自动初始化为该标识符"
|
||||
},
|
||||
"visual": {
|
||||
"label": "启用视觉"
|
||||
}
|
||||
},
|
||||
"saveHint": "你可以稍后配置更详细的任务分配。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,6 +26,10 @@ export interface MaisakaToolCall {
|
||||
export interface SessionStartEvent {
|
||||
session_id: string
|
||||
session_name: string
|
||||
is_group_chat?: boolean
|
||||
group_id?: string | null
|
||||
user_id?: string | null
|
||||
platform?: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
@@ -91,6 +95,60 @@ export interface ToolExecutionEvent {
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface MaisakaRequestBlock {
|
||||
messages: MaisakaMessage[]
|
||||
selected_history_count: number
|
||||
tool_count: number
|
||||
}
|
||||
|
||||
export interface MaisakaPlannerBlock {
|
||||
content: string | null
|
||||
tool_calls: MaisakaToolCall[]
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
total_tokens: number
|
||||
duration_ms: number
|
||||
prompt_html_uri?: string
|
||||
}
|
||||
|
||||
export interface MaisakaTimingGateBlock {
|
||||
request: MaisakaRequestBlock | null
|
||||
result: {
|
||||
action: 'continue' | 'wait' | 'no_reply' | null
|
||||
content: string | null
|
||||
tool_calls: MaisakaToolCall[]
|
||||
tool_results: unknown[]
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
total_tokens: number
|
||||
duration_ms: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface MaisakaFinalizedToolResult {
|
||||
tool_call_id: string
|
||||
tool_name: string
|
||||
tool_args: Record<string, unknown>
|
||||
success: boolean
|
||||
duration_ms: number
|
||||
summary: string
|
||||
detail?: unknown
|
||||
}
|
||||
|
||||
export interface PlannerFinalizedEvent {
|
||||
session_id: string
|
||||
cycle_id: number
|
||||
timestamp: number
|
||||
timing_gate: MaisakaTimingGateBlock | null
|
||||
request: MaisakaRequestBlock | null
|
||||
planner: MaisakaPlannerBlock | null
|
||||
tools: MaisakaFinalizedToolResult[]
|
||||
final_state: {
|
||||
time_records: Record<string, number>
|
||||
agent_state: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface CycleEndEvent {
|
||||
session_id: string
|
||||
cycle_id: number
|
||||
@@ -128,6 +186,7 @@ export type MaisakaMonitorEvent =
|
||||
| { type: 'timing_gate.result'; data: TimingGateResultEvent }
|
||||
| { type: 'planner.request'; data: PlannerRequestEvent }
|
||||
| { type: 'planner.response'; data: PlannerResponseEvent }
|
||||
| { type: 'planner.finalized'; data: PlannerFinalizedEvent }
|
||||
| { type: 'tool.execution'; data: ToolExecutionEvent }
|
||||
| { type: 'cycle.end'; data: CycleEndEvent }
|
||||
| { type: 'replier.request'; data: ReplierRequestEvent }
|
||||
|
||||
@@ -901,12 +901,16 @@ export async function getMemoryProfiles(limit: number = 50): Promise<MemoryProfi
|
||||
export async function queryMemoryProfile(options: {
|
||||
personId?: string
|
||||
personKeyword?: string
|
||||
platform?: string
|
||||
userId?: string
|
||||
limit?: number
|
||||
forceRefresh?: boolean
|
||||
}): Promise<MemoryProfileQueryPayload> {
|
||||
const params = new URLSearchParams({
|
||||
person_id: options.personId ?? '',
|
||||
person_keyword: options.personKeyword ?? '',
|
||||
platform: options.platform ?? '',
|
||||
user_id: options.userId ?? '',
|
||||
limit: String(options.limit ?? 12),
|
||||
force_refresh: options.forceRefresh ? 'true' : 'false',
|
||||
})
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface PackTaskConfigs {
|
||||
utils_small?: PackTaskConfig
|
||||
tool_use?: PackTaskConfig
|
||||
replyer?: PackTaskConfig
|
||||
learner?: PackTaskConfig
|
||||
planner?: PackTaskConfig
|
||||
vlm?: PackTaskConfig
|
||||
voice?: PackTaskConfig
|
||||
|
||||
@@ -35,6 +35,12 @@ interface PluginApiResponse {
|
||||
}
|
||||
homepage_url?: string
|
||||
repository_url?: string
|
||||
urls?: {
|
||||
repository?: string
|
||||
homepage?: string
|
||||
documentation?: string
|
||||
issues?: string
|
||||
}
|
||||
keywords: string[]
|
||||
categories?: string[]
|
||||
default_locale: string
|
||||
@@ -44,6 +50,28 @@ interface PluginApiResponse {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
function normalizePluginManifest(manifest: PluginApiResponse['manifest']): PluginInfo['manifest'] {
|
||||
const repositoryUrl = manifest.repository_url || manifest.urls?.repository
|
||||
const homepageUrl = manifest.homepage_url || manifest.urls?.homepage
|
||||
|
||||
return {
|
||||
manifest_version: manifest.manifest_version || 1,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description || '',
|
||||
author: manifest.author || { name: 'Unknown' },
|
||||
license: manifest.license || 'Unknown',
|
||||
host_application: manifest.host_application || { min_version: '0.0.0' },
|
||||
homepage_url: homepageUrl,
|
||||
repository_url: repositoryUrl,
|
||||
urls: manifest.urls,
|
||||
keywords: manifest.keywords || [],
|
||||
categories: manifest.categories || [],
|
||||
default_locale: manifest.default_locale || 'zh-CN',
|
||||
locales_path: manifest.locales_path,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从远程获取插件列表(通过后端代理避免 CORS)
|
||||
*/
|
||||
@@ -88,21 +116,7 @@ export async function fetchPluginList(): Promise<ApiResponse<PluginInfo[]>> {
|
||||
})
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
manifest: {
|
||||
manifest_version: item.manifest.manifest_version || 1,
|
||||
name: item.manifest.name,
|
||||
version: item.manifest.version,
|
||||
description: item.manifest.description || '',
|
||||
author: item.manifest.author || { name: 'Unknown' },
|
||||
license: item.manifest.license || 'Unknown',
|
||||
host_application: item.manifest.host_application || { min_version: '0.0.0' },
|
||||
homepage_url: item.manifest.homepage_url,
|
||||
repository_url: item.manifest.repository_url,
|
||||
keywords: item.manifest.keywords || [],
|
||||
categories: item.manifest.categories || [],
|
||||
default_locale: item.manifest.default_locale || 'zh-CN',
|
||||
locales_path: item.manifest.locales_path,
|
||||
},
|
||||
manifest: normalizePluginManifest(item.manifest),
|
||||
downloads: 0,
|
||||
rating: 0,
|
||||
review_count: 0,
|
||||
|
||||
@@ -44,6 +44,10 @@ export interface InstalledPlugin {
|
||||
[key: string]: unknown // 允许其他字段
|
||||
}
|
||||
path: string
|
||||
enabled?: boolean
|
||||
disabled?: boolean
|
||||
loaded?: boolean
|
||||
load_status?: 'success' | 'failed' | 'inactive' | 'disabled' | 'unknown'
|
||||
}
|
||||
/**
|
||||
* 旧版本插件格式(直接包含 version 字段)
|
||||
|
||||
49
dashboard/src/lib/prompt-api.ts
Normal file
49
dashboard/src/lib/prompt-api.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { parseResponse } from '@/lib/api-helpers'
|
||||
import { fetchWithAuth } from '@/lib/fetch-with-auth'
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
|
||||
const API_BASE = '/api/webui/config/prompts'
|
||||
|
||||
export interface PromptFileInfo {
|
||||
name: string
|
||||
size: number
|
||||
modified_at: number
|
||||
}
|
||||
|
||||
export interface PromptCatalog {
|
||||
success: boolean
|
||||
languages: string[]
|
||||
files: Record<string, PromptFileInfo[]>
|
||||
}
|
||||
|
||||
export interface PromptFileContent {
|
||||
success: boolean
|
||||
language: string
|
||||
filename: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export async function getPromptCatalog(): Promise<ApiResponse<PromptCatalog>> {
|
||||
const response = await fetchWithAuth(API_BASE)
|
||||
return parseResponse<PromptCatalog>(response)
|
||||
}
|
||||
|
||||
export async function getPromptFile(
|
||||
language: string,
|
||||
filename: string
|
||||
): Promise<ApiResponse<PromptFileContent>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/${encodeURIComponent(language)}/${encodeURIComponent(filename)}`)
|
||||
return parseResponse<PromptFileContent>(response)
|
||||
}
|
||||
|
||||
export async function updatePromptFile(
|
||||
language: string,
|
||||
filename: string,
|
||||
content: string
|
||||
): Promise<ApiResponse<PromptFileContent>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/${encodeURIComponent(language)}/${encodeURIComponent(filename)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
return parseResponse<PromptFileContent>(response)
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* 修改此处的版本号后,所有展示版本的地方都会自动更新
|
||||
*/
|
||||
|
||||
export const APP_VERSION = '1.0.2'
|
||||
export const APP_VERSION = '1.0.3'
|
||||
export const APP_NAME = 'MaiBot Dashboard'
|
||||
export const APP_FULL_NAME = `${APP_NAME} v${APP_VERSION}`
|
||||
|
||||
|
||||
@@ -86,6 +86,12 @@ const modelConfigRoute = createRoute({
|
||||
})
|
||||
|
||||
// 配置路由 - 麦麦适配器配置(已停用,引导跳转到插件配置;旧实现保留在 ./routes/config/adapter)
|
||||
const promptManagementRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/config/prompts',
|
||||
component: lazyRouteComponent(() => import('./routes/config/prompts'), 'PromptManagementPage'),
|
||||
})
|
||||
|
||||
const adapterConfigRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/config/adapter',
|
||||
@@ -206,6 +212,12 @@ const pluginMirrorsRoute = createRoute({
|
||||
})
|
||||
|
||||
// 设置页路由
|
||||
const mcpSettingsRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/mcp-settings',
|
||||
component: lazyRouteComponent(() => import('./routes/mcp-settings'), 'MCPSettingsPage'),
|
||||
})
|
||||
|
||||
const settingsRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/settings',
|
||||
@@ -262,6 +274,7 @@ const routeTree = rootRoute.addChildren([
|
||||
botConfigRoute,
|
||||
modelProviderConfigRoute,
|
||||
modelConfigRoute,
|
||||
promptManagementRoute,
|
||||
adapterConfigRoute,
|
||||
emojiManagementRoute,
|
||||
expressionManagementRoute,
|
||||
@@ -274,6 +287,7 @@ const routeTree = rootRoute.addChildren([
|
||||
modelPresetsRoute,
|
||||
pluginConfigRoute,
|
||||
pluginMirrorsRoute,
|
||||
mcpSettingsRoute,
|
||||
logsRoute,
|
||||
plannerMonitorRoute,
|
||||
chatRoute,
|
||||
|
||||
@@ -29,7 +29,6 @@ import { Code2, Info, Layout, Power, Save } from 'lucide-react'
|
||||
import type { ConfigSchema } from '@/types/config-schema'
|
||||
import {
|
||||
ChatTalkValueRulesHook,
|
||||
ExperimentalChatPromptsHook,
|
||||
ExpressionGroupsHook,
|
||||
ExpressionLearningListHook,
|
||||
KeywordRulesHook,
|
||||
@@ -53,11 +52,9 @@ const TAB_ORDER = [
|
||||
'expression',
|
||||
'emoji',
|
||||
'response_post_process',
|
||||
'lpmm_knowledge',
|
||||
'webui',
|
||||
'maisaka',
|
||||
'plugin_runtime',
|
||||
'debug',
|
||||
'log',
|
||||
]
|
||||
|
||||
// ==================== Tab 分组类型与构建 ====================
|
||||
@@ -156,23 +153,21 @@ function BotConfigPageContent() {
|
||||
const [expressionConfig, setExpressionConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [emojiConfig, setEmojiConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [memoryConfig, setMemoryConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [relationshipConfig, setRelationshipConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [visualConfig, setVisualConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [voiceConfig, setVoiceConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [messageReceiveConfig, setMessageReceiveConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [lpmmConfig, setLpmmConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [keywordReactionConfig, setKeywordReactionConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [responsePostProcessConfig, setResponsePostProcessConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [chineseTypoConfig, setChineseTypoConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [responseSplitterConfig, setResponseSplitterConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [debugConfig, setDebugConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [experimentalConfig, setExperimentalConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [maimMessageConfig, setMaimMessageConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [telemetryConfig, setTelemetryConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [webuiConfig, setWebuiConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [databaseConfig, setDatabaseConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [maisakaConfig, setMaisakaConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [mcpConfig, setMcpConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [pluginRuntimeConfig, setPluginRuntimeConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [aMemorixConfig, setAMemorixConfig] = useState<ConfigSectionData | null>(null)
|
||||
|
||||
// Schema 状态(用于动态 tab 分组)
|
||||
const [configSchema, setConfigSchema] = useState<ConfigSchema | null>(null)
|
||||
@@ -253,23 +248,21 @@ function BotConfigPageContent() {
|
||||
setExpressionConfig((config.expression ?? {}) as ConfigSectionData)
|
||||
setEmojiConfig((config.emoji ?? {}) as ConfigSectionData)
|
||||
setMemoryConfig((config.memory ?? {}) as ConfigSectionData)
|
||||
setRelationshipConfig((config.relationship ?? {}) as ConfigSectionData)
|
||||
setVisualConfig((config.visual ?? {}) as ConfigSectionData)
|
||||
setVoiceConfig((config.voice ?? {}) as ConfigSectionData)
|
||||
setMessageReceiveConfig((config.message_receive ?? {}) as ConfigSectionData)
|
||||
setLpmmConfig((config.lpmm_knowledge ?? {}) as ConfigSectionData)
|
||||
setKeywordReactionConfig((config.keyword_reaction ?? {}) as ConfigSectionData)
|
||||
setResponsePostProcessConfig((config.response_post_process ?? {}) as ConfigSectionData)
|
||||
setChineseTypoConfig((config.chinese_typo ?? {}) as ConfigSectionData)
|
||||
setResponseSplitterConfig((config.response_splitter ?? {}) as ConfigSectionData)
|
||||
setDebugConfig((config.debug ?? {}) as ConfigSectionData)
|
||||
setExperimentalConfig((config.experimental ?? {}) as ConfigSectionData)
|
||||
setMaimMessageConfig((config.maim_message ?? {}) as ConfigSectionData)
|
||||
setTelemetryConfig((config.telemetry ?? {}) as ConfigSectionData)
|
||||
setWebuiConfig((config.webui ?? {}) as ConfigSectionData)
|
||||
setDatabaseConfig((config.database ?? {}) as ConfigSectionData)
|
||||
setMaisakaConfig((config.maisaka ?? {}) as ConfigSectionData)
|
||||
setMcpConfig((config.mcp ?? {}) as ConfigSectionData)
|
||||
setPluginRuntimeConfig((config.plugin_runtime ?? {}) as ConfigSectionData)
|
||||
setAMemorixConfig((config.a_memorix ?? {}) as ConfigSectionData)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
@@ -285,23 +278,21 @@ function BotConfigPageContent() {
|
||||
expression: expressionConfig,
|
||||
emoji: emojiConfig,
|
||||
memory: memoryConfig,
|
||||
relationship: relationshipConfig,
|
||||
visual: visualConfig,
|
||||
voice: voiceConfig,
|
||||
message_receive: messageReceiveConfig,
|
||||
lpmm_knowledge: lpmmConfig,
|
||||
keyword_reaction: keywordReactionConfig,
|
||||
response_post_process: responsePostProcessConfig,
|
||||
chinese_typo: chineseTypoConfig,
|
||||
response_splitter: responseSplitterConfig,
|
||||
debug: debugConfig,
|
||||
experimental: experimentalConfig,
|
||||
maim_message: maimMessageConfig,
|
||||
telemetry: telemetryConfig,
|
||||
webui: webuiConfig,
|
||||
database: databaseConfig,
|
||||
maisaka: maisakaConfig,
|
||||
mcp: mcpConfig,
|
||||
plugin_runtime: pluginRuntimeConfig,
|
||||
a_memorix: aMemorixConfig,
|
||||
}
|
||||
}, [
|
||||
botConfig,
|
||||
@@ -310,23 +301,21 @@ function BotConfigPageContent() {
|
||||
expressionConfig,
|
||||
emojiConfig,
|
||||
memoryConfig,
|
||||
relationshipConfig,
|
||||
visualConfig,
|
||||
voiceConfig,
|
||||
messageReceiveConfig,
|
||||
lpmmConfig,
|
||||
keywordReactionConfig,
|
||||
responsePostProcessConfig,
|
||||
chineseTypoConfig,
|
||||
responseSplitterConfig,
|
||||
debugConfig,
|
||||
experimentalConfig,
|
||||
maimMessageConfig,
|
||||
telemetryConfig,
|
||||
webuiConfig,
|
||||
databaseConfig,
|
||||
maisakaConfig,
|
||||
mcpConfig,
|
||||
pluginRuntimeConfig,
|
||||
aMemorixConfig,
|
||||
])
|
||||
|
||||
// 加载源代码
|
||||
@@ -406,7 +395,6 @@ function BotConfigPageContent() {
|
||||
useEffect(() => {
|
||||
const hookEntries = [
|
||||
['chat.talk_value_rules', ChatTalkValueRulesHook],
|
||||
['experimental.chat_prompts', ExperimentalChatPromptsHook],
|
||||
['expression.expression_groups', ExpressionGroupsHook],
|
||||
['expression.learning_list', ExpressionLearningListHook],
|
||||
['keyword_reaction.keyword_rules', KeywordRulesHook],
|
||||
@@ -442,23 +430,21 @@ function BotConfigPageContent() {
|
||||
useConfigAutoSave(expressionConfig, 'expression', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(emojiConfig, 'emoji', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(memoryConfig, 'memory', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(relationshipConfig, 'relationship', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(visualConfig, 'visual', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(voiceConfig, 'voice', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(messageReceiveConfig, 'message_receive', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(lpmmConfig, 'lpmm_knowledge', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(keywordReactionConfig, 'keyword_reaction', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(responsePostProcessConfig, 'response_post_process', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(chineseTypoConfig, 'chinese_typo', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(responseSplitterConfig, 'response_splitter', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(debugConfig, 'debug', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(experimentalConfig, 'experimental', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(maimMessageConfig, 'maim_message', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(telemetryConfig, 'telemetry', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(webuiConfig, 'webui', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(databaseConfig, 'database', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(maisakaConfig, 'maisaka', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(mcpConfig, 'mcp', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(pluginRuntimeConfig, 'plugin_runtime', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(aMemorixConfig, 'a_memorix', initialLoadRef.current, triggerAutoSave)
|
||||
|
||||
// 保存源代码
|
||||
const saveSourceCode = async () => {
|
||||
@@ -657,23 +643,21 @@ function BotConfigPageContent() {
|
||||
expression: expressionConfig,
|
||||
emoji: emojiConfig,
|
||||
memory: memoryConfig,
|
||||
relationship: relationshipConfig,
|
||||
visual: visualConfig,
|
||||
voice: voiceConfig,
|
||||
message_receive: messageReceiveConfig,
|
||||
lpmm_knowledge: lpmmConfig,
|
||||
keyword_reaction: keywordReactionConfig,
|
||||
response_post_process: responsePostProcessConfig,
|
||||
chinese_typo: chineseTypoConfig,
|
||||
response_splitter: responseSplitterConfig,
|
||||
debug: debugConfig,
|
||||
experimental: experimentalConfig,
|
||||
maim_message: maimMessageConfig,
|
||||
telemetry: telemetryConfig,
|
||||
webui: webuiConfig,
|
||||
database: databaseConfig,
|
||||
maisaka: maisakaConfig,
|
||||
mcp: mcpConfig,
|
||||
plugin_runtime: pluginRuntimeConfig,
|
||||
a_memorix: aMemorixConfig,
|
||||
}),
|
||||
[
|
||||
botConfig,
|
||||
@@ -682,23 +666,21 @@ function BotConfigPageContent() {
|
||||
expressionConfig,
|
||||
emojiConfig,
|
||||
memoryConfig,
|
||||
relationshipConfig,
|
||||
visualConfig,
|
||||
voiceConfig,
|
||||
messageReceiveConfig,
|
||||
lpmmConfig,
|
||||
keywordReactionConfig,
|
||||
responsePostProcessConfig,
|
||||
chineseTypoConfig,
|
||||
responseSplitterConfig,
|
||||
debugConfig,
|
||||
experimentalConfig,
|
||||
maimMessageConfig,
|
||||
telemetryConfig,
|
||||
webuiConfig,
|
||||
databaseConfig,
|
||||
maisakaConfig,
|
||||
mcpConfig,
|
||||
pluginRuntimeConfig,
|
||||
aMemorixConfig,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -710,23 +692,21 @@ function BotConfigPageContent() {
|
||||
expression: setExpressionConfig,
|
||||
emoji: setEmojiConfig,
|
||||
memory: setMemoryConfig,
|
||||
relationship: setRelationshipConfig,
|
||||
visual: setVisualConfig,
|
||||
voice: setVoiceConfig,
|
||||
message_receive: setMessageReceiveConfig,
|
||||
lpmm_knowledge: setLpmmConfig,
|
||||
keyword_reaction: setKeywordReactionConfig,
|
||||
response_post_process: setResponsePostProcessConfig,
|
||||
chinese_typo: setChineseTypoConfig,
|
||||
response_splitter: setResponseSplitterConfig,
|
||||
debug: setDebugConfig,
|
||||
experimental: setExperimentalConfig,
|
||||
maim_message: setMaimMessageConfig,
|
||||
telemetry: setTelemetryConfig,
|
||||
webui: setWebuiConfig,
|
||||
database: setDatabaseConfig,
|
||||
maisaka: setMaisakaConfig,
|
||||
mcp: setMcpConfig,
|
||||
plugin_runtime: setPluginRuntimeConfig,
|
||||
a_memorix: setAMemorixConfig,
|
||||
}
|
||||
|
||||
sectionSetterMap[sectionName]?.(value)
|
||||
|
||||
@@ -101,12 +101,6 @@ export const ExpressionGroupsHook = createJsonFieldHook({
|
||||
placeholder: '[\n {\n "expression_groups": [\n {\n "platform": "qq",\n "item_id": "123456",\n "rule_type": "group"\n }\n ]\n }\n]',
|
||||
})
|
||||
|
||||
export const ExperimentalChatPromptsHook = createJsonFieldHook({
|
||||
emptyValue: [],
|
||||
helperText: '实验配置中的定向 Prompt 列表使用 JSON 编辑。每一项应包含 platform、item_id、rule_type、prompt。',
|
||||
placeholder: '[\n {\n "platform": "qq",\n "item_id": "123456",\n "rule_type": "group",\n "prompt": "这里填写额外提示词"\n }\n]',
|
||||
})
|
||||
|
||||
export const MCPRootItemsHook = createJsonFieldHook({
|
||||
emptyValue: [],
|
||||
helperText: 'MCP Roots 条目为对象数组,使用 JSON 编辑。',
|
||||
|
||||
@@ -12,7 +12,6 @@ export type {
|
||||
} from './useAutoSave'
|
||||
export {
|
||||
ChatTalkValueRulesHook,
|
||||
ExperimentalChatPromptsHook,
|
||||
ExpressionGroupsHook,
|
||||
ExpressionLearningListHook,
|
||||
KeywordRulesHook,
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Plus, Trash2, AlertTriangle, Eye, Code2 } from 'lucide-react'
|
||||
import type { ExperimentalConfig } from '../types'
|
||||
|
||||
interface ChatPromptData {
|
||||
platform: string
|
||||
id: string
|
||||
type: 'group' | 'private'
|
||||
prompt: string
|
||||
}
|
||||
|
||||
interface ExperimentalSectionProps {
|
||||
config: ExperimentalConfig
|
||||
onChange: (config: ExperimentalConfig) => void
|
||||
}
|
||||
|
||||
export const ExperimentalSection = React.memo(function ExperimentalSection({ config, onChange }: ExperimentalSectionProps) {
|
||||
// 解析 chat_prompt 字符串为结构化数据
|
||||
const parseChatPrompt = (promptStr: string): ChatPromptData => {
|
||||
const parts = promptStr.split(':')
|
||||
if (parts.length >= 4) {
|
||||
const platform = parts[0]
|
||||
const id = parts[1]
|
||||
const type = parts[2] as 'group' | 'private'
|
||||
const prompt = parts.slice(3).join(':') // 处理 prompt 中可能包含的冒号
|
||||
return { platform, id, type, prompt }
|
||||
}
|
||||
return { platform: 'qq', id: '', type: 'group', prompt: '' }
|
||||
}
|
||||
|
||||
// 将结构化数据转换为字符串
|
||||
const stringifyChatPrompt = (data: ChatPromptData): string => {
|
||||
return `${data.platform}:${data.id}:${data.type}:${data.prompt}`
|
||||
}
|
||||
|
||||
const addChatPrompt = () => {
|
||||
onChange({ ...config, chat_prompts: [...config.chat_prompts, 'qq::group:'] })
|
||||
}
|
||||
|
||||
const removeChatPrompt = (index: number) => {
|
||||
onChange({
|
||||
...config,
|
||||
chat_prompts: config.chat_prompts.filter((_, i) => i !== index),
|
||||
})
|
||||
}
|
||||
|
||||
const updateChatPrompt = (index: number, data: Partial<ChatPromptData>) => {
|
||||
const currentData = parseChatPrompt(config.chat_prompts[index])
|
||||
const newData = { ...currentData, ...data }
|
||||
const newPrompts = [...config.chat_prompts]
|
||||
newPrompts[index] = stringifyChatPrompt(newData)
|
||||
onChange({ ...config, chat_prompts: newPrompts })
|
||||
}
|
||||
|
||||
// 预览组件
|
||||
const ChatPromptPreview = ({ promptStr }: { promptStr: string }) => {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
预览
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 sm:w-96">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-sm">配置预览</h4>
|
||||
<div className="rounded-md bg-muted p-3 font-mono text-xs break-all">
|
||||
"{promptStr}"
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
这是保存到 bot_config.toml 文件中的格式
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-orange-500/10 border border-orange-500/20">
|
||||
<AlertTriangle className="h-5 w-5 text-orange-500 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-medium text-orange-500">实验性功能</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
此部分包含实验性功能,可能不稳定或在未来版本中发生变化。请谨慎使用,并注意不推荐在生产环境中修改私聊规则。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">实验性设置</h3>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="lpmm_memory"
|
||||
checked={config.lpmm_memory ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, lpmm_memory: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="lpmm_memory" className="cursor-pointer">
|
||||
将聊天历史总结导入到 LPMM 知识库
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground -mt-4">
|
||||
开启后,chat_history_summarizer 总结出的历史记录会同时导入到知识库
|
||||
</p>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="private_plan_style">私聊规则(实验性)</Label>
|
||||
<Textarea
|
||||
id="private_plan_style"
|
||||
value={config.private_plan_style}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange({ ...config, private_plan_style: e.target.value })}
|
||||
placeholder="私聊的说话规则和行为风格(不推荐修改)"
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
⚠️ 不推荐修改此项,可能会影响私聊对话的稳定性
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>特定聊天 Prompt 配置</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
为指定聊天添加额外的 prompt,用于定制特定场景的对话行为
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={addChatPrompt} size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加配置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{config.chat_prompts.map((promptStr, index) => {
|
||||
const data = parseChatPrompt(promptStr)
|
||||
|
||||
return (
|
||||
<div key={index} className="rounded-lg border p-4 space-y-4 bg-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
Prompt 配置 {index + 1}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChatPromptPreview promptStr={promptStr} />
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="ghost">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除这个 prompt 配置吗?此操作无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => removeChatPrompt(index)}>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{/* 平台选择 */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-medium">平台</Label>
|
||||
<Select
|
||||
value={data.platform}
|
||||
onValueChange={(value) => updateChatPrompt(index, { platform: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择平台" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="qq">QQ</SelectItem>
|
||||
<SelectItem value="wx">微信</SelectItem>
|
||||
<SelectItem value="webui">WebUI</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* ID 输入 */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-medium">
|
||||
{data.type === 'group' ? '群号' : '用户ID'}
|
||||
</Label>
|
||||
<Input
|
||||
value={data.id}
|
||||
onChange={(e) => updateChatPrompt(index, { id: e.target.value })}
|
||||
placeholder={data.type === 'group' ? '输入群号' : '输入用户ID'}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 类型选择 */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-medium">类型</Label>
|
||||
<Select
|
||||
value={data.type}
|
||||
onValueChange={(value: 'group' | 'private') => updateChatPrompt(index, { type: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="group">群聊 (group)</SelectItem>
|
||||
<SelectItem value="private">私聊 (private)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Prompt 内容 */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-medium">Prompt 内容</Label>
|
||||
<Textarea
|
||||
value={data.prompt}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => updateChatPrompt(index, { prompt: e.target.value })}
|
||||
placeholder="输入额外的 prompt 内容,例如:这是一个摄影群,你精通摄影知识"
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
这段文本会作为系统提示添加到该聊天的上下文中
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 原始格式显示 */}
|
||||
<div className="rounded-md bg-muted/50 p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Code2 className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground">原始格式</span>
|
||||
</div>
|
||||
<code className="text-xs font-mono text-muted-foreground break-all">
|
||||
{promptStr || '(未配置)'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{config.chat_prompts.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p className="text-sm">暂无特定聊天 prompt 配置</p>
|
||||
<p className="text-xs mt-1">点击上方"添加配置"按钮创建新配置</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<div className="text-xs text-muted-foreground space-y-2 p-4 rounded-lg bg-muted/30 border">
|
||||
<p className="font-medium text-foreground">💡 使用说明</p>
|
||||
<ul className="list-disc list-inside space-y-1 pl-2">
|
||||
<li>为不同的聊天环境配置专属的行为提示</li>
|
||||
<li>支持多个平台:QQ、微信、WebUI</li>
|
||||
<li>可为群聊或私聊分别配置</li>
|
||||
<li>Prompt 会自动注入到该聊天的上下文中</li>
|
||||
</ul>
|
||||
<p className="font-medium text-foreground mt-3">📝 配置示例</p>
|
||||
<ul className="list-disc list-inside space-y-1 pl-2">
|
||||
<li>摄影群:<code className="text-xs bg-muted px-1 py-0.5 rounded">这是一个摄影群,你精通摄影知识</code></li>
|
||||
<li>二次元群:<code className="text-xs bg-muted px-1 py-0.5 rounded">这是一个二次元交流群</code></li>
|
||||
<li>好友私聊:<code className="text-xs bg-muted px-1 py-0.5 rounded">这是你与好朋友的私聊</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -590,16 +590,16 @@ export const ExpressionSection = React.memo(function ExpressionSection({
|
||||
id="expression_auto_check_interval"
|
||||
type="number"
|
||||
min="60"
|
||||
value={config.expression_auto_check_interval ?? 3600}
|
||||
value={config.expression_auto_check_interval ?? 900}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
expression_auto_check_interval: parseInt(e.target.value) || 3600,
|
||||
expression_auto_check_interval: parseInt(e.target.value) || 900,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
表达方式自动检查的间隔时间(单位:秒),默认值:3600秒(1小时)
|
||||
表达方式自动检查的间隔时间(单位:秒),默认值:900秒(15分钟)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -613,16 +613,16 @@ export const ExpressionSection = React.memo(function ExpressionSection({
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={config.expression_auto_check_count ?? 10}
|
||||
value={config.expression_auto_check_count ?? 5}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
expression_auto_check_count: parseInt(e.target.value) || 10,
|
||||
expression_auto_check_count: parseInt(e.target.value) || 5,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
每次自动检查时随机选取的表达方式数量,默认值:10条
|
||||
每次自动检查时随机选取的表达方式数量,默认值:5条
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ export { DreamSection } from './DreamSection'
|
||||
export { LPMMSection } from './LPMMSection'
|
||||
export { LogSection } from './LogSection'
|
||||
export { DebugSection } from './DebugSection'
|
||||
export { ExperimentalSection } from './ExperimentalSection'
|
||||
export { MaimMessageSection } from './MaimMessageSection'
|
||||
export { TelemetrySection } from './TelemetrySection'
|
||||
export { FeaturesSection } from './FeaturesSection'
|
||||
|
||||
@@ -189,12 +189,6 @@ export interface DebugConfig {
|
||||
show_lpmm_paragraph: boolean
|
||||
}
|
||||
|
||||
export interface ExperimentalConfig {
|
||||
private_plan_style: string
|
||||
chat_prompts: string[]
|
||||
lpmm_memory: boolean
|
||||
}
|
||||
|
||||
export interface MaimMessageConfig {
|
||||
auth_token: string[]
|
||||
enable_api_server: boolean
|
||||
@@ -239,14 +233,12 @@ export interface AllBotConfigs {
|
||||
voiceConfig: VoiceConfig | null
|
||||
messageReceiveConfig: MessageReceiveConfig | null
|
||||
dreamConfig: DreamConfig | null
|
||||
lpmmConfig: LPMMKnowledgeConfig | null
|
||||
keywordReactionConfig: KeywordReactionConfig | null
|
||||
responsePostProcessConfig: ResponsePostProcessConfig | null
|
||||
chineseTypoConfig: ChineseTypoConfig | null
|
||||
responseSplitterConfig: ResponseSplitterConfig | null
|
||||
logConfig: LogConfig | null
|
||||
debugConfig: DebugConfig | null
|
||||
experimentalConfig: ExperimentalConfig | null
|
||||
maimMessageConfig: MaimMessageConfig | null
|
||||
telemetryConfig: TelemetryConfig | null
|
||||
}
|
||||
@@ -261,23 +253,21 @@ export type ConfigSectionName =
|
||||
| 'expression'
|
||||
| 'emoji'
|
||||
| 'memory'
|
||||
| 'relationship'
|
||||
| 'visual'
|
||||
| 'tool'
|
||||
| 'voice'
|
||||
| 'message_receive'
|
||||
| 'dream'
|
||||
| 'lpmm_knowledge'
|
||||
| 'keyword_reaction'
|
||||
| 'response_post_process'
|
||||
| 'chinese_typo'
|
||||
| 'response_splitter'
|
||||
| 'log'
|
||||
| 'debug'
|
||||
| 'experimental'
|
||||
| 'maim_message'
|
||||
| 'telemetry'
|
||||
| 'webui'
|
||||
| 'database'
|
||||
| 'maisaka'
|
||||
| 'mcp'
|
||||
| 'plugin_runtime'
|
||||
| 'a_memorix'
|
||||
|
||||
@@ -106,6 +106,7 @@ function ModelConfigPageContent() {
|
||||
const [jumpToPage, setJumpToPage] = useState('')
|
||||
|
||||
const [advancedTemperatureMode, setAdvancedTemperatureMode] = useState(false)
|
||||
const [advancedTaskSettingsVisible, setAdvancedTaskSettingsVisible] = useState(false)
|
||||
|
||||
// 模型 Combobox 状态
|
||||
const [modelComboboxOpen, setModelComboboxOpen] = useState(false)
|
||||
@@ -155,7 +156,9 @@ function ModelConfigPageContent() {
|
||||
|
||||
// 检查是否有模型
|
||||
if (!task.model_list || task.model_list.length === 0) {
|
||||
emptyTaskList.push(key)
|
||||
if (key !== 'learner') {
|
||||
emptyTaskList.push(key)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -939,14 +942,26 @@ function ModelConfigPageContent() {
|
||||
|
||||
{/* 模型任务配置标签页 */}
|
||||
<TabsContent value="tasks" className="space-y-6 mt-0">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
为不同的任务配置使用的模型和参数
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
为不同的任务配置使用的模型和参数
|
||||
</p>
|
||||
{taskConfigSchema?.fields.some((field) => field.advanced) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant={advancedTaskSettingsVisible ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setAdvancedTaskSettingsVisible((current) => !current)}
|
||||
>
|
||||
高级设置
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{taskConfig && taskConfigSchema && (
|
||||
<div className="grid gap-4 sm:gap-6">
|
||||
{taskConfigSchema.fields
|
||||
.filter(f => f.type === 'object')
|
||||
.filter(f => f.type === 'object' && (advancedTaskSettingsVisible || !f.advanced))
|
||||
.map((field, index) => {
|
||||
const desc = field.description || field.name
|
||||
const commaIdx = desc.search(/[,,]/)
|
||||
@@ -960,6 +975,7 @@ function ModelConfigPageContent() {
|
||||
taskConfig={taskConfig[field.name] ?? { model_list: [] }}
|
||||
modelNames={modelNames}
|
||||
onChange={(f, value) => updateTaskConfig(field.name, f, value)}
|
||||
advanced={field.advanced}
|
||||
{...(index === 0 ? { dataTour: 'task-model-select' } : {})}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { TaskConfig } from '../types'
|
||||
|
||||
interface TaskConfigCardProps {
|
||||
@@ -23,6 +24,7 @@ interface TaskConfigCardProps {
|
||||
onChange: (field: keyof TaskConfig, value: string[] | number | string) => void
|
||||
hideTemperature?: boolean
|
||||
hideMaxTokens?: boolean
|
||||
advanced?: boolean
|
||||
dataTour?: string
|
||||
}
|
||||
|
||||
@@ -34,6 +36,7 @@ export const TaskConfigCard = React.memo(function TaskConfigCard({
|
||||
onChange,
|
||||
hideTemperature = false,
|
||||
hideMaxTokens = false,
|
||||
advanced = false,
|
||||
dataTour,
|
||||
}: TaskConfigCardProps) {
|
||||
const handleModelChange = (values: string[]) => {
|
||||
@@ -41,7 +44,12 @@ export const TaskConfigCard = React.memo(function TaskConfigCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-card p-4 sm:p-6 space-y-4",
|
||||
advanced && "border-amber-300 bg-amber-50/40 dark:border-amber-500/50 dark:bg-amber-500/10",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<h4 className="font-semibold text-base sm:text-lg">{title}</h4>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1">{description}</p>
|
||||
|
||||
272
dashboard/src/routes/config/prompts.tsx
Normal file
272
dashboard/src/routes/config/prompts.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { FileText, Loader2, RefreshCw, Save, Search } from 'lucide-react'
|
||||
|
||||
import { CodeEditor } from '@/components/CodeEditor'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import {
|
||||
getPromptCatalog,
|
||||
getPromptFile,
|
||||
updatePromptFile,
|
||||
type PromptCatalog,
|
||||
type PromptFileInfo,
|
||||
} from '@/lib/prompt-api'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function formatFileSize(size: number) {
|
||||
if (size < 1024) return `${size} B`
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
|
||||
return `${(size / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function PromptManagementPage() {
|
||||
const { toast } = useToast()
|
||||
const [catalog, setCatalog] = useState<PromptCatalog | null>(null)
|
||||
const [language, setLanguage] = useState('zh-CN')
|
||||
const [filename, setFilename] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [savedContent, setSavedContent] = useState('')
|
||||
const [loadingCatalog, setLoadingCatalog] = useState(true)
|
||||
const [loadingFile, setLoadingFile] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const hasUnsavedChanges = content !== savedContent
|
||||
|
||||
const promptFiles = useMemo<PromptFileInfo[]>(() => {
|
||||
if (!catalog || !language) return []
|
||||
return catalog.files[language] ?? []
|
||||
}, [catalog, language])
|
||||
|
||||
const filteredFiles = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
if (!normalizedQuery) return promptFiles
|
||||
return promptFiles.filter((file) => file.name.toLowerCase().includes(normalizedQuery))
|
||||
}, [promptFiles, query])
|
||||
|
||||
const selectedFile = promptFiles.find((file) => file.name === filename)
|
||||
|
||||
const loadCatalog = useCallback(async () => {
|
||||
try {
|
||||
setLoadingCatalog(true)
|
||||
const result = await getPromptCatalog()
|
||||
if (!result.success) {
|
||||
toast({ title: '加载 Prompt 目录失败', description: result.error, variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
setCatalog(result.data)
|
||||
const nextLanguage = language && result.data.languages.includes(language)
|
||||
? language
|
||||
: result.data.languages.includes('zh-CN')
|
||||
? 'zh-CN'
|
||||
: result.data.languages[0] ?? ''
|
||||
setLanguage(nextLanguage)
|
||||
|
||||
const nextFiles = nextLanguage ? result.data.files[nextLanguage] ?? [] : []
|
||||
setFilename((current) => nextFiles.some((file) => file.name === current) ? current : nextFiles[0]?.name ?? '')
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '加载 Prompt 目录失败',
|
||||
description: (error as Error).message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoadingCatalog(false)
|
||||
}
|
||||
}, [language, toast])
|
||||
|
||||
useEffect(() => {
|
||||
void loadCatalog()
|
||||
}, [loadCatalog])
|
||||
|
||||
useEffect(() => {
|
||||
if (!language || !filename) {
|
||||
setContent('')
|
||||
setSavedContent('')
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const loadFile = async () => {
|
||||
try {
|
||||
setLoadingFile(true)
|
||||
const result = await getPromptFile(language, filename)
|
||||
if (cancelled) return
|
||||
if (!result.success) {
|
||||
toast({ title: '读取 Prompt 失败', description: result.error, variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
setContent(result.data.content)
|
||||
setSavedContent(result.data.content)
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
toast({
|
||||
title: '读取 Prompt 失败',
|
||||
description: (error as Error).message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoadingFile(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadFile()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [filename, language, toast])
|
||||
|
||||
const handleLanguageChange = (nextLanguage: string) => {
|
||||
setLanguage(nextLanguage)
|
||||
setQuery('')
|
||||
const nextFiles = catalog?.files[nextLanguage] ?? []
|
||||
setFilename(nextFiles[0]?.name ?? '')
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!language || !filename) return
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
const result = await updatePromptFile(language, filename, content)
|
||||
if (!result.success) {
|
||||
toast({ title: '保存 Prompt 失败', description: result.error, variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
setContent(result.data.content)
|
||||
setSavedContent(result.data.content)
|
||||
toast({ title: 'Prompt 已保存', description: `${language}/${filename}` })
|
||||
void loadCatalog()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '保存 Prompt 失败',
|
||||
description: (error as Error).message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-140px)] flex-col gap-4 p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold sm:text-2xl md:text-3xl">Prompt 管理</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">编辑 prompts 目录下不同语言的系统提示词模板</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={language} onValueChange={handleLanguageChange} disabled={loadingCatalog}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="选择语言" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(catalog?.languages ?? []).map((item) => (
|
||||
<SelectItem key={item} value={item}>{item}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" onClick={() => void loadCatalog()} disabled={loadingCatalog}>
|
||||
<RefreshCw className={cn('mr-2 h-4 w-4', loadingCatalog && 'animate-spin')} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!hasUnsavedChanges || saving || loadingFile || !filename}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{saving ? '保存中' : hasUnsavedChanges ? '保存' : '已保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
||||
<Card className="min-h-0 overflow-hidden">
|
||||
<CardHeader className="space-y-3 pb-3">
|
||||
<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>
|
||||
</CardTitle>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索文件"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-1 p-2">
|
||||
{loadingCatalog ? (
|
||||
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载中
|
||||
</div>
|
||||
) : filteredFiles.length > 0 ? (
|
||||
filteredFiles.map((file) => (
|
||||
<button
|
||||
key={file.name}
|
||||
type="button"
|
||||
onClick={() => setFilename(file.name)}
|
||||
className={cn(
|
||||
'w-full rounded-md px-3 py-2 text-left text-sm transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
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>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">没有可编辑的 Prompt 文件</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{language}
|
||||
{selectedFile ? ` · ${formatFileSize(selectedFile.size)}` : ''}
|
||||
{hasUnsavedChanges ? ' · 有未保存修改' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-0 p-0">
|
||||
{loadingFile ? (
|
||||
<div className="flex h-[calc(100vh-290px)] items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
读取中
|
||||
</div>
|
||||
) : (
|
||||
<CodeEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
language="text"
|
||||
height="calc(100vh - 290px)"
|
||||
minHeight="520px"
|
||||
placeholder="选择一个 Prompt 文件后开始编辑"
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from 'recharts'
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
DollarSign,
|
||||
Clock,
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
AlertCircle,
|
||||
ClipboardList,
|
||||
ClipboardCheck,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -52,6 +54,7 @@ import { Link } from '@tanstack/react-router'
|
||||
import { RestartProvider, useRestart } from '@/lib/restart-context'
|
||||
import { RestartOverlay } from '@/components/restart-overlay'
|
||||
import { ExpressionReviewer } from '@/components/expression-reviewer'
|
||||
import { getBotConfig, getModelConfig } from '@/lib/config-api'
|
||||
import { getReviewStats } from '@/lib/expression-api'
|
||||
import { ZoomableChart } from '@/components/ui/zoomable-chart'
|
||||
|
||||
@@ -117,6 +120,11 @@ interface DashboardData {
|
||||
recent_activity: RecentActivity[]
|
||||
}
|
||||
|
||||
interface FeatureStatus {
|
||||
memoryEnabled: boolean
|
||||
visualEnabled: boolean
|
||||
}
|
||||
|
||||
// 为饼图生成更丰富的颜色方案 (HSL色相均匀分布)
|
||||
const generatePieColors = (count: number): string[] => {
|
||||
const colors: string[] = []
|
||||
@@ -129,6 +137,19 @@ const generatePieColors = (count: number): string[] => {
|
||||
}
|
||||
|
||||
// 内部实现组件
|
||||
function FeatureStatusLight({ enabled, label }: { enabled: boolean; label: string }) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5 rounded-md border bg-background px-2 py-1 text-xs text-muted-foreground">
|
||||
<span
|
||||
className={`h-2.5 w-2.5 rounded-full ${
|
||||
enabled ? 'bg-green-500 shadow-[0_0_0_3px_rgba(34,197,94,0.18)]' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IndexPageContent() {
|
||||
const { t } = useTranslation()
|
||||
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null)
|
||||
@@ -139,6 +160,10 @@ function IndexPageContent() {
|
||||
const [hitokoto, setHitokoto] = useState<{ hitokoto: string; from: string } | null>(null)
|
||||
const [hitokotoLoading, setHitokotoLoading] = useState(true)
|
||||
const [botStatus, setBotStatus] = useState<BotStatus | null>(null)
|
||||
const [featureStatus, setFeatureStatus] = useState<FeatureStatus>({
|
||||
memoryEnabled: false,
|
||||
visualEnabled: false,
|
||||
})
|
||||
const [isReviewerOpen, setIsReviewerOpen] = useState(false)
|
||||
const [uncheckedCount, setUncheckedCount] = useState(0)
|
||||
const { triggerRestart, isRestarting } = useRestart()
|
||||
@@ -219,6 +244,44 @@ function IndexPageContent() {
|
||||
}, [])
|
||||
|
||||
// 重启机器人
|
||||
const fetchFeatureStatus = useCallback(async () => {
|
||||
try {
|
||||
const [botConfigResult, modelConfigResult] = await Promise.all([
|
||||
getBotConfig(),
|
||||
getModelConfig(),
|
||||
])
|
||||
|
||||
if (!isMountedRef.current || !botConfigResult.success) return
|
||||
|
||||
const botPayload = botConfigResult.data as { config?: Record<string, unknown> } & Record<string, unknown>
|
||||
const botConfig = (botPayload.config ?? botPayload) as Record<string, unknown>
|
||||
const memorixConfig = (botConfig.a_memorix ?? {}) as Record<string, unknown>
|
||||
const memorixPlugin = (memorixConfig.plugin ?? {}) as Record<string, unknown>
|
||||
|
||||
const modelPayload = modelConfigResult.success
|
||||
? (modelConfigResult.data as { config?: Record<string, unknown> } & Record<string, unknown>)
|
||||
: {}
|
||||
const modelConfig = (modelPayload.config ?? modelPayload) as Record<string, unknown>
|
||||
const taskConfig = (modelConfig.model_task_config ?? {}) as Record<string, unknown>
|
||||
const vlmTask = (taskConfig.vlm ?? {}) as Record<string, unknown>
|
||||
const vlmModelList = Array.isArray(vlmTask.model_list) ? vlmTask.model_list : []
|
||||
const hasVlmModel = vlmModelList.some((modelName) => String(modelName ?? '').trim().length > 0)
|
||||
|
||||
setFeatureStatus({
|
||||
memoryEnabled: memorixPlugin.enabled === true,
|
||||
visualEnabled: hasVlmModel,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('获取功能启用状态失败:', error)
|
||||
if (isMountedRef.current) {
|
||||
setFeatureStatus({
|
||||
memoryEnabled: false,
|
||||
visualEnabled: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRestart = async () => {
|
||||
await triggerRestart()
|
||||
}
|
||||
@@ -278,8 +341,9 @@ function IndexPageContent() {
|
||||
fetchDashboardData()
|
||||
fetchHitokoto()
|
||||
fetchBotStatus()
|
||||
fetchFeatureStatus()
|
||||
fetchReviewStats()
|
||||
}, [fetchDashboardData, fetchHitokoto, fetchBotStatus, fetchReviewStats])
|
||||
}, [fetchDashboardData, fetchHitokoto, fetchBotStatus, fetchFeatureStatus, fetchReviewStats])
|
||||
|
||||
// 自动刷新
|
||||
useEffect(() => {
|
||||
@@ -295,6 +359,7 @@ function IndexPageContent() {
|
||||
if (isMountedRef.current) {
|
||||
fetchDashboardData()
|
||||
fetchBotStatus()
|
||||
fetchFeatureStatus()
|
||||
}
|
||||
}, 30000) // 30秒刷新一次
|
||||
|
||||
@@ -304,7 +369,7 @@ function IndexPageContent() {
|
||||
refreshIntervalRef.current = null
|
||||
}
|
||||
}
|
||||
}, [autoRefresh, fetchDashboardData, fetchBotStatus])
|
||||
}, [autoRefresh, fetchDashboardData, fetchBotStatus, fetchFeatureStatus])
|
||||
|
||||
if (loading || !dashboardData) {
|
||||
return (
|
||||
@@ -483,33 +548,41 @@ function IndexPageContent() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{botStatus?.running ? (
|
||||
<>
|
||||
<div className="h-3 w-3 rounded-full bg-green-500 animate-pulse" />
|
||||
<Badge variant="outline" className="text-green-600 border-green-300 bg-green-50">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
{t('home.botStatus.running')}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{botStatus?.running ? (
|
||||
<>
|
||||
<div className="h-3 w-3 rounded-full bg-green-500 animate-pulse" />
|
||||
<Badge variant="outline" className="text-green-600 border-green-300 bg-green-50">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
{t('home.botStatus.running')}
|
||||
</Badge>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-3 w-3 rounded-full bg-red-500" />
|
||||
<Badge variant="outline" className="text-red-600 border-red-300 bg-red-50">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
{t('home.botStatus.stopped')}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{botStatus && (
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="secondary" className="border border-primary/20 bg-primary/10 px-2 py-0.5 font-semibold text-primary">
|
||||
v{botStatus.version}
|
||||
</Badge>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-3 w-3 rounded-full bg-red-500" />
|
||||
<Badge variant="outline" className="text-red-600 border-red-300 bg-red-50">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
{t('home.botStatus.stopped')}
|
||||
</Badge>
|
||||
</>
|
||||
<span className="mx-2">|</span>
|
||||
<span>{t('home.botStatus.uptime', { time: formatTime(botStatus.uptime) })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{botStatus && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span>v{botStatus.version}</span>
|
||||
<span className="mx-2">|</span>
|
||||
<span>{t('home.botStatus.uptime', { time: formatTime(botStatus.uptime) })}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<FeatureStatusLight enabled={featureStatus.visualEnabled} label="启用视觉" />
|
||||
<FeatureStatusLight enabled={featureStatus.memoryEnabled} label="启用记忆" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -566,6 +639,13 @@ function IndexPageContent() {
|
||||
{t('home.quickActions.systemSettings')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild className="gap-2">
|
||||
<a href="/maibot_statistics.html" target="_blank" rel="noopener noreferrer">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
详细统计数据
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
657
dashboard/src/routes/mcp-settings.tsx
Normal file
657
dashboard/src/routes/mcp-settings.tsx
Normal file
@@ -0,0 +1,657 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { KeyValueEditor } from '@/components/ui/key-value-editor'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { DynamicConfigForm } from '@/components/dynamic-form'
|
||||
import { RestartOverlay } from '@/components/restart-overlay'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { getBotConfig, getBotConfigSchema, updateBotConfigSection } from '@/lib/config-api'
|
||||
import { fieldHooks } from '@/lib/field-hooks'
|
||||
import { RestartProvider, useRestart } from '@/lib/restart-context'
|
||||
import type { ConfigSchema } from '@/types/config-schema'
|
||||
import { Copy, Info, Plus, Power, Save, Server, Trash2 } from 'lucide-react'
|
||||
|
||||
import { MCPRootItemsHook } from './config/bot/hooks'
|
||||
|
||||
type ConfigSectionData = Record<string, unknown>
|
||||
type MCPTransport = 'stdio' | 'streamable_http'
|
||||
|
||||
interface MCPAuthorization {
|
||||
mode: 'none' | 'bearer'
|
||||
bearer_token: string
|
||||
}
|
||||
|
||||
interface MCPServerConfig {
|
||||
name: string
|
||||
enabled: boolean
|
||||
transport: MCPTransport
|
||||
command: string
|
||||
args: string[]
|
||||
env: Record<string, string>
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
http_timeout_seconds: number
|
||||
read_timeout_seconds: number
|
||||
authorization: MCPAuthorization
|
||||
}
|
||||
|
||||
const DEFAULT_MCP_SERVER: MCPServerConfig = {
|
||||
name: '',
|
||||
enabled: true,
|
||||
transport: 'stdio',
|
||||
command: '',
|
||||
args: [],
|
||||
env: {},
|
||||
url: '',
|
||||
headers: {},
|
||||
http_timeout_seconds: 30,
|
||||
read_timeout_seconds: 300,
|
||||
authorization: {
|
||||
mode: 'none',
|
||||
bearer_token: '',
|
||||
},
|
||||
}
|
||||
|
||||
function asStringMap(value: unknown): Record<string, string> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, itemValue]) => [
|
||||
key,
|
||||
String(itemValue ?? ''),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeMCPServer(value: unknown, index: number): MCPServerConfig {
|
||||
const source =
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
const auth =
|
||||
source.authorization &&
|
||||
typeof source.authorization === 'object' &&
|
||||
!Array.isArray(source.authorization)
|
||||
? (source.authorization as Record<string, unknown>)
|
||||
: {}
|
||||
const transport = source.transport === 'streamable_http' ? 'streamable_http' : 'stdio'
|
||||
|
||||
return {
|
||||
...DEFAULT_MCP_SERVER,
|
||||
name: typeof source.name === 'string' ? source.name : `mcp-server-${index + 1}`,
|
||||
enabled: typeof source.enabled === 'boolean' ? source.enabled : DEFAULT_MCP_SERVER.enabled,
|
||||
transport,
|
||||
command: typeof source.command === 'string' ? source.command : '',
|
||||
args: Array.isArray(source.args) ? source.args.map((item) => String(item ?? '')) : [],
|
||||
env: asStringMap(source.env),
|
||||
url: typeof source.url === 'string' ? source.url : '',
|
||||
headers: asStringMap(source.headers),
|
||||
http_timeout_seconds:
|
||||
typeof source.http_timeout_seconds === 'number'
|
||||
? source.http_timeout_seconds
|
||||
: DEFAULT_MCP_SERVER.http_timeout_seconds,
|
||||
read_timeout_seconds:
|
||||
typeof source.read_timeout_seconds === 'number'
|
||||
? source.read_timeout_seconds
|
||||
: DEFAULT_MCP_SERVER.read_timeout_seconds,
|
||||
authorization: {
|
||||
mode: auth.mode === 'bearer' ? 'bearer' : 'none',
|
||||
bearer_token: typeof auth.bearer_token === 'string' ? auth.bearer_token : '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMCPServers(value: unknown): MCPServerConfig[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return value.map((item, index) => normalizeMCPServer(item, index))
|
||||
}
|
||||
|
||||
function updateNestedValue(
|
||||
target: ConfigSectionData | null | undefined,
|
||||
pathSegments: string[],
|
||||
value: unknown
|
||||
): ConfigSectionData {
|
||||
const currentTarget = target && typeof target === 'object' && !Array.isArray(target) ? target : {}
|
||||
const [currentPath, ...restPath] = pathSegments
|
||||
|
||||
if (!currentPath) {
|
||||
return currentTarget
|
||||
}
|
||||
|
||||
if (restPath.length === 0) {
|
||||
return {
|
||||
...currentTarget,
|
||||
[currentPath]: value,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...currentTarget,
|
||||
[currentPath]: updateNestedValue(currentTarget[currentPath] as ConfigSectionData | undefined, restPath, value),
|
||||
}
|
||||
}
|
||||
|
||||
function MCPServersBlockEditor({
|
||||
servers,
|
||||
onChange,
|
||||
}: {
|
||||
servers: MCPServerConfig[]
|
||||
onChange: (servers: MCPServerConfig[]) => void
|
||||
}) {
|
||||
const updateServer = (index: number, patch: Partial<MCPServerConfig>) => {
|
||||
onChange(servers.map((server, serverIndex) => (
|
||||
serverIndex === index ? { ...server, ...patch } : server
|
||||
)))
|
||||
}
|
||||
|
||||
const updateAuthorization = (index: number, patch: Partial<MCPAuthorization>) => {
|
||||
const server = servers[index]
|
||||
if (!server) {
|
||||
return
|
||||
}
|
||||
updateServer(index, {
|
||||
authorization: {
|
||||
...server.authorization,
|
||||
...patch,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const addServer = () => {
|
||||
onChange([
|
||||
...servers,
|
||||
{
|
||||
...DEFAULT_MCP_SERVER,
|
||||
name: `mcp-server-${servers.length + 1}`,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const duplicateServer = (index: number) => {
|
||||
const server = servers[index]
|
||||
if (!server) {
|
||||
return
|
||||
}
|
||||
const nextServer = {
|
||||
...server,
|
||||
name: `${server.name || 'mcp-server'}-copy`,
|
||||
args: [...server.args],
|
||||
env: { ...server.env },
|
||||
headers: { ...server.headers },
|
||||
authorization: { ...server.authorization },
|
||||
}
|
||||
onChange([
|
||||
...servers.slice(0, index + 1),
|
||||
nextServer,
|
||||
...servers.slice(index + 1),
|
||||
])
|
||||
}
|
||||
|
||||
const removeServer = (index: number) => {
|
||||
onChange(servers.filter((_, serverIndex) => serverIndex !== index))
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-lg">MCP 服务</CardTitle>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{servers.length} 个
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription>
|
||||
这里会写入 mcp.servers。stdio 用命令启动本地服务,streamable_http 连接远程 MCP 端点。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={addServer}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
添加服务
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{servers.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
尚未配置 MCP 服务。添加一个服务后,MaiSaka 可以调用它暴露的工具。
|
||||
</div>
|
||||
) : (
|
||||
servers.map((server, index) => (
|
||||
<Card key={`${server.name}-${index}`} className="border-border/70 bg-muted/20 shadow-none">
|
||||
<CardHeader className="space-y-3 px-4 py-3">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Switch
|
||||
checked={server.enabled}
|
||||
onCheckedChange={(enabled) => updateServer(index, { enabled })}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Input
|
||||
value={server.name}
|
||||
onChange={(event) => updateServer(index, { name: event.target.value })}
|
||||
placeholder="服务名称,必须唯一"
|
||||
className="h-8 font-medium"
|
||||
/>
|
||||
</div>
|
||||
<Badge variant={server.enabled ? 'default' : 'secondary'} className="shrink-0 text-[10px]">
|
||||
{server.enabled ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => duplicateServer(index)}
|
||||
title="复制服务"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => removeServer(index)}
|
||||
title="删除服务"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 px-4 pb-4 pt-0">
|
||||
<div className="grid gap-3 md:grid-cols-[12rem_1fr]">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">传输方式</label>
|
||||
<Select
|
||||
value={server.transport}
|
||||
onValueChange={(transport) => updateServer(index, { transport: transport as MCPTransport })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stdio">stdio</SelectItem>
|
||||
<SelectItem value="streamable_http">streamable_http</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{server.transport === 'stdio' ? (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">启动命令</label>
|
||||
<Input
|
||||
value={server.command}
|
||||
onChange={(event) => updateServer(index, { command: event.target.value })}
|
||||
placeholder="例如 uvx、npx、python"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">服务 URL</label>
|
||||
<Input
|
||||
value={server.url}
|
||||
onChange={(event) => updateServer(index, { url: event.target.value })}
|
||||
placeholder="https://example.com/mcp"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{server.transport === 'stdio' ? (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">命令参数</label>
|
||||
<Textarea
|
||||
value={server.args.join('\n')}
|
||||
onChange={(event) => updateServer(index, {
|
||||
args: event.target.value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0),
|
||||
})}
|
||||
rows={4}
|
||||
placeholder="每行一个参数"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">环境变量</label>
|
||||
<KeyValueEditor
|
||||
value={server.env}
|
||||
onChange={(env) => updateServer(index, { env: asStringMap(env) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">认证模式</label>
|
||||
<Select
|
||||
value={server.authorization.mode}
|
||||
onValueChange={(mode) => updateAuthorization(index, { mode: mode as MCPAuthorization['mode'] })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">none</SelectItem>
|
||||
<SelectItem value="bearer">bearer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{server.authorization.mode === 'bearer' && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Bearer Token</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={server.authorization.bearer_token}
|
||||
onChange={(event) => updateAuthorization(index, { bearer_token: event.target.value })}
|
||||
placeholder="HTTP Bearer Token"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">请求 Headers</label>
|
||||
<KeyValueEditor
|
||||
value={server.headers}
|
||||
onChange={(headers) => updateServer(index, { headers: asStringMap(headers) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">HTTP 请求超时(秒)</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0.1}
|
||||
step={0.1}
|
||||
value={server.http_timeout_seconds}
|
||||
onChange={(event) => updateServer(index, {
|
||||
http_timeout_seconds: Number.parseFloat(event.target.value) || 0.1,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">会话读取超时(秒)</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0.1}
|
||||
step={0.1}
|
||||
value={server.read_timeout_seconds}
|
||||
onChange={(event) => updateServer(index, {
|
||||
read_timeout_seconds: Number.parseFloat(event.target.value) || 0.1,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function MCPSettingsPage() {
|
||||
return (
|
||||
<RestartProvider>
|
||||
<MCPSettingsPageContent />
|
||||
</RestartProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function MCPSettingsPageContent() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
|
||||
const [mcpConfig, setMcpConfig] = useState<ConfigSectionData>({})
|
||||
const [mcpSchema, setMcpSchema] = useState<ConfigSchema | null>(null)
|
||||
const { toast } = useToast()
|
||||
const { triggerRestart, isRestarting } = useRestart()
|
||||
|
||||
useEffect(() => {
|
||||
const hookEntries = [
|
||||
['mcp.client.roots.items', MCPRootItemsHook],
|
||||
] as const
|
||||
|
||||
for (const [fieldPath, hookComponent] of hookEntries) {
|
||||
fieldHooks.register(fieldPath, hookComponent, 'replace')
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const [fieldPath] of hookEntries) {
|
||||
fieldHooks.unregister(fieldPath)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const [configResult, schemaResult] = await Promise.all([getBotConfig(), getBotConfigSchema()])
|
||||
|
||||
if (!configResult.success) {
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: configResult.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!schemaResult.success) {
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: schemaResult.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const configPayload = configResult.data as { config?: Record<string, unknown> } & Record<string, unknown>
|
||||
const fullConfig = (configPayload.config ?? configPayload) as Record<string, unknown>
|
||||
const schemaPayload = schemaResult.data as { schema?: ConfigSchema } & ConfigSchema
|
||||
const fullSchema = (schemaPayload.schema ?? schemaPayload) as ConfigSchema
|
||||
|
||||
setMcpConfig((fullConfig.mcp ?? {}) as ConfigSectionData)
|
||||
setMcpSchema(fullSchema.nested?.mcp ?? null)
|
||||
setHasUnsavedChanges(false)
|
||||
} catch (error) {
|
||||
console.error('加载 MCP 设置失败:', error)
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: (error as Error).message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
useEffect(() => {
|
||||
void loadConfig()
|
||||
}, [loadConfig])
|
||||
|
||||
const saveConfig = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
setSaving(true)
|
||||
const result = await updateBotConfigSection('mcp', mcpConfig)
|
||||
|
||||
if (!result.success) {
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false)
|
||||
toast({
|
||||
title: '保存成功',
|
||||
description: 'MCP 设置已保存,重启后生效。',
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('保存 MCP 设置失败:', error)
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: (error as Error).message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return false
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [mcpConfig, toast])
|
||||
|
||||
const saveAndRestart = useCallback(async () => {
|
||||
const saved = await saveConfig()
|
||||
if (!saved) {
|
||||
return
|
||||
}
|
||||
await triggerRestart({ delay: 500 })
|
||||
}, [saveConfig, triggerRestart])
|
||||
|
||||
const formSchema: ConfigSchema | null = mcpSchema
|
||||
? {
|
||||
className: 'MCPSettings',
|
||||
classDoc: 'MCP 设置',
|
||||
fields: [],
|
||||
nested: {
|
||||
mcp: {
|
||||
...mcpSchema,
|
||||
fields: mcpSchema.fields.filter((field) => field.name !== 'servers'),
|
||||
nested: mcpSchema.nested
|
||||
? Object.fromEntries(
|
||||
Object.entries(mcpSchema.nested).filter(([key]) => key !== 'servers'),
|
||||
)
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
}
|
||||
: null
|
||||
const mcpServers = normalizeMCPServers(mcpConfig.servers)
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold">MCP 设置</h1>
|
||||
<p className="text-muted-foreground mt-1 text-xs sm:text-sm">
|
||||
管理 MCP 客户端能力与服务器连接配置
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={saveConfig}
|
||||
disabled={loading || saving || !hasUnsavedChanges || isRestarting}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-24"
|
||||
>
|
||||
<Save className="h-4 w-4" strokeWidth={2} fill="none" />
|
||||
<span className="ml-1 text-xs sm:text-sm">{saving ? '保存中' : hasUnsavedChanges ? '保存' : '已保存'}</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={saveAndRestart}
|
||||
disabled={loading || saving || isRestarting}
|
||||
size="sm"
|
||||
className="w-28"
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
<span className="ml-1 text-xs sm:text-sm">{isRestarting ? '重启中' : '保存重启'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
MCP 设置保存后需要重启麦麦才会生效。这里与主程序配置中的 MCP 栏目使用同一份配置。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{loading && (
|
||||
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">
|
||||
加载中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<MCPServersBlockEditor
|
||||
servers={mcpServers}
|
||||
onChange={(servers) => {
|
||||
setMcpConfig((currentConfig) => ({
|
||||
...currentConfig,
|
||||
servers,
|
||||
}))
|
||||
setHasUnsavedChanges(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && formSchema && (
|
||||
<DynamicConfigForm
|
||||
schema={formSchema}
|
||||
values={{ mcp: mcpConfig }}
|
||||
onChange={(fieldPath, value) => {
|
||||
const [, ...restPath] = fieldPath.split('.')
|
||||
const nextConfig = restPath.length === 0
|
||||
? (value as ConfigSectionData)
|
||||
: updateNestedValue(mcpConfig, restPath, value)
|
||||
|
||||
setMcpConfig(nextConfig)
|
||||
setHasUnsavedChanges(true)
|
||||
}}
|
||||
hooks={fieldHooks}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !formSchema && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>当前配置 schema 中没有找到 MCP 设置。</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<RestartOverlay />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
CircleDot,
|
||||
Clock,
|
||||
Eraser,
|
||||
ExternalLink,
|
||||
Gauge,
|
||||
MessageSquare,
|
||||
PauseCircle,
|
||||
Radio,
|
||||
Timer,
|
||||
Wrench,
|
||||
XCircle,
|
||||
@@ -36,6 +38,7 @@ import type {
|
||||
CycleStartEvent,
|
||||
MaisakaToolCall,
|
||||
MessageIngestedEvent,
|
||||
PlannerFinalizedEvent,
|
||||
PlannerResponseEvent,
|
||||
ReplierResponseEvent,
|
||||
TimingGateResultEvent,
|
||||
@@ -51,6 +54,10 @@ function formatMs(ms: number): string {
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
function buildCycleKey(sessionId: string, cycleId: number) {
|
||||
return `${sessionId}:${cycleId}`
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
@@ -73,18 +80,28 @@ function SessionSidebar({
|
||||
sessions,
|
||||
selectedSession,
|
||||
onSelect,
|
||||
collapsed,
|
||||
}: {
|
||||
sessions: Map<string, SessionInfo>
|
||||
selectedSession: string | null
|
||||
onSelect: (id: string) => void
|
||||
collapsed: boolean
|
||||
}) {
|
||||
const sortedSessions = Array.from(sessions.values()).sort(
|
||||
(a, b) => b.lastActivity - a.lastActivity,
|
||||
)
|
||||
const getSessionInitial = (session: SessionInfo) => {
|
||||
const name = session.sessionName.trim()
|
||||
if (name) return name.slice(0, 1)
|
||||
return session.isGroupChat ? '群' : '私'
|
||||
}
|
||||
|
||||
if (sortedSessions.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2 p-4">
|
||||
<div className={cn(
|
||||
'flex flex-col items-center justify-center h-full text-muted-foreground gap-2',
|
||||
collapsed ? 'p-2' : 'p-4',
|
||||
)}>
|
||||
<Bot className="h-8 w-8 opacity-40" />
|
||||
<p className="text-sm text-center">等待 MaiSaka 会话…</p>
|
||||
</div>
|
||||
@@ -92,28 +109,42 @@ function SessionSidebar({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 p-2">
|
||||
<div className={cn('flex flex-col gap-1', collapsed ? 'items-center p-2' : 'p-2')}>
|
||||
{sortedSessions.map((session) => (
|
||||
<button
|
||||
key={session.sessionId}
|
||||
onClick={() => onSelect(session.sessionId)}
|
||||
title={session.sessionName}
|
||||
className={cn(
|
||||
'flex flex-col items-start gap-0.5 rounded-lg px-3 py-2 text-left text-sm transition-colors',
|
||||
'rounded-lg text-left text-sm transition-colors',
|
||||
'hover:bg-accent/50',
|
||||
collapsed
|
||||
? 'flex h-10 w-10 items-center justify-center p-0'
|
||||
: 'flex w-full flex-col items-start gap-0.5 px-2.5 py-2',
|
||||
selectedSession === session.sessionId && 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="font-medium truncate max-w-35">
|
||||
{session.sessionName}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1">
|
||||
<div className={cn('flex w-full items-center', collapsed ? 'justify-center' : 'justify-between gap-2')}>
|
||||
<div className={cn('flex min-w-0 items-center gap-2', !collapsed && 'flex-1')}>
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-xs font-semibold text-primary">
|
||||
{getSessionInitial(session)}
|
||||
</span>
|
||||
{false && session.isGroupChat !== undefined && (
|
||||
<Badge variant="outline" className="h-4 shrink-0 px-1 text-[10px]">
|
||||
{session.isGroupChat ? '群' : '私'}
|
||||
</Badge>
|
||||
)}
|
||||
{!collapsed && <span className="min-w-0 flex-1 truncate font-medium" title={session.sessionName}>
|
||||
{session.sessionName}
|
||||
</span>}
|
||||
</div>
|
||||
{!collapsed && <Badge variant="secondary" className="h-4 shrink-0 px-1 text-[10px]">
|
||||
{session.eventCount}
|
||||
</Badge>
|
||||
</Badge>}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{!collapsed && <span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(session.lastActivity)}
|
||||
</span>
|
||||
</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -176,7 +207,8 @@ function TimingGateCard({ data }: { data: TimingGateResultEvent }) {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="text-sm font-medium">Timing Gate</span>
|
||||
<span className="text-sm font-medium">反应</span>
|
||||
<Badge variant="outline" className="text-[10px]">react</Badge>
|
||||
<Badge variant={config.variant} className="text-[10px] gap-0.5">
|
||||
<Icon className="h-2.5 w-2.5" />
|
||||
{config.label}
|
||||
@@ -191,6 +223,29 @@ function TimingGateCard({ data }: { data: TimingGateResultEvent }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ToolCallBadges({ toolCalls }: { toolCalls: MaisakaToolCall[] }) {
|
||||
if (toolCalls.length <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{toolCalls.map((tc: MaisakaToolCall, idx: number) => (
|
||||
<Badge key={`${tc.id || tc.name}-${idx}`} variant="secondary" className="text-[10px] gap-1">
|
||||
<Wrench className="h-2.5 w-2.5" />
|
||||
{tc.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function openPromptHtml(uri: string) {
|
||||
const normalized = uri.trim()
|
||||
if (!normalized) return
|
||||
window.open(normalized, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function PlannerResponseCard({ data }: { data: PlannerResponseEvent }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -208,21 +263,120 @@ function PlannerResponseCard({ data }: { data: PlannerResponseEvent }) {
|
||||
{data.content && (
|
||||
<CollapsibleText text={data.content} maxLines={6} />
|
||||
)}
|
||||
{data.tool_calls.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{data.tool_calls.map((tc: MaisakaToolCall, idx: number) => (
|
||||
<Badge key={idx} variant="secondary" className="text-[10px] gap-1">
|
||||
<Wrench className="h-2.5 w-2.5" />
|
||||
{tc.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ToolCallBadges toolCalls={data.tool_calls} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlannerFinalizedCard({ data }: { data: PlannerFinalizedEvent }) {
|
||||
const planner = data.planner
|
||||
const promptHtmlUri = planner?.prompt_html_uri?.trim() ?? ''
|
||||
|
||||
return (
|
||||
<Card className="border-l-4 border-l-emerald-500/60">
|
||||
<CardHeader className="py-3 px-4 space-y-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Brain className="h-4 w-4 text-emerald-500" />
|
||||
<CardTitle className="text-sm font-medium">主循环 planner</CardTitle>
|
||||
{promptHtmlUri && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
onClick={() => openPromptHtml(promptHtmlUri)}
|
||||
title="打开 planner HTML 记录"
|
||||
>
|
||||
<ExternalLink className="mr-1 h-3 w-3" />
|
||||
HTML
|
||||
</Button>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs font-normal ml-auto">
|
||||
{formatMs(planner?.duration_ms ?? 0)}
|
||||
</Badge>
|
||||
{data.request && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
上下文 {data.request.selected_history_count} 条 / 可用工具 {data.request.tool_count}
|
||||
</Badge>
|
||||
)}
|
||||
{planner && (planner.prompt_tokens > 0 || planner.completion_tokens > 0) && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{planner.prompt_tokens}+{planner.completion_tokens} tokens
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{planner?.content ? (
|
||||
<CollapsibleText text={planner.content} maxLines={6} className="text-foreground/90" />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">planner 本轮没有文本内容</p>
|
||||
)}
|
||||
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function PlannerToolCallsBlock({ data }: { data: PlannerFinalizedEvent }) {
|
||||
const toolCalls = data.planner?.tool_calls ?? []
|
||||
const tools = data.tools ?? []
|
||||
const displayTools = tools.length > 0
|
||||
? tools
|
||||
: toolCalls.map((toolCall) => ({
|
||||
tool_call_id: toolCall.id,
|
||||
tool_name: toolCall.name,
|
||||
tool_args: toolCall.arguments ?? {},
|
||||
success: true,
|
||||
duration_ms: 0,
|
||||
summary: '',
|
||||
}))
|
||||
|
||||
if (displayTools.length <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-l-4 border-l-teal-500/60">
|
||||
<CardHeader className="py-3 px-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wrench className="h-4 w-4 text-teal-500" />
|
||||
<CardTitle className="text-sm font-medium">Planner 工具调用</CardTitle>
|
||||
<Badge variant="secondary" className="ml-auto text-[10px]">
|
||||
{displayTools.length} 个
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{displayTools.map((tool, idx) => (
|
||||
<div
|
||||
key={`${tool.tool_call_id || tool.tool_name}-${idx}`}
|
||||
className="rounded-md border bg-muted/40 px-2.5 py-2 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono font-medium">{tool.tool_name || 'unknown'}</span>
|
||||
{tool.success
|
||||
? <CheckCircle2 className="h-3.5 w-3.5 text-teal-500" />
|
||||
: <XCircle className="h-3.5 w-3.5 text-red-500" />
|
||||
}
|
||||
{tool.duration_ms > 0 && (
|
||||
<span className="text-muted-foreground">{formatMs(tool.duration_ms)}</span>
|
||||
)}
|
||||
</div>
|
||||
{Object.keys(tool.tool_args ?? {}).length > 0 && (
|
||||
<pre className="mt-1 whitespace-pre-wrap break-all rounded bg-background/70 px-2 py-1 text-[11px] text-muted-foreground">
|
||||
{JSON.stringify(tool.tool_args, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{tool.summary && (
|
||||
<p className="mt-1 text-muted-foreground whitespace-pre-wrap break-words">{tool.summary}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolExecutionCard({ data }: { data: ToolExecutionEvent }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -387,16 +541,30 @@ function ReplierResponseCard({ data }: { data: ReplierResponseEvent }) {
|
||||
|
||||
// ─── 时间线入口渲染器 ──────────────────────────────────────────
|
||||
|
||||
function TimelineEventRenderer({ entry }: { entry: TimelineEntry }) {
|
||||
function TimelineEventRenderer({
|
||||
entry,
|
||||
showCycleMarkers,
|
||||
}: {
|
||||
entry: TimelineEntry
|
||||
showCycleMarkers: boolean
|
||||
}) {
|
||||
switch (entry.type) {
|
||||
case 'message.ingested':
|
||||
return <MessageIngestedCard data={entry.data as MessageIngestedEvent} />
|
||||
case 'cycle.start':
|
||||
if (!showCycleMarkers) return null
|
||||
return <CycleStartCard data={entry.data as CycleStartEvent} />
|
||||
case 'timing_gate.result':
|
||||
return <TimingGateCard data={entry.data as TimingGateResultEvent} />
|
||||
case 'planner.response':
|
||||
return <PlannerResponseCard data={entry.data as PlannerResponseEvent} />
|
||||
case 'planner.finalized':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<PlannerFinalizedCard data={entry.data as PlannerFinalizedEvent} />
|
||||
<PlannerToolCallsBlock data={entry.data as PlannerFinalizedEvent} />
|
||||
</div>
|
||||
)
|
||||
case 'tool.execution':
|
||||
return <ToolExecutionCard data={entry.data as ToolExecutionEvent} />
|
||||
case 'cycle.end':
|
||||
@@ -418,11 +586,29 @@ export function MaisakaMonitor() {
|
||||
selectedSession,
|
||||
setSelectedSession,
|
||||
connected,
|
||||
backgroundCollection,
|
||||
setBackgroundCollectionEnabled,
|
||||
clearTimeline,
|
||||
} = useMaisakaMonitor()
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [autoScroll, setAutoScroll] = useState(true)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||
const saved = localStorage.getItem('maisaka-monitor-sidebar-collapsed')
|
||||
return saved !== 'false'
|
||||
})
|
||||
const [showCycleMarkers, setShowCycleMarkers] = useState(() => {
|
||||
const saved = localStorage.getItem('maisaka-monitor-show-cycle-markers')
|
||||
return saved === 'true'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('maisaka-monitor-sidebar-collapsed', String(sidebarCollapsed))
|
||||
}, [sidebarCollapsed])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('maisaka-monitor-show-cycle-markers', String(showCycleMarkers))
|
||||
}, [showCycleMarkers])
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
@@ -445,20 +631,43 @@ export function MaisakaMonitor() {
|
||||
const stats = {
|
||||
messages: timeline.filter((e) => e.type === 'message.ingested').length,
|
||||
cycles: timeline.filter((e) => e.type === 'cycle.start').length,
|
||||
toolCalls: timeline.filter((e) => e.type === 'tool.execution').length,
|
||||
toolCalls: timeline.reduce((count, entry) => {
|
||||
if (entry.type === 'tool.execution') {
|
||||
return count + 1
|
||||
}
|
||||
if (entry.type === 'planner.finalized') {
|
||||
return count + ((entry.data as PlannerFinalizedEvent).tools?.length ?? 0)
|
||||
}
|
||||
return count
|
||||
}, 0),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-180px)] gap-4">
|
||||
{/* 会话侧边栏 */}
|
||||
<Card className="w-60 shrink-0 flex flex-col">
|
||||
<CardHeader className="py-3 px-4 space-y-0">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
<Card className={cn(
|
||||
'shrink-0 flex flex-col transition-[width] duration-200',
|
||||
sidebarCollapsed ? 'w-16' : 'w-52',
|
||||
)}>
|
||||
<CardHeader className={cn('py-3 space-y-0', sidebarCollapsed ? 'px-2' : 'px-3')}>
|
||||
<CardTitle className={cn(
|
||||
'text-sm font-medium flex items-center gap-2',
|
||||
sidebarCollapsed && 'justify-center text-[0px]',
|
||||
)}>
|
||||
{!sidebarCollapsed && <Activity className="h-4 w-4" />}
|
||||
聊天流
|
||||
{connected && (
|
||||
<span className="ml-auto flex h-2 w-2 rounded-full bg-emerald-500" />
|
||||
<span className={cn('flex h-2 w-2 rounded-full bg-emerald-500', !sidebarCollapsed && 'ml-auto')} />
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => setSidebarCollapsed((value) => !value)}
|
||||
title={sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'}
|
||||
>
|
||||
{sidebarCollapsed ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
@@ -467,6 +676,7 @@ export function MaisakaMonitor() {
|
||||
sessions={sessions}
|
||||
selectedSession={selectedSession}
|
||||
onSelect={setSelectedSession}
|
||||
collapsed={sidebarCollapsed}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
@@ -490,6 +700,26 @@ export function MaisakaMonitor() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant={backgroundCollection ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setBackgroundCollectionEnabled(!backgroundCollection)}
|
||||
title={backgroundCollection ? '关闭离开页面后的持续获取' : '开启离开页面后的持续获取'}
|
||||
>
|
||||
<Radio className={cn('h-3.5 w-3.5 mr-1', backgroundCollection && 'text-primary')} />
|
||||
持续获取
|
||||
</Button>
|
||||
<Button
|
||||
variant={showCycleMarkers ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setShowCycleMarkers((value) => !value)}
|
||||
title={showCycleMarkers ? '隐藏推理循环标记' : '显示推理循环标记'}
|
||||
>
|
||||
<CircleDot className={cn('h-3.5 w-3.5 mr-1', showCycleMarkers && 'text-primary')} />
|
||||
循环标记
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -528,21 +758,39 @@ export function MaisakaMonitor() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
timeline.map((entry) => {
|
||||
const rendered = <TimelineEventRenderer entry={entry} />
|
||||
if (!rendered) return null
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="animate-in fade-in-0 slide-in-from-bottom-2 duration-300"
|
||||
>
|
||||
{rendered}
|
||||
{entry.type === 'cycle.end' && (
|
||||
<Separator className="mt-3" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
(() => {
|
||||
const continuedTimingGateCycles = new Set<string>()
|
||||
|
||||
return timeline.map((entry) => {
|
||||
if (entry.type === 'timing_gate.result') {
|
||||
const data = entry.data as TimingGateResultEvent
|
||||
if (data.action === 'continue') {
|
||||
continuedTimingGateCycles.add(buildCycleKey(data.session_id, data.cycle_id))
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'planner.response' || entry.type === 'planner.finalized') {
|
||||
const data = entry.data as PlannerResponseEvent | PlannerFinalizedEvent
|
||||
if (!continuedTimingGateCycles.has(buildCycleKey(data.session_id, data.cycle_id))) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const rendered = <TimelineEventRenderer entry={entry} showCycleMarkers={showCycleMarkers} />
|
||||
if (!rendered) return null
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="animate-in fade-in-0 slide-in-from-bottom-2 duration-300"
|
||||
>
|
||||
{rendered}
|
||||
{entry.type === 'cycle.end' && (
|
||||
<Separator className="mt-3" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* 管理 WebSocket 订阅与事件流的状态。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import type { MaisakaMonitorEvent } from '@/lib/maisaka-monitor-client'
|
||||
import { maisakaMonitorClient } from '@/lib/maisaka-monitor-client'
|
||||
@@ -26,105 +26,266 @@ export interface TimelineEntry {
|
||||
export interface SessionInfo {
|
||||
sessionId: string
|
||||
sessionName: string
|
||||
isGroupChat?: boolean
|
||||
groupId?: string | null
|
||||
userId?: string | null
|
||||
platform?: string
|
||||
lastActivity: number
|
||||
eventCount: number
|
||||
}
|
||||
|
||||
/** 最大保留的时间线条目数 */
|
||||
const MAX_TIMELINE_ENTRIES = 500
|
||||
const BACKGROUND_COLLECTION_STORAGE_KEY = 'maisaka-monitor-background-collection'
|
||||
|
||||
function resolveSessionDisplayName({
|
||||
fallbackName,
|
||||
groupId,
|
||||
isGroupChat,
|
||||
sessionId,
|
||||
userId,
|
||||
}: {
|
||||
fallbackName?: string
|
||||
groupId?: string | null
|
||||
isGroupChat?: boolean
|
||||
sessionId: string
|
||||
userId?: string | null
|
||||
}) {
|
||||
const targetId = isGroupChat ? groupId : userId
|
||||
const normalizedName = fallbackName?.trim()
|
||||
|
||||
if (targetId && normalizedName?.endsWith(`(${targetId})`)) {
|
||||
return normalizedName
|
||||
}
|
||||
if (normalizedName && targetId && normalizedName !== targetId && normalizedName !== sessionId) {
|
||||
return `${normalizedName}(${targetId})`
|
||||
}
|
||||
if (isGroupChat && groupId) {
|
||||
return groupId
|
||||
}
|
||||
if (!isGroupChat && userId) {
|
||||
return userId
|
||||
}
|
||||
return fallbackName || sessionId.slice(0, 8)
|
||||
}
|
||||
|
||||
let entryCounter = 0
|
||||
let cachedTimeline: TimelineEntry[] = []
|
||||
let cachedSessions: Map<string, SessionInfo> = new Map()
|
||||
let cachedSelectedSession: string | null = null
|
||||
let cachedConnected = false
|
||||
let backgroundCollectionEnabled = false
|
||||
let backgroundCollectionPreferenceLoaded = false
|
||||
let activeConsumerCount = 0
|
||||
let monitorSubscriptionStarted = false
|
||||
let monitorSubscriptionPromise: Promise<void> | null = null
|
||||
let monitorUnsubscribe: (() => Promise<void>) | null = null
|
||||
const storeListeners = new Set<() => void>()
|
||||
|
||||
export function useMaisakaMonitor() {
|
||||
const [timeline, setTimeline] = useState<TimelineEntry[]>([])
|
||||
const [sessions, setSessions] = useState<Map<string, SessionInfo>>(new Map())
|
||||
const [selectedSession, setSelectedSession] = useState<string | null>(null)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const unsubRef = useRef<(() => Promise<void>) | null>(null)
|
||||
function notifyStoreListeners() {
|
||||
storeListeners.forEach((listener) => listener())
|
||||
}
|
||||
|
||||
const handleEvent = useCallback((event: MaisakaMonitorEvent) => {
|
||||
const sessionId = (event.data as unknown as Record<string, unknown>).session_id as string
|
||||
const timestamp = (event.data as unknown as Record<string, unknown>).timestamp as number
|
||||
function loadBackgroundCollectionPreference() {
|
||||
if (backgroundCollectionPreferenceLoaded) {
|
||||
return backgroundCollectionEnabled
|
||||
}
|
||||
|
||||
const entry: TimelineEntry = {
|
||||
id: `evt_${++entryCounter}_${Date.now()}`,
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
timestamp,
|
||||
backgroundCollectionPreferenceLoaded = true
|
||||
if (typeof window !== 'undefined') {
|
||||
backgroundCollectionEnabled = window.localStorage.getItem(BACKGROUND_COLLECTION_STORAGE_KEY) === 'true'
|
||||
}
|
||||
return backgroundCollectionEnabled
|
||||
}
|
||||
|
||||
function shouldKeepMonitorActive() {
|
||||
return activeConsumerCount > 0 || backgroundCollectionEnabled
|
||||
}
|
||||
|
||||
function appendTimelineEntry(entry: TimelineEntry) {
|
||||
const next = [...cachedTimeline, entry]
|
||||
cachedTimeline = next.length > MAX_TIMELINE_ENTRIES
|
||||
? next.slice(next.length - MAX_TIMELINE_ENTRIES)
|
||||
: next
|
||||
}
|
||||
|
||||
function updateSessionInfo(event: MaisakaMonitorEvent, sessionId: string, timestamp: number) {
|
||||
const dataRecord = event.data as unknown as Record<string, unknown>
|
||||
const isGroupChat = typeof dataRecord.is_group_chat === 'boolean'
|
||||
? dataRecord.is_group_chat
|
||||
: undefined
|
||||
const groupId = typeof dataRecord.group_id === 'string' ? dataRecord.group_id : null
|
||||
const userId = typeof dataRecord.user_id === 'string' ? dataRecord.user_id : null
|
||||
const platform = typeof dataRecord.platform === 'string' ? dataRecord.platform : undefined
|
||||
const sessionName = typeof dataRecord.session_name === 'string'
|
||||
? dataRecord.session_name
|
||||
: undefined
|
||||
|
||||
const next = new Map(cachedSessions)
|
||||
const existing = next.get(sessionId)
|
||||
|
||||
if (event.type === 'session.start' || !existing) {
|
||||
next.set(sessionId, {
|
||||
sessionId,
|
||||
}
|
||||
|
||||
setTimeline((prev) => {
|
||||
const next = [...prev, entry]
|
||||
return next.length > MAX_TIMELINE_ENTRIES
|
||||
? next.slice(next.length - MAX_TIMELINE_ENTRIES)
|
||||
: next
|
||||
sessionName: resolveSessionDisplayName({
|
||||
fallbackName: sessionName,
|
||||
groupId,
|
||||
isGroupChat,
|
||||
sessionId,
|
||||
userId,
|
||||
}),
|
||||
isGroupChat,
|
||||
groupId,
|
||||
userId,
|
||||
platform,
|
||||
lastActivity: timestamp,
|
||||
eventCount: (existing?.eventCount ?? 0) + 1,
|
||||
})
|
||||
} else {
|
||||
next.set(sessionId, {
|
||||
...existing,
|
||||
sessionName: resolveSessionDisplayName({
|
||||
fallbackName: sessionName ?? existing.sessionName,
|
||||
groupId: groupId ?? existing.groupId,
|
||||
isGroupChat: isGroupChat ?? existing.isGroupChat,
|
||||
sessionId,
|
||||
userId: userId ?? existing.userId,
|
||||
}),
|
||||
isGroupChat: isGroupChat ?? existing.isGroupChat,
|
||||
groupId: groupId ?? existing.groupId,
|
||||
userId: userId ?? existing.userId,
|
||||
platform: platform ?? existing.platform,
|
||||
lastActivity: timestamp,
|
||||
eventCount: existing.eventCount + 1,
|
||||
})
|
||||
}
|
||||
|
||||
// 更新会话信息
|
||||
if (event.type === 'session.start') {
|
||||
const d = event.data
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(sessionId, {
|
||||
sessionId,
|
||||
sessionName: d.session_name,
|
||||
lastActivity: timestamp,
|
||||
eventCount: (prev.get(sessionId)?.eventCount ?? 0) + 1,
|
||||
})
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
setSessions((prev) => {
|
||||
const existing = prev.get(sessionId)
|
||||
if (!existing) {
|
||||
const next = new Map(prev)
|
||||
next.set(sessionId, {
|
||||
sessionId,
|
||||
sessionName: sessionId.slice(0, 8),
|
||||
lastActivity: timestamp,
|
||||
eventCount: 1,
|
||||
})
|
||||
return next
|
||||
}
|
||||
const next = new Map(prev)
|
||||
next.set(sessionId, {
|
||||
...existing,
|
||||
lastActivity: timestamp,
|
||||
eventCount: existing.eventCount + 1,
|
||||
})
|
||||
return next
|
||||
})
|
||||
}
|
||||
cachedSessions = next
|
||||
}
|
||||
|
||||
// 自动选中第一个会话
|
||||
setSelectedSession((current) => current ?? sessionId)
|
||||
}, [])
|
||||
function handleMonitorEvent(event: MaisakaMonitorEvent) {
|
||||
const dataRecord = event.data as unknown as Record<string, unknown>
|
||||
const sessionId = dataRecord.session_id as string
|
||||
const timestamp = dataRecord.timestamp as number
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
if (!sessionId || typeof timestamp !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
maisakaMonitorClient.subscribe(handleEvent).then((unsub) => {
|
||||
if (cancelled) {
|
||||
appendTimelineEntry({
|
||||
id: `evt_${++entryCounter}_${Date.now()}`,
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
timestamp,
|
||||
sessionId,
|
||||
})
|
||||
|
||||
updateSessionInfo(event, sessionId, timestamp)
|
||||
|
||||
if (cachedSelectedSession === null) {
|
||||
cachedSelectedSession = sessionId
|
||||
}
|
||||
|
||||
notifyStoreListeners()
|
||||
}
|
||||
|
||||
function ensureMonitorSubscription() {
|
||||
if (monitorSubscriptionStarted || monitorSubscriptionPromise !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
monitorSubscriptionPromise = maisakaMonitorClient
|
||||
.subscribe(handleMonitorEvent)
|
||||
.then((unsub) => {
|
||||
monitorUnsubscribe = unsub
|
||||
if (!shouldKeepMonitorActive()) {
|
||||
monitorUnsubscribe = null
|
||||
void unsub()
|
||||
cachedConnected = false
|
||||
notifyStoreListeners()
|
||||
return
|
||||
}
|
||||
unsubRef.current = unsub
|
||||
setConnected(true)
|
||||
monitorSubscriptionStarted = true
|
||||
cachedConnected = true
|
||||
notifyStoreListeners()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('MaiSaka 监控订阅失败:', error)
|
||||
cachedConnected = false
|
||||
notifyStoreListeners()
|
||||
})
|
||||
.finally(() => {
|
||||
monitorSubscriptionPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (unsubRef.current) {
|
||||
void unsubRef.current()
|
||||
unsubRef.current = null
|
||||
}
|
||||
setConnected(false)
|
||||
function stopMonitorSubscriptionIfIdle() {
|
||||
if (shouldKeepMonitorActive()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (monitorUnsubscribe) {
|
||||
const unsub = monitorUnsubscribe
|
||||
monitorUnsubscribe = null
|
||||
monitorSubscriptionStarted = false
|
||||
cachedConnected = false
|
||||
notifyStoreListeners()
|
||||
void unsub()
|
||||
}
|
||||
}
|
||||
|
||||
export function useMaisakaMonitor() {
|
||||
const [timeline, setTimeline] = useState<TimelineEntry[]>(cachedTimeline)
|
||||
const [sessions, setSessions] = useState<Map<string, SessionInfo>>(new Map(cachedSessions))
|
||||
const [selectedSession, setSelectedSessionState] = useState<string | null>(cachedSelectedSession)
|
||||
const [connected, setConnected] = useState(cachedConnected)
|
||||
const [backgroundCollection, setBackgroundCollection] = useState(loadBackgroundCollectionPreference)
|
||||
|
||||
useEffect(() => {
|
||||
activeConsumerCount += 1
|
||||
ensureMonitorSubscription()
|
||||
const syncFromStore = () => {
|
||||
setTimeline(cachedTimeline)
|
||||
setSessions(new Map(cachedSessions))
|
||||
setSelectedSessionState(cachedSelectedSession)
|
||||
setConnected(cachedConnected)
|
||||
setBackgroundCollection(backgroundCollectionEnabled)
|
||||
}
|
||||
}, [handleEvent])
|
||||
|
||||
storeListeners.add(syncFromStore)
|
||||
syncFromStore()
|
||||
return () => {
|
||||
storeListeners.delete(syncFromStore)
|
||||
activeConsumerCount = Math.max(0, activeConsumerCount - 1)
|
||||
stopMonitorSubscriptionIfIdle()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clearTimeline = useCallback(() => {
|
||||
cachedTimeline = []
|
||||
setTimeline([])
|
||||
notifyStoreListeners()
|
||||
}, [])
|
||||
|
||||
const setSelectedSession = useCallback((sessionId: string | null) => {
|
||||
cachedSelectedSession = sessionId
|
||||
setSelectedSessionState(sessionId)
|
||||
notifyStoreListeners()
|
||||
}, [])
|
||||
|
||||
const setBackgroundCollectionEnabled = useCallback((enabled: boolean) => {
|
||||
backgroundCollectionEnabled = enabled
|
||||
backgroundCollectionPreferenceLoaded = true
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(BACKGROUND_COLLECTION_STORAGE_KEY, String(enabled))
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
ensureMonitorSubscription()
|
||||
} else {
|
||||
stopMonitorSubscriptionIfIdle()
|
||||
}
|
||||
notifyStoreListeners()
|
||||
}, [])
|
||||
|
||||
/** 当前选中会话的时间线 */
|
||||
@@ -139,6 +300,8 @@ export function useMaisakaMonitor() {
|
||||
selectedSession,
|
||||
setSelectedSession,
|
||||
connected,
|
||||
backgroundCollection,
|
||||
setBackgroundCollectionEnabled,
|
||||
clearTimeline,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -851,8 +851,25 @@ function PluginConfigPageContent() {
|
||||
)
|
||||
|
||||
// 统计数据
|
||||
const enabledCount = plugins.length // 暂时假设都启用
|
||||
const disabledCount = 0
|
||||
const isPluginDisabled = (plugin: InstalledPlugin) => plugin.disabled === true || plugin.enabled === false
|
||||
const isPluginLoadSuccess = (plugin: InstalledPlugin) => !isPluginDisabled(plugin) && (
|
||||
plugin.load_status === 'success' || plugin.loaded === true
|
||||
)
|
||||
const isPluginLoadFailed = (plugin: InstalledPlugin) => !isPluginDisabled(plugin) && !isPluginLoadSuccess(plugin)
|
||||
const installedCount = plugins.length
|
||||
const disabledCount = plugins.filter(isPluginDisabled).length
|
||||
const enabledCount = installedCount - disabledCount
|
||||
const loadSuccessCount = plugins.filter(isPluginLoadSuccess).length
|
||||
const loadFailedCount = plugins.filter(isPluginLoadFailed).length
|
||||
const getPluginStatusMeta = (plugin: InstalledPlugin) => {
|
||||
if (isPluginDisabled(plugin)) {
|
||||
return { dotClassName: 'bg-muted-foreground/45', label: '已禁用' }
|
||||
}
|
||||
if (isPluginLoadSuccess(plugin)) {
|
||||
return { dotClassName: 'bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.16)]', label: '加载成功' }
|
||||
}
|
||||
return { dotClassName: 'bg-red-500 shadow-[0_0_0_3px_rgba(239,68,68,0.16)]', label: '加载失败' }
|
||||
}
|
||||
|
||||
// 如果选中了插件,显示配置编辑器
|
||||
if (selectedPlugin) {
|
||||
@@ -888,43 +905,29 @@ function PluginConfigPageContent() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid gap-4 grid-cols-1 xs:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">已安装插件</CardTitle>
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{plugins.length}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{loading ? '正在加载...' : '个插件'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">已启用</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{enabledCount}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">运行中的插件</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">已禁用</CardTitle>
|
||||
<AlertCircle className="h-4 w-4 text-orange-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{disabledCount}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">未激活的插件</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/* 统计信息 */}
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
已安装 <strong>{installedCount}</strong> 个插件
|
||||
</span>
|
||||
<span>已启用 <strong className="text-emerald-600">{enabledCount}</strong> 个</span>
|
||||
<span>已禁用 <strong className="text-muted-foreground">{disabledCount}</strong> 个</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border-t pt-3 text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||||
加载成功 <strong className="text-emerald-600">{loadSuccessCount}</strong> 个
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-600" />
|
||||
加载失败 <strong className="text-red-600">{loadFailedCount}</strong> 个
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="relative">
|
||||
@@ -962,16 +965,23 @@ function PluginConfigPageContent() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{uniqueFilteredPlugins.map(plugin => (
|
||||
{uniqueFilteredPlugins.map(plugin => {
|
||||
const statusMeta = getPluginStatusMeta(plugin)
|
||||
return (
|
||||
<div
|
||||
key={plugin.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg border hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
className={`flex items-center justify-between p-4 rounded-lg border hover:bg-muted/50 cursor-pointer transition-colors ${isPluginDisabled(plugin) ? 'opacity-70' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setSelectedPlugin(plugin)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedPlugin(plugin) } }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span
|
||||
className={`h-2.5 w-2.5 rounded-full flex-shrink-0 ${statusMeta.dotClassName}`}
|
||||
title={statusMeta.label}
|
||||
aria-label={statusMeta.label}
|
||||
/>
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Package className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
@@ -996,7 +1006,8 @@ function PluginConfigPageContent() {
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -110,10 +110,20 @@ export function PluginDetailPage() {
|
||||
throw new Error('未找到该插件')
|
||||
}
|
||||
|
||||
const rawManifest = foundPlugin.manifest || {}
|
||||
const repositoryUrl = rawManifest.repository_url || rawManifest.urls?.repository
|
||||
const homepageUrl = rawManifest.homepage_url || rawManifest.urls?.homepage
|
||||
|
||||
// 转换为 PluginInfo 格式
|
||||
const pluginInfo: PluginInfo = {
|
||||
id: foundPlugin.id,
|
||||
manifest: foundPlugin.manifest,
|
||||
manifest: {
|
||||
...rawManifest,
|
||||
homepage_url: homepageUrl,
|
||||
repository_url: repositoryUrl,
|
||||
default_locale: rawManifest.default_locale || rawManifest.i18n?.default_locale || 'zh-CN',
|
||||
locales_path: rawManifest.locales_path || rawManifest.i18n?.locales_path,
|
||||
},
|
||||
downloads: 0,
|
||||
rating: 0,
|
||||
review_count: 0,
|
||||
@@ -270,7 +280,8 @@ export function PluginDetailPage() {
|
||||
try {
|
||||
setOperating(true)
|
||||
|
||||
const installResult = await installPlugin(plugin.id, plugin.manifest.repository_url || '', 'main')
|
||||
const repositoryUrl = plugin.manifest.repository_url || plugin.manifest.urls?.repository || ''
|
||||
const installResult = await installPlugin(plugin.id, repositoryUrl, 'main')
|
||||
|
||||
if (!installResult.success) {
|
||||
toast({
|
||||
@@ -367,7 +378,8 @@ export function PluginDetailPage() {
|
||||
try {
|
||||
setOperating(true)
|
||||
|
||||
const updateResult = await updatePlugin(plugin.id, plugin.manifest.repository_url || '', 'main')
|
||||
const repositoryUrl = plugin.manifest.repository_url || plugin.manifest.urls?.repository || ''
|
||||
const updateResult = await updatePlugin(plugin.id, repositoryUrl, 'main')
|
||||
|
||||
if (!updateResult.success) {
|
||||
toast({
|
||||
|
||||
@@ -214,6 +214,7 @@ function PluginsPageContent() {
|
||||
for (const installedPlugin of installed) {
|
||||
const existsInMarket = mergedData.some(p => p.id === installedPlugin.id)
|
||||
if (!existsInMarket && installedPlugin.manifest) {
|
||||
const urls = installedPlugin.manifest.urls as PluginInfo['manifest']['urls'] | undefined
|
||||
// 添加本地插件到列表
|
||||
mergedData.push({
|
||||
id: installedPlugin.id,
|
||||
@@ -225,8 +226,9 @@ function PluginsPageContent() {
|
||||
author: installedPlugin.manifest.author,
|
||||
license: installedPlugin.manifest.license || 'Unknown',
|
||||
host_application: installedPlugin.manifest.host_application,
|
||||
homepage_url: installedPlugin.manifest.homepage_url,
|
||||
repository_url: installedPlugin.manifest.repository_url,
|
||||
homepage_url: installedPlugin.manifest.homepage_url || urls?.homepage,
|
||||
repository_url: installedPlugin.manifest.repository_url || urls?.repository,
|
||||
urls,
|
||||
keywords: installedPlugin.manifest.keywords || [],
|
||||
categories: installedPlugin.manifest.categories || [],
|
||||
default_locale: (installedPlugin.manifest.default_locale as string) || 'zh-CN',
|
||||
@@ -430,7 +432,7 @@ function PluginsPageContent() {
|
||||
|
||||
const installResult = await installPlugin(
|
||||
installingPlugin.id,
|
||||
installingPlugin.manifest.repository_url || '',
|
||||
installingPlugin.manifest.repository_url || installingPlugin.manifest.urls?.repository || '',
|
||||
branch
|
||||
)
|
||||
|
||||
@@ -574,7 +576,7 @@ function PluginsPageContent() {
|
||||
try {
|
||||
const updateResult = await updatePlugin(
|
||||
plugin.id,
|
||||
plugin.manifest.repository_url || '',
|
||||
plugin.manifest.repository_url || plugin.manifest.urls?.repository || '',
|
||||
'main'
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ import type {
|
||||
import { IMPORT_CHUNK_PAGE_SIZE, IMPORT_KIND_OPTIONS, RUNNING_IMPORT_STATUS } from '../constants'
|
||||
import {
|
||||
formatImportTime,
|
||||
formatProgressPercent,
|
||||
getImportStatusLabel,
|
||||
getImportStatusVariant,
|
||||
getImportStepLabel,
|
||||
@@ -871,7 +872,7 @@ export function ImportTab(props: ImportTabProps) {
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>{getImportStepLabel(String(task.current_step ?? 'running'))}</span>
|
||||
<span>{Number(task.progress ?? 0).toFixed(1)}%</span>
|
||||
<span>{formatProgressPercent(task.progress)}</span>
|
||||
</div>
|
||||
<Progress value={normalizeProgress(task.progress)} className="mt-2 h-1.5" />
|
||||
</button>
|
||||
@@ -966,7 +967,7 @@ export function ImportTab(props: ImportTabProps) {
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>完成进度</span>
|
||||
<span>{Number(task.progress ?? 0).toFixed(1)}%</span>
|
||||
<span>{formatProgressPercent(task.progress)}</span>
|
||||
</div>
|
||||
<Progress value={normalizeProgress(task.progress)} className="mt-2 h-1.5" />
|
||||
</button>
|
||||
@@ -1155,11 +1156,11 @@ export function ImportTab(props: ImportTabProps) {
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>{getImportStepLabel(String(file.current_step ?? ''))}</span>
|
||||
<span>{Number(file.progress ?? 0).toFixed(1)}%</span>
|
||||
<span>{formatProgressPercent(file.progress)}</span>
|
||||
</div>
|
||||
<Progress value={normalizeProgress(file.progress)} className="mt-2 h-1.5" />
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
{Number(file.progress ?? 0).toFixed(1)}% · {Number(file.done_chunks ?? 0)} / {Number(file.total_chunks ?? 0)}
|
||||
{formatProgressPercent(file.progress)} · {Number(file.done_chunks ?? 0)} / {Number(file.total_chunks ?? 0)}
|
||||
</div>
|
||||
{file.error ? (
|
||||
<div className="mt-2 truncate text-xs text-destructive">{file.error}</div>
|
||||
@@ -1230,7 +1231,7 @@ export function ImportTab(props: ImportTabProps) {
|
||||
<TableCell>{chunk.index}</TableCell>
|
||||
<TableCell>{getImportStatusLabel(String(chunk.status ?? ''))}</TableCell>
|
||||
<TableCell>{getImportStepLabel(String(chunk.step ?? ''))}</TableCell>
|
||||
<TableCell>{Number(chunk.progress ?? 0).toFixed(1)}%</TableCell>
|
||||
<TableCell>{formatProgressPercent(chunk.progress)}</TableCell>
|
||||
<TableCell className="max-w-[360px]">
|
||||
<div className="space-y-2">
|
||||
{String(chunk.error ?? '').trim() ? (
|
||||
|
||||
@@ -20,13 +20,18 @@ export function normalizeProgress(value: number | string | null | undefined): nu
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return 0
|
||||
}
|
||||
if (numeric < 0) {
|
||||
const percent = numeric > 0 && numeric <= 1 ? numeric * 100 : numeric
|
||||
if (percent < 0) {
|
||||
return 0
|
||||
}
|
||||
if (numeric > 100) {
|
||||
if (percent > 100) {
|
||||
return 100
|
||||
}
|
||||
return numeric
|
||||
return percent
|
||||
}
|
||||
|
||||
export function formatProgressPercent(value: number | string | null | undefined): string {
|
||||
return `${normalizeProgress(value).toFixed(1)}%`
|
||||
}
|
||||
|
||||
export function parseOptionalPositiveInt(input: string): number | undefined {
|
||||
|
||||
@@ -206,7 +206,12 @@ function buildParagraphFromMetadata(
|
||||
}
|
||||
}
|
||||
|
||||
export function KnowledgeGraphPage() {
|
||||
interface KnowledgeGraphPageProps {
|
||||
embedded?: boolean
|
||||
onOpenConsole?: () => void
|
||||
}
|
||||
|
||||
export function KnowledgeGraphPage({ embedded = false, onOpenConsole }: KnowledgeGraphPageProps = {}) {
|
||||
const navigate = useNavigate()
|
||||
const { toast } = useToast()
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -731,17 +736,26 @@ export function KnowledgeGraphPage() {
|
||||
|
||||
const activeGraph = viewMode === 'entity' ? graphData : evidenceGraph
|
||||
const canShowEvidence = Boolean(selectedNodeData || selectedEdgeData || nodeDetail || edgeDetail)
|
||||
const openConsole = useCallback(() => {
|
||||
if (onOpenConsole) {
|
||||
onOpenConsole()
|
||||
return
|
||||
}
|
||||
void navigate({ to: '/resource/knowledge-base' })
|
||||
}, [navigate, onOpenConsole])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex-none border-b bg-card/60 px-6 py-4 backdrop-blur">
|
||||
<div className={embedded ? 'flex-none border-b bg-card/60 px-4 py-4 backdrop-blur' : 'flex-none border-b bg-card/60 px-6 py-4 backdrop-blur'}>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">长期记忆图谱</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
基于 A_Memorix 的实体关系图与证据视图
|
||||
</p>
|
||||
</div>
|
||||
{!embedded && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">长期记忆图谱</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
基于 A_Memorix 的实体关系图与证据视图
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline" className="gap-1">
|
||||
@@ -791,7 +805,7 @@ export function KnowledgeGraphPage() {
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新图谱
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate({ to: '/resource/knowledge-base' })}>
|
||||
<Button variant="outline" onClick={openConsole} className={embedded ? 'hidden' : undefined}>
|
||||
<SlidersHorizontal className="mr-2 h-4 w-4" />
|
||||
打开控制台
|
||||
</Button>
|
||||
@@ -873,7 +887,7 @@ export function KnowledgeGraphPage() {
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
先在长期记忆控制台里完成导入或记忆生成,再回来查看关系网络。
|
||||
</p>
|
||||
<Button className="mt-4" onClick={() => navigate({ to: '/resource/knowledge-base' })}>
|
||||
<Button className="mt-4" onClick={openConsole}>
|
||||
前往长期记忆控制台
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// 设置向导各步骤表单组件
|
||||
|
||||
import { ExternalLink, Eye, EyeOff, X } from 'lucide-react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -15,16 +14,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import type {
|
||||
ApiProviderSetupConfig,
|
||||
BotBasicConfig,
|
||||
EmojiConfig,
|
||||
OtherBasicConfig,
|
||||
ModelSetupConfig,
|
||||
PersonalityConfig,
|
||||
SiliconFlowConfig,
|
||||
} from './types'
|
||||
|
||||
// ====== 步骤1:Bot基础配置 ======
|
||||
@@ -156,22 +153,6 @@ export function BotBasicForm({ config, onChange }: BotBasicFormProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddAlias = (alias: string) => {
|
||||
if (alias.trim() && !config.alias_names.includes(alias.trim())) {
|
||||
onChange({
|
||||
...config,
|
||||
alias_names: [...config.alias_names, alias.trim()],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveAlias = (index: number) => {
|
||||
onChange({
|
||||
...config,
|
||||
alias_names: config.alias_names.filter((_, aliasIndex) => aliasIndex !== index),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
@@ -254,53 +235,6 @@ export function BotBasicForm({ config, onChange }: BotBasicFormProps) {
|
||||
{t('setupPage.forms.botBasic.nickname.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>{t('setupPage.forms.botBasic.alias.label')}</Label>
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{config.alias_names.map((alias, index) => (
|
||||
<Badge key={index} variant="secondary" className="gap-1">
|
||||
{alias}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveAlias(index)}
|
||||
className="hover:text-destructive ml-1"
|
||||
aria-label={t('setupPage.forms.botBasic.alias.remove', { alias })}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="alias_input"
|
||||
placeholder={t('setupPage.forms.botBasic.alias.placeholder')}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleAddAlias((e.target as HTMLInputElement).value)
|
||||
;(e.target as HTMLInputElement).value = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const input = document.getElementById('alias_input') as HTMLInputElement
|
||||
if (input) {
|
||||
handleAddAlias(input.value)
|
||||
input.value = ''
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('setupPage.forms.botBasic.alias.add')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('setupPage.forms.botBasic.alias.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -313,7 +247,6 @@ interface PersonalityFormProps {
|
||||
|
||||
export function PersonalityForm({ config, onChange }: PersonalityFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const multipleReplyStyleText = config.multiple_reply_style.join('\n')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -344,276 +277,61 @@ export function PersonalityForm({ config, onChange }: PersonalityFormProps) {
|
||||
{t('setupPage.forms.personality.replyStyle.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="multiple_reply_style">
|
||||
{t('setupPage.forms.personality.multipleReplyStyle.label')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="multiple_reply_style"
|
||||
placeholder={t('setupPage.forms.personality.multipleReplyStyle.placeholder')}
|
||||
value={multipleReplyStyleText}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
multiple_reply_style: e.target.value
|
||||
.split('\n')
|
||||
.map((style) => style.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
rows={5}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('setupPage.forms.personality.multipleReplyStyle.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="multiple_probability">
|
||||
{t('setupPage.forms.personality.multipleProbability.label')}
|
||||
</Label>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{(config.multiple_probability * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
id="multiple_probability"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={config.multiple_probability}
|
||||
onChange={(e) => onChange({ ...config, multiple_probability: Number(e.target.value) })}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('setupPage.forms.personality.multipleProbability.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ====== 步骤3:表情包配置 ======
|
||||
interface EmojiFormProps {
|
||||
config: EmojiConfig
|
||||
onChange: (config: EmojiConfig) => void
|
||||
// ====== 步骤3:API 提供商配置 ======
|
||||
interface ApiProviderSetupFormProps {
|
||||
config: ApiProviderSetupConfig
|
||||
onChange: (config: ApiProviderSetupConfig) => void
|
||||
}
|
||||
|
||||
export function EmojiForm({ config, onChange }: EmojiFormProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="emoji_send_num">{t('setupPage.forms.emoji.emojiSendNum.label')}</Label>
|
||||
<Input
|
||||
id="emoji_send_num"
|
||||
type="number"
|
||||
min="1"
|
||||
max="64"
|
||||
value={config.emoji_send_num}
|
||||
onChange={(e) => onChange({ ...config, emoji_send_num: Number(e.target.value) })}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('setupPage.forms.emoji.emojiSendNum.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="max_reg_num">{t('setupPage.forms.emoji.maxRegNum.label')}</Label>
|
||||
<Input
|
||||
id="max_reg_num"
|
||||
type="number"
|
||||
min="1"
|
||||
max="200"
|
||||
value={config.max_reg_num}
|
||||
onChange={(e) => onChange({ ...config, max_reg_num: Number(e.target.value) })}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('setupPage.forms.emoji.maxRegNum.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="do_replace">{t('setupPage.forms.emoji.doReplace.label')}</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('setupPage.forms.emoji.doReplace.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="do_replace"
|
||||
checked={config.do_replace}
|
||||
onCheckedChange={(checked) => onChange({ ...config, do_replace: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="check_interval">{t('setupPage.forms.emoji.checkInterval.label')}</Label>
|
||||
<Input
|
||||
id="check_interval"
|
||||
type="number"
|
||||
min="1"
|
||||
max="120"
|
||||
value={config.check_interval}
|
||||
onChange={(e) => onChange({ ...config, check_interval: Number(e.target.value) })}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('setupPage.forms.emoji.checkInterval.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="steal_emoji">{t('setupPage.forms.emoji.stealEmoji.label')}</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('setupPage.forms.emoji.stealEmoji.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="steal_emoji"
|
||||
checked={config.steal_emoji}
|
||||
onCheckedChange={(checked) => onChange({ ...config, steal_emoji: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="content_filtration">
|
||||
{t('setupPage.forms.emoji.contentFiltration.label')}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('setupPage.forms.emoji.contentFiltration.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="content_filtration"
|
||||
checked={config.content_filtration}
|
||||
onCheckedChange={(checked) => onChange({ ...config, content_filtration: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.content_filtration && (
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="filtration_prompt">
|
||||
{t('setupPage.forms.emoji.filtrationPrompt.label')}
|
||||
</Label>
|
||||
<Input
|
||||
id="filtration_prompt"
|
||||
placeholder={t('setupPage.forms.emoji.filtrationPrompt.placeholder')}
|
||||
value={config.filtration_prompt}
|
||||
onChange={(e) => onChange({ ...config, filtration_prompt: e.target.value })}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('setupPage.forms.emoji.filtrationPrompt.description')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ====== 步骤4:其他基础配置 ======
|
||||
interface OtherBasicFormProps {
|
||||
config: OtherBasicConfig
|
||||
onChange: (config: OtherBasicConfig) => void
|
||||
}
|
||||
|
||||
export function OtherBasicForm({ config, onChange }: OtherBasicFormProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="all_global">{t('setupPage.forms.other.allGlobal.label')}</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('setupPage.forms.other.allGlobal.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="all_global"
|
||||
checked={config.all_global}
|
||||
onCheckedChange={(checked) => onChange({ ...config, all_global: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ====== 步骤5:硅基流动API配置 ======
|
||||
interface SiliconFlowFormProps {
|
||||
config: SiliconFlowConfig
|
||||
onChange: (config: SiliconFlowConfig) => void
|
||||
}
|
||||
|
||||
export function SiliconFlowForm({ config, onChange }: SiliconFlowFormProps) {
|
||||
export function ApiProviderSetupForm({ config, onChange }: ApiProviderSetupFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const [showApiKey, setShowApiKey] = useState(false)
|
||||
const apiKeyToggleLabel = showApiKey
|
||||
? t('setupPage.forms.siliconFlow.apiKey.hide')
|
||||
: t('setupPage.forms.siliconFlow.apiKey.show')
|
||||
const autoConfigItems = [
|
||||
t('setupPage.forms.siliconFlow.autoConfig.items.deepseek'),
|
||||
t('setupPage.forms.siliconFlow.autoConfig.items.qwen3'),
|
||||
t('setupPage.forms.siliconFlow.autoConfig.items.qwen3Vl'),
|
||||
t('setupPage.forms.siliconFlow.autoConfig.items.senseVoice'),
|
||||
t('setupPage.forms.siliconFlow.autoConfig.items.bgeM3'),
|
||||
t('setupPage.forms.siliconFlow.autoConfig.items.lpmm'),
|
||||
]
|
||||
? t('setupPage.forms.apiProvider.apiKey.hide')
|
||||
: t('setupPage.forms.apiProvider.apiKey.show')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950/30">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
<svg
|
||||
className="h-5 w-5 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="mb-1 font-medium text-blue-900 dark:text-blue-100">
|
||||
{t('setupPage.forms.siliconFlow.about.title')}
|
||||
</p>
|
||||
<p className="mb-2 text-blue-700 dark:text-blue-300">
|
||||
{t('setupPage.forms.siliconFlow.about.description')}
|
||||
</p>
|
||||
<a
|
||||
href="https://cloud.siliconflow.cn"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
{t('setupPage.forms.siliconFlow.about.link')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="provider_name">{t('setupPage.forms.apiProvider.providerName.label')}</Label>
|
||||
<Input
|
||||
id="provider_name"
|
||||
placeholder={t('setupPage.forms.apiProvider.providerName.placeholder')}
|
||||
value={config.provider_name}
|
||||
onChange={(e) => onChange({ ...config, provider_name: e.target.value })}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('setupPage.forms.apiProvider.providerName.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="siliconflow_api_key">{t('setupPage.forms.siliconFlow.apiKey.label')}</Label>
|
||||
<Label htmlFor="base_url">{t('setupPage.forms.apiProvider.baseUrl.label')}</Label>
|
||||
<Input
|
||||
id="base_url"
|
||||
placeholder="https://api.example.com/v1"
|
||||
value={config.base_url}
|
||||
onChange={(e) => onChange({ ...config, base_url: e.target.value })}
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('setupPage.forms.apiProvider.baseUrl.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="api_key">{t('setupPage.forms.apiProvider.apiKey.label')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="siliconflow_api_key"
|
||||
id="api_key"
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
placeholder="sk-..."
|
||||
value={config.api_key}
|
||||
onChange={(e) => onChange({ api_key: e.target.value })}
|
||||
onChange={(e) => onChange({ ...config, api_key: e.target.value })}
|
||||
className="pr-10 font-mono"
|
||||
/>
|
||||
<Button
|
||||
@@ -633,25 +351,103 @@ export function SiliconFlowForm({ config, onChange }: SiliconFlowFormProps) {
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('setupPage.forms.siliconFlow.apiKey.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 space-y-2 rounded-lg p-4 text-sm">
|
||||
<p className="font-medium">{t('setupPage.forms.siliconFlow.autoConfig.title')}</p>
|
||||
<ul className="text-muted-foreground ml-2 list-inside list-disc space-y-1">
|
||||
{autoConfigItems.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30">
|
||||
<p className="text-sm text-amber-900 dark:text-amber-100">
|
||||
<span className="font-medium">{t('setupPage.forms.siliconFlow.hint.title')}</span>
|
||||
{t('setupPage.forms.siliconFlow.hint.description')}
|
||||
{t('setupPage.forms.apiProvider.apiKey.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ====== 步骤4:基础模型配置 ======
|
||||
interface ModelSetupFormProps {
|
||||
config: ModelSetupConfig
|
||||
onChange: (config: ModelSetupConfig) => void
|
||||
}
|
||||
|
||||
export function ModelSetupForm({ config, onChange }: ModelSetupFormProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="planner_model_identifier">
|
||||
{t('setupPage.forms.modelSetup.planner.identifier.label')}
|
||||
</Label>
|
||||
<Input
|
||||
id="planner_model_identifier"
|
||||
placeholder="gpt-4.1-mini"
|
||||
value={config.planner_model_identifier}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
planner_model_identifier: e.target.value,
|
||||
planner_model_name: e.target.value,
|
||||
})
|
||||
}
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('setupPage.forms.modelSetup.planner.identifier.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 rounded-md bg-muted/40 p-3">
|
||||
<Label htmlFor="planner_visual" className="text-sm font-medium">
|
||||
{t('setupPage.forms.modelSetup.planner.visual.label')}
|
||||
</Label>
|
||||
<Switch
|
||||
id="planner_visual"
|
||||
checked={config.planner_visual}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, planner_visual: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="replyer_model_identifier">
|
||||
{t('setupPage.forms.modelSetup.replyer.identifier.label')}
|
||||
</Label>
|
||||
<Input
|
||||
id="replyer_model_identifier"
|
||||
placeholder="gpt-4.1"
|
||||
value={config.replyer_model_identifier}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
replyer_model_identifier: e.target.value,
|
||||
replyer_model_name: e.target.value,
|
||||
})
|
||||
}
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('setupPage.forms.modelSetup.replyer.identifier.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 rounded-md bg-muted/40 p-3">
|
||||
<Label htmlFor="replyer_visual" className="text-sm font-medium">
|
||||
{t('setupPage.forms.modelSetup.replyer.visual.label')}
|
||||
</Label>
|
||||
<Switch
|
||||
id="replyer_visual"
|
||||
checked={config.replyer_visual}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, replyer_visual: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4 text-sm text-muted-foreground">
|
||||
{t('setupPage.forms.modelSetup.saveHint')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,13 +4,49 @@ import { parseResponse, throwIfError } from '@/lib/api-helpers'
|
||||
import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth'
|
||||
|
||||
import type {
|
||||
ApiProviderSetupConfig,
|
||||
BotBasicConfig,
|
||||
EmojiConfig,
|
||||
OtherBasicConfig,
|
||||
ModelSetupConfig,
|
||||
PersonalityConfig,
|
||||
SiliconFlowConfig,
|
||||
} from './types'
|
||||
|
||||
interface ModelInfo {
|
||||
model_identifier: string
|
||||
name: string
|
||||
api_provider: string
|
||||
price_in?: number
|
||||
cache?: boolean
|
||||
cache_price_in?: number
|
||||
price_out?: number
|
||||
force_stream_mode?: boolean
|
||||
visual?: boolean
|
||||
extra_params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ApiProviderConfig {
|
||||
name: string
|
||||
base_url: string
|
||||
api_key: string
|
||||
client_type?: string
|
||||
max_retry?: number
|
||||
timeout?: number
|
||||
retry_interval?: number
|
||||
}
|
||||
|
||||
interface TaskConfig {
|
||||
model_list?: string[]
|
||||
max_tokens?: number
|
||||
temperature?: number
|
||||
slow_threshold?: number
|
||||
selection_strategy?: string
|
||||
}
|
||||
|
||||
interface ModelConfig {
|
||||
models?: ModelInfo[]
|
||||
api_providers?: ApiProviderConfig[]
|
||||
model_task_config?: Record<string, TaskConfig>
|
||||
}
|
||||
|
||||
// ===== 读取配置 =====
|
||||
|
||||
// 读取Bot基础配置
|
||||
@@ -56,73 +92,57 @@ export async function loadPersonalityConfig(): Promise<PersonalityConfig> {
|
||||
}
|
||||
}
|
||||
|
||||
// 读取表情包配置
|
||||
export async function loadEmojiConfig(): Promise<EmojiConfig> {
|
||||
const response = await fetchWithAuth('/api/webui/config/bot', {
|
||||
method: 'GET',
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
|
||||
const result = await parseResponse<{ config: { emoji?: EmojiConfig } }>(
|
||||
response
|
||||
)
|
||||
const data = throwIfError(result)
|
||||
const emojiConfig = (data.config.emoji || {}) as Partial<EmojiConfig>
|
||||
|
||||
return {
|
||||
emoji_send_num: emojiConfig.emoji_send_num ?? 25,
|
||||
max_reg_num: emojiConfig.max_reg_num ?? 64,
|
||||
do_replace: emojiConfig.do_replace ?? true,
|
||||
check_interval: emojiConfig.check_interval ?? 10,
|
||||
steal_emoji: emojiConfig.steal_emoji ?? true,
|
||||
content_filtration: emojiConfig.content_filtration ?? false,
|
||||
filtration_prompt: emojiConfig.filtration_prompt || '',
|
||||
}
|
||||
}
|
||||
|
||||
// 读取其他基础配置
|
||||
export async function loadOtherBasicConfig(): Promise<OtherBasicConfig> {
|
||||
const response = await fetchWithAuth('/api/webui/config/bot', {
|
||||
method: 'GET',
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
|
||||
const result = await parseResponse<{
|
||||
config: {
|
||||
expression?: { all_global_jargon?: boolean }
|
||||
}
|
||||
}>(response)
|
||||
const data = throwIfError(result)
|
||||
const config = data.config
|
||||
|
||||
const expressionConfig = config.expression || {}
|
||||
|
||||
return {
|
||||
all_global: expressionConfig.all_global_jargon ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
// 读取硅基流动API配置
|
||||
export async function loadSiliconFlowConfig(): Promise<SiliconFlowConfig> {
|
||||
async function loadModelConfig(): Promise<ModelConfig> {
|
||||
const response = await fetchWithAuth('/api/webui/config/model', {
|
||||
method: 'GET',
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
|
||||
const result = await parseResponse<{
|
||||
config: {
|
||||
api_providers?: Array<{ name: string; api_key?: string }>
|
||||
}
|
||||
}>(response)
|
||||
const result = await parseResponse<{ config: ModelConfig }>(response)
|
||||
const data = throwIfError(result)
|
||||
const modelConfig = data.config
|
||||
return data.config || {}
|
||||
}
|
||||
|
||||
// 获取SiliconFlow提供商的API Key
|
||||
const apiProviders = modelConfig.api_providers || []
|
||||
const siliconFlowProvider = apiProviders.find((p) => p.name === 'SiliconFlow')
|
||||
// 读取 API 提供商配置
|
||||
export async function loadApiProviderSetupConfig(): Promise<ApiProviderSetupConfig> {
|
||||
const modelConfig = await loadModelConfig()
|
||||
const models = modelConfig.models || []
|
||||
const taskConfig = modelConfig.model_task_config || {}
|
||||
const plannerName = taskConfig.planner?.model_list?.[0] || ''
|
||||
const replyerName = taskConfig.replyer?.model_list?.[0] || ''
|
||||
const plannerModel = models.find((model) => model.name === plannerName)
|
||||
const replyerModel = models.find((model) => model.name === replyerName)
|
||||
const providerName =
|
||||
plannerModel?.api_provider ||
|
||||
replyerModel?.api_provider ||
|
||||
modelConfig.api_providers?.[0]?.name ||
|
||||
''
|
||||
const provider = modelConfig.api_providers?.find((item) => item.name === providerName)
|
||||
|
||||
return {
|
||||
api_key: siliconFlowProvider?.api_key || '',
|
||||
provider_name: providerName,
|
||||
base_url: provider?.base_url || '',
|
||||
api_key: '',
|
||||
}
|
||||
}
|
||||
|
||||
// 读取基础模型配置
|
||||
export async function loadModelSetupConfig(): Promise<ModelSetupConfig> {
|
||||
const modelConfig = await loadModelConfig()
|
||||
const models = modelConfig.models || []
|
||||
const taskConfig = modelConfig.model_task_config || {}
|
||||
const plannerName = taskConfig.planner?.model_list?.[0] || ''
|
||||
const replyerName = taskConfig.replyer?.model_list?.[0] || ''
|
||||
const plannerModel = models.find((model) => model.name === plannerName)
|
||||
const replyerModel = models.find((model) => model.name === replyerName)
|
||||
|
||||
return {
|
||||
planner_model_name: plannerName,
|
||||
planner_model_identifier: plannerModel?.model_identifier || plannerName,
|
||||
planner_visual: Boolean(plannerModel?.visual),
|
||||
replyer_model_name: replyerName,
|
||||
replyer_model_identifier: replyerModel?.model_identifier || replyerName,
|
||||
replyer_visual: Boolean(replyerModel?.visual),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,19 +163,6 @@ export async function saveBotBasicConfig(config: BotBasicConfig) {
|
||||
// 保存人格配置
|
||||
export async function savePersonalityConfig(config: PersonalityConfig) {
|
||||
const response = await fetchWithAuth('/api/webui/config/bot/section/personality', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(config),
|
||||
}
|
||||
)
|
||||
|
||||
const result = await parseResponse(response)
|
||||
return throwIfError(result)
|
||||
}
|
||||
|
||||
// 保存表情包配置
|
||||
export async function saveEmojiConfig(config: EmojiConfig) {
|
||||
const response = await fetchWithAuth('/api/webui/config/bot/section/emoji', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(config),
|
||||
@@ -165,58 +172,62 @@ export async function saveEmojiConfig(config: EmojiConfig) {
|
||||
return throwIfError(result)
|
||||
}
|
||||
|
||||
// 保存其他基础配置(黑话)
|
||||
export async function saveOtherBasicConfig(config: OtherBasicConfig) {
|
||||
const response = await fetchWithAuth('/api/webui/config/bot/section/expression', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ all_global_jargon: config.all_global }),
|
||||
})
|
||||
|
||||
const result = await parseResponse(response)
|
||||
return throwIfError(result)
|
||||
function createBasicModel(
|
||||
modelName: string,
|
||||
modelIdentifier: string,
|
||||
providerName: string,
|
||||
visual: boolean,
|
||||
existing?: ModelInfo
|
||||
): ModelInfo {
|
||||
return {
|
||||
price_in: 0,
|
||||
cache: false,
|
||||
cache_price_in: 0,
|
||||
price_out: 0,
|
||||
force_stream_mode: false,
|
||||
extra_params: {},
|
||||
...existing,
|
||||
visual,
|
||||
model_identifier: modelIdentifier,
|
||||
name: modelName,
|
||||
api_provider: providerName,
|
||||
}
|
||||
}
|
||||
|
||||
// 保存硅基流动API配置
|
||||
export async function saveSiliconFlowConfig(config: SiliconFlowConfig) {
|
||||
// 1. 读取现有配置
|
||||
const response = await fetchWithAuth('/api/webui/config/model', {
|
||||
method: 'GET',
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
function upsertModel(models: ModelInfo[], model: ModelInfo): ModelInfo[] {
|
||||
const index = models.findIndex((item) => item.name === model.name)
|
||||
if (index >= 0) {
|
||||
return models.map((item, itemIndex) => (itemIndex === index ? model : item))
|
||||
}
|
||||
return [...models, model]
|
||||
}
|
||||
|
||||
const result = await parseResponse<{
|
||||
config: {
|
||||
api_providers?: Array<Record<string, unknown>>
|
||||
}
|
||||
}>(response)
|
||||
const currentModelConfig = throwIfError(result)
|
||||
const modelConfig = currentModelConfig.config
|
||||
// 保存 API 提供商配置
|
||||
export async function saveApiProviderSetupConfig(config: ApiProviderSetupConfig) {
|
||||
const modelConfig = await loadModelConfig()
|
||||
const providerName = config.provider_name.trim()
|
||||
|
||||
// 2. 更新SiliconFlow提供商的API Key
|
||||
const apiProviders = modelConfig.api_providers || []
|
||||
const siliconFlowIndex = apiProviders.findIndex((p) => p.name === 'SiliconFlow')
|
||||
|
||||
if (siliconFlowIndex >= 0) {
|
||||
// 更新现有提供商的API Key
|
||||
apiProviders[siliconFlowIndex] = {
|
||||
...apiProviders[siliconFlowIndex],
|
||||
api_key: config.api_key,
|
||||
}
|
||||
} else {
|
||||
// 如果不存在,创建新的SiliconFlow提供商
|
||||
apiProviders.push({
|
||||
name: 'SiliconFlow',
|
||||
base_url: 'https://api.siliconflow.cn/v1',
|
||||
api_key: config.api_key,
|
||||
client_type: 'openai',
|
||||
max_retry: 3,
|
||||
timeout: 120,
|
||||
retry_interval: 5,
|
||||
})
|
||||
const providerIndex = apiProviders.findIndex((provider) => provider.name === providerName)
|
||||
const providerConfig: ApiProviderConfig = {
|
||||
name: providerName,
|
||||
base_url: config.base_url.trim(),
|
||||
api_key: config.api_key.trim(),
|
||||
client_type: 'openai',
|
||||
max_retry: 3,
|
||||
timeout: 120,
|
||||
retry_interval: 5,
|
||||
}
|
||||
|
||||
if (providerIndex >= 0) {
|
||||
apiProviders[providerIndex] = {
|
||||
...apiProviders[providerIndex],
|
||||
...providerConfig,
|
||||
}
|
||||
} else {
|
||||
apiProviders.push(providerConfig)
|
||||
}
|
||||
|
||||
// 3. 保存更新后的配置
|
||||
const updatedConfig = {
|
||||
...modelConfig,
|
||||
api_providers: apiProviders,
|
||||
@@ -232,6 +243,77 @@ export async function saveSiliconFlowConfig(config: SiliconFlowConfig) {
|
||||
return throwIfError(saveResult)
|
||||
}
|
||||
|
||||
// 保存基础模型配置
|
||||
export async function saveModelSetupConfig(
|
||||
config: ModelSetupConfig,
|
||||
providerName: string
|
||||
) {
|
||||
const modelConfig = await loadModelConfig()
|
||||
const trimmedProviderName = providerName.trim()
|
||||
const plannerModelIdentifier = config.planner_model_identifier.trim()
|
||||
const plannerModelName = plannerModelIdentifier
|
||||
const replyerModelIdentifier = config.replyer_model_identifier.trim()
|
||||
const replyerModelName = replyerModelIdentifier
|
||||
|
||||
// 新增或更新 planner/replyer 模型,并仅同步 utils 到 planner。
|
||||
let models = modelConfig.models || []
|
||||
const existingPlannerModel = models.find((model) => model.name === plannerModelName)
|
||||
const existingReplyerModel = models.find((model) => model.name === replyerModelName)
|
||||
models = upsertModel(
|
||||
models,
|
||||
createBasicModel(
|
||||
plannerModelName,
|
||||
plannerModelIdentifier,
|
||||
trimmedProviderName,
|
||||
config.planner_visual,
|
||||
existingPlannerModel
|
||||
)
|
||||
)
|
||||
models = upsertModel(
|
||||
models,
|
||||
createBasicModel(
|
||||
replyerModelName,
|
||||
replyerModelIdentifier,
|
||||
trimmedProviderName,
|
||||
config.replyer_visual,
|
||||
existingReplyerModel
|
||||
)
|
||||
)
|
||||
|
||||
const modelTaskConfig = modelConfig.model_task_config || {}
|
||||
const updatedTaskConfig = {
|
||||
...modelTaskConfig,
|
||||
planner: {
|
||||
...(modelTaskConfig.planner || {}),
|
||||
model_list: [plannerModelName],
|
||||
},
|
||||
replyer: {
|
||||
...(modelTaskConfig.replyer || {}),
|
||||
model_list: [replyerModelName],
|
||||
},
|
||||
utils: {
|
||||
...(modelTaskConfig.utils || {}),
|
||||
model_list: [plannerModelName],
|
||||
},
|
||||
}
|
||||
|
||||
// vlm/voice/embedding 等其他任务配置保持原样。
|
||||
const updatedConfig = {
|
||||
...modelConfig,
|
||||
models,
|
||||
model_task_config: updatedTaskConfig,
|
||||
}
|
||||
|
||||
const saveResponse = await fetchWithAuth('/api/webui/config/model', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(updatedConfig),
|
||||
})
|
||||
|
||||
const saveResult = await parseResponse(saveResponse)
|
||||
return throwIfError(saveResult)
|
||||
}
|
||||
|
||||
// 标记设置完成
|
||||
export async function completeSetup() {
|
||||
const response = await fetchWithAuth('/api/webui/setup/complete', {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
ArrowRight,
|
||||
Brain,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
Globe,
|
||||
Key,
|
||||
Settings,
|
||||
SkipForward,
|
||||
Smile,
|
||||
Sparkles,
|
||||
User,
|
||||
} from 'lucide-react'
|
||||
@@ -38,31 +37,27 @@ import { cn } from '@/lib/utils'
|
||||
import { APP_NAME } from '@/lib/version'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import type {
|
||||
ApiProviderSetupConfig,
|
||||
SetupStep,
|
||||
BotBasicConfig,
|
||||
ModelSetupConfig,
|
||||
PersonalityConfig,
|
||||
EmojiConfig,
|
||||
OtherBasicConfig,
|
||||
SiliconFlowConfig,
|
||||
} from './types'
|
||||
import {
|
||||
ApiProviderSetupForm,
|
||||
BotBasicForm,
|
||||
ModelSetupForm,
|
||||
PersonalityForm,
|
||||
EmojiForm,
|
||||
OtherBasicForm,
|
||||
SiliconFlowForm,
|
||||
} from './StepForms'
|
||||
import {
|
||||
loadBotBasicConfig,
|
||||
loadPersonalityConfig,
|
||||
loadEmojiConfig,
|
||||
loadOtherBasicConfig,
|
||||
loadSiliconFlowConfig,
|
||||
loadApiProviderSetupConfig,
|
||||
loadModelSetupConfig,
|
||||
saveBotBasicConfig,
|
||||
savePersonalityConfig,
|
||||
saveEmojiConfig,
|
||||
saveOtherBasicConfig,
|
||||
saveSiliconFlowConfig,
|
||||
saveApiProviderSetupConfig,
|
||||
saveModelSetupConfig,
|
||||
completeSetup,
|
||||
} from './api'
|
||||
import { RestartProvider, useRestart } from '@/lib/restart-context'
|
||||
@@ -103,15 +98,6 @@ function SetupPageContent() {
|
||||
],
|
||||
multiple_probability: 0.2,
|
||||
})
|
||||
const createDefaultEmojiConfig = (): EmojiConfig => ({
|
||||
emoji_send_num: 25,
|
||||
max_reg_num: 64,
|
||||
do_replace: true,
|
||||
check_interval: 10,
|
||||
steal_emoji: true,
|
||||
content_filtration: false,
|
||||
filtration_prompt: t('setupPage.defaults.emoji.filtrationPrompt'),
|
||||
})
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [isCompleting, setIsCompleting] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
@@ -131,17 +117,21 @@ function SetupPageContent() {
|
||||
createDefaultPersonalityConfig()
|
||||
)
|
||||
|
||||
// 步骤3:表情包配置
|
||||
const [emoji, setEmoji] = useState<EmojiConfig>(() => createDefaultEmojiConfig())
|
||||
|
||||
// 步骤4:其他基础配置
|
||||
const [otherBasic, setOtherBasic] = useState<OtherBasicConfig>({
|
||||
all_global: true,
|
||||
// 步骤3:API 提供商配置
|
||||
const [apiProviderSetup, setApiProviderSetup] = useState<ApiProviderSetupConfig>({
|
||||
provider_name: '',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
})
|
||||
|
||||
// 步骤5:硅基流动API配置
|
||||
const [siliconFlow, setSiliconFlow] = useState<SiliconFlowConfig>({
|
||||
api_key: '',
|
||||
// 步骤4:基础模型配置
|
||||
const [modelSetup, setModelSetup] = useState<ModelSetupConfig>({
|
||||
planner_model_name: '',
|
||||
planner_model_identifier: '',
|
||||
planner_visual: false,
|
||||
replyer_model_name: '',
|
||||
replyer_model_identifier: '',
|
||||
replyer_visual: false,
|
||||
})
|
||||
|
||||
const steps: SetupStep[] = [
|
||||
@@ -158,23 +148,17 @@ function SetupPageContent() {
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
id: 'emoji',
|
||||
title: t('setupPage.steps.emoji.title'),
|
||||
description: t('setupPage.steps.emoji.description'),
|
||||
icon: Smile,
|
||||
},
|
||||
{
|
||||
id: 'other',
|
||||
title: t('setupPage.steps.other.title'),
|
||||
description: t('setupPage.steps.other.description'),
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
id: 'siliconflow',
|
||||
title: t('setupPage.steps.siliconFlow.title'),
|
||||
description: t('setupPage.steps.siliconFlow.description'),
|
||||
id: 'api-provider',
|
||||
title: t('setupPage.steps.apiProvider.title'),
|
||||
description: t('setupPage.steps.apiProvider.description'),
|
||||
icon: Key,
|
||||
},
|
||||
{
|
||||
id: 'model-setup',
|
||||
title: t('setupPage.steps.modelSetup.title'),
|
||||
description: t('setupPage.steps.modelSetup.description'),
|
||||
icon: Brain,
|
||||
},
|
||||
]
|
||||
|
||||
const progress = ((currentStep + 1) / steps.length) * 100
|
||||
@@ -186,19 +170,17 @@ function SetupPageContent() {
|
||||
setIsLoading(true)
|
||||
|
||||
// 并行加载所有配置
|
||||
const [bot, personality, emoji, other, silicon] = await Promise.all([
|
||||
const [bot, personality, apiProvider, model] = await Promise.all([
|
||||
loadBotBasicConfig(),
|
||||
loadPersonalityConfig(),
|
||||
loadEmojiConfig(),
|
||||
loadOtherBasicConfig(),
|
||||
loadSiliconFlowConfig(),
|
||||
loadApiProviderSetupConfig(),
|
||||
loadModelSetupConfig(),
|
||||
])
|
||||
|
||||
setBotBasic(bot)
|
||||
setPersonality(personality)
|
||||
setEmoji(emoji)
|
||||
setOtherBasic(other)
|
||||
setSiliconFlow(silicon)
|
||||
setApiProviderSetup(apiProvider)
|
||||
setModelSetup(model)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('setupPage.toast.loadFailedTitle'),
|
||||
@@ -225,14 +207,11 @@ function SetupPageContent() {
|
||||
case 1: // 人格配置
|
||||
await savePersonalityConfig(personality)
|
||||
break
|
||||
case 2: // 表情包
|
||||
await saveEmojiConfig(emoji)
|
||||
case 2: // API 提供商
|
||||
await saveApiProviderSetupConfig(apiProviderSetup)
|
||||
break
|
||||
case 3: // 其他设置
|
||||
await saveOtherBasicConfig(otherBasic)
|
||||
break
|
||||
case 4: // 硅基流动API
|
||||
await saveSiliconFlowConfig(siliconFlow)
|
||||
case 3: // 基础模型
|
||||
await saveModelSetupConfig(modelSetup, apiProviderSetup.provider_name)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -272,6 +251,24 @@ function SetupPageContent() {
|
||||
return null
|
||||
}
|
||||
|
||||
function validateApiProviderSetup(config: ApiProviderSetupConfig): string | null {
|
||||
if (!config.provider_name.trim()) return t('setupPage.validation.enterProviderName')
|
||||
if (!config.base_url.trim()) return t('setupPage.validation.enterBaseUrl')
|
||||
if (!config.api_key.trim()) return t('setupPage.validation.enterApiKey')
|
||||
return null
|
||||
}
|
||||
|
||||
function validateModelSetup(config: ModelSetupConfig): string | null {
|
||||
if (!config.planner_model_identifier.trim()) {
|
||||
return t('setupPage.validation.enterPlannerModelIdentifier')
|
||||
}
|
||||
if (!config.replyer_model_identifier.trim()) {
|
||||
return t('setupPage.validation.enterReplyerModelIdentifier')
|
||||
}
|
||||
if (!apiProviderSetup.provider_name.trim()) return t('setupPage.validation.enterProviderName')
|
||||
return null
|
||||
}
|
||||
|
||||
const handleNext = async () => {
|
||||
// Step 1 验证
|
||||
if (currentStep === 0) {
|
||||
@@ -285,6 +282,28 @@ function SetupPageContent() {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (currentStep === 2) {
|
||||
const error = validateApiProviderSetup(apiProviderSetup)
|
||||
if (error) {
|
||||
toast({
|
||||
title: t('setupPage.toast.validationFailedTitle'),
|
||||
description: error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
if (currentStep === 3) {
|
||||
const error = validateModelSetup(modelSetup)
|
||||
if (error) {
|
||||
toast({
|
||||
title: t('setupPage.toast.validationFailedTitle'),
|
||||
description: error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 保存当前步骤
|
||||
const saved = await saveCurrentStep()
|
||||
@@ -306,7 +325,18 @@ function SetupPageContent() {
|
||||
setIsCompleting(true)
|
||||
|
||||
try {
|
||||
// 1. 保存最后一步的配置(硅基流动API Key)
|
||||
const error = validateModelSetup(modelSetup)
|
||||
if (error) {
|
||||
toast({
|
||||
title: t('setupPage.toast.validationFailedTitle'),
|
||||
description: error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsCompleting(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 保存最后一步的基础模型配置
|
||||
const saved = await saveCurrentStep()
|
||||
if (!saved) {
|
||||
setIsCompleting(false)
|
||||
@@ -357,11 +387,9 @@ function SetupPageContent() {
|
||||
case 1:
|
||||
return <PersonalityForm config={personality} onChange={setPersonality} />
|
||||
case 2:
|
||||
return <EmojiForm config={emoji} onChange={setEmoji} />
|
||||
return <ApiProviderSetupForm config={apiProviderSetup} onChange={setApiProviderSetup} />
|
||||
case 3:
|
||||
return <OtherBasicForm config={otherBasic} onChange={setOtherBasic} />
|
||||
case 4:
|
||||
return <SiliconFlowForm config={siliconFlow} onChange={setSiliconFlow} />
|
||||
return <ModelSetupForm config={modelSetup} onChange={setModelSetup} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -24,23 +24,19 @@ export interface PersonalityConfig {
|
||||
multiple_probability: number
|
||||
}
|
||||
|
||||
// 步骤3:表情包配置
|
||||
export interface EmojiConfig {
|
||||
emoji_send_num: number
|
||||
max_reg_num: number
|
||||
do_replace: boolean
|
||||
check_interval: number
|
||||
steal_emoji: boolean
|
||||
content_filtration: boolean
|
||||
filtration_prompt: string
|
||||
}
|
||||
|
||||
// 步骤4:其他基础配置
|
||||
export interface OtherBasicConfig {
|
||||
all_global: boolean // 全局黑话模式(expression.all_global_jargon)
|
||||
}
|
||||
|
||||
// 步骤5:硅基流动API配置
|
||||
export interface SiliconFlowConfig {
|
||||
// 步骤3:API 提供商配置
|
||||
export interface ApiProviderSetupConfig {
|
||||
provider_name: string
|
||||
base_url: string
|
||||
api_key: string
|
||||
}
|
||||
|
||||
// 步骤4:基础模型配置
|
||||
export interface ModelSetupConfig {
|
||||
planner_model_name: string
|
||||
planner_model_identifier: string
|
||||
planner_visual: boolean
|
||||
replyer_model_name: string
|
||||
replyer_model_identifier: string
|
||||
replyer_visual: boolean
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ export interface FieldSchema {
|
||||
properties?: ConfigSchema
|
||||
'x-widget'?: XWidgetType
|
||||
'x-icon'?: string
|
||||
'x-layout'?: 'inline-right'
|
||||
'x-input-width'?: string
|
||||
advanced?: boolean
|
||||
step?: number
|
||||
}
|
||||
|
||||
@@ -49,6 +52,7 @@ export interface ConfigSchema {
|
||||
uiParent?: string
|
||||
uiLabel?: string
|
||||
uiIcon?: string
|
||||
uiMergeChildren?: string[]
|
||||
}
|
||||
|
||||
export interface ConfigSchemaResponse {
|
||||
|
||||
@@ -36,6 +36,13 @@ export interface PluginManifest {
|
||||
homepage_url?: string
|
||||
/** 插件仓库地址(可选) */
|
||||
repository_url?: string
|
||||
/** Manifest v2 URL 集合(可选) */
|
||||
urls?: {
|
||||
repository?: string
|
||||
homepage?: string
|
||||
documentation?: string
|
||||
issues?: string
|
||||
}
|
||||
/** 插件关键词 */
|
||||
keywords: string[]
|
||||
/** 插件分类(可选) */
|
||||
|
||||
@@ -17,6 +17,10 @@ export default defineConfig({
|
||||
cookieDomainRewrite: '', // 移除域名限制
|
||||
cookiePathRewrite: '/', // 确保路径一致
|
||||
},
|
||||
'/maibot_statistics.html': {
|
||||
target: 'http://127.0.0.1:8001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
|
||||
Reference in New Issue
Block a user