feat:优化webui多个页面的人机交互,修复插件地址问题,放宽插件id限制,增加高级页面缩进,统计页面快捷按钮,优化新手引导

This commit is contained in:
SengokuCola
2026-05-04 12:46:55 +08:00
parent 75665a4d38
commit 75e9453495
29 changed files with 1101 additions and 831 deletions

View File

@@ -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,142 @@ 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 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 ? 'secondary' : 'outline'}
size="sm"
onClick={onClick}
>
</Button>
)
}
function DynamicConfigSection({
basePath,
hooks,
level,
nestedSchema,
onChange,
sectionDescription,
sectionTitle,
values,
}: {
basePath: string
hooks: FieldHookRegistry
level: number
nestedSchema: ConfigSchema
onChange: (field: string, value: unknown) => void
sectionDescription?: string
sectionTitle: string
values: Record<string, unknown>
}) {
const [advancedVisible, setAdvancedVisible] = React.useState(false)
const hasAdvanced = hasTopLevelAdvancedFields(nestedSchema)
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={onChange}
basePath={basePath}
hooks={hooks}
level={level}
advancedVisible={hasAdvanced ? advancedVisible : undefined}
/>
</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 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 +165,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 +194,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 && field.type !== 'boolean' && fields[index - 1]?.type !== 'boolean' && (
<Separator className="my-1" />
)}
<div>{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>
))}
{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]) => {
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
@@ -192,49 +285,39 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
? nestedSchema.classDoc
: undefined
// 一级嵌套:使用 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>
<DynamicConfigSection
key={key}
nestedSchema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
onChange={(field, value) => onChange(`${key}.${field}`, value)}
basePath={nestedFieldPath}
hooks={hooks}
level={level + 1}
sectionTitle={sectionTitle}
sectionDescription={sectionDescription}
/>
)
}
// 二级及更深嵌套:使用左侧指示条 + 轻量分组
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 className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<SectionIcon iconName={nestedSchema.uiIcon} />
<h4 className="text-sm font-semibold">{sectionTitle}</h4>
</div>
{sectionDescription && (
<p className="text-xs text-muted-foreground">
{sectionDescription}
</p>
)}
</div>
{sectionDescription && (
<p className="text-xs text-muted-foreground">
{sectionDescription}
</p>
)}
</div>
<DynamicConfigForm

View File

@@ -313,20 +313,22 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
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>
<div className="space-y-0.5">
{/* 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>
{/* Description */}
{schema.description && (
<p className="text-[13px] text-muted-foreground whitespace-pre-line">{schema.description}</p>
)}
</div>
{/* Input component */}
{renderInputComponent()}
{/* Description */}
{schema.description && (
<p className="text-[13px] text-muted-foreground whitespace-pre-line">{schema.description}</p>
)}
</div>
)
}

View File

@@ -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, Server, Settings, Sliders, Smile, UserCircle } from 'lucide-react'
import type { MenuSection } from './types'
@@ -15,7 +15,6 @@ 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' },
],
},
{
@@ -33,7 +32,6 @@ export const menuSections: MenuSection[] = [
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' },

View File

@@ -500,17 +500,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 +524,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 +668,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."
}
}
},

View File

@@ -500,17 +500,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 +524,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 +668,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": "より詳細なタスク割り当ては後で設定できます。"
}
}
},

View File

@@ -500,17 +500,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 +524,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 +668,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": "더 자세한 작업 할당은 나중에 설정할 수 있습니다."
}
}
},

View File

@@ -500,17 +500,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 +524,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 +560,8 @@
"footer": "您可以随时在设置中修改这些配置",
"defaults": {
"personality": {
"personality": "是一个女大学生,现在在读大二,会刷贴吧。",
"replyStyle": "请回复得平淡一些,简短一些,说中文,不要刻意突出自身学科背景。可以参考贴吧知乎和微博的回复风格。",
"personality": "是一个大二女大学生,现在正在上网和群友聊天。有轻微攻击性",
"replyStyle": "你的风格平淡简短。可以参考贴吧知乎和微博的回复风格。不浮夸不长篇大论,不要过分修辞和复杂句。",
"multipleReplyStyles": {
"plain": "你的风格平淡但不失讽刺,很简短,很白话。可以参考贴吧、微博的回复风格。",
"shortText": "用1-2个字进行回复",
@@ -577,7 +578,7 @@
"platform": {
"label": "平台 *",
"placeholder": "请选择平台",
"description": "选择机器人运行的平台",
"description": "选择麦麦Bot运行的平台",
"options": {
"custom": "其他平台"
}
@@ -589,7 +590,7 @@
"qqAccount": {
"label": "QQ账号 *",
"placeholder": "请输入机器人的 QQ 账号",
"description": "机器人登录使用的 QQ 账号"
"description": "运行麦麦Bot的 QQ 账号"
},
"primaryAccount": {
"label": "账号 ID *",
@@ -599,7 +600,7 @@
"nickname": {
"label": "昵称 *",
"placeholder": "请输入机器人的昵称",
"description": "机器人的主要称呼名称"
"description": "麦麦Bot的名称"
},
"alias": {
"label": "别名",
@@ -667,33 +668,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": "你可以稍后配置更详细的任务分配。"
}
}
},

View File

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

View File

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

View File

@@ -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'
@@ -566,6 +568,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>

View File

@@ -104,10 +104,17 @@ function SessionSidebar({
)}
>
<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="flex min-w-0 items-center gap-1.5">
{session.isGroupChat !== undefined && (
<Badge variant="outline" className="h-4 shrink-0 px-1 text-[10px]">
{session.isGroupChat ? '群' : '私'}
</Badge>
)}
<span className="truncate font-medium" title={session.sessionName}>
{session.sessionName}
</span>
</div>
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[10px]">
{session.eventCount}
</Badge>
</div>

View File

@@ -26,6 +26,10 @@ export interface TimelineEntry {
export interface SessionInfo {
sessionId: string
sessionName: string
isGroupChat?: boolean
groupId?: string | null
userId?: string | null
platform?: string
lastActivity: number
eventCount: number
}
@@ -33,18 +37,62 @@ export interface SessionInfo {
/** 最大保留的时间线条目数 */
const MAX_TIMELINE_ENTRIES = 500
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
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 [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(false)
const unsubRef = useRef<(() => Promise<void>) | null>(null)
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
const dataRecord = event.data as unknown as Record<string, unknown>
const sessionId = dataRecord.session_id as string
const timestamp = dataRecord.timestamp as number
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 entry: TimelineEntry = {
id: `evt_${++entryCounter}_${Date.now()}`,
@@ -56,22 +104,34 @@ export function useMaisakaMonitor() {
setTimeline((prev) => {
const next = [...prev, entry]
return next.length > MAX_TIMELINE_ENTRIES
const trimmed = next.length > MAX_TIMELINE_ENTRIES
? next.slice(next.length - MAX_TIMELINE_ENTRIES)
: next
cachedTimeline = trimmed
return trimmed
})
// 更新会话信息
if (event.type === 'session.start') {
const d = event.data
setSessions((prev) => {
const next = new Map(prev)
next.set(sessionId, {
sessionId,
sessionName: d.session_name,
sessionName: resolveSessionDisplayName({
fallbackName: sessionName,
groupId,
isGroupChat,
sessionId,
userId,
}),
isGroupChat,
groupId,
userId,
platform,
lastActivity: timestamp,
eventCount: (prev.get(sessionId)?.eventCount ?? 0) + 1,
})
cachedSessions = next
return next
})
} else {
@@ -81,24 +141,51 @@ export function useMaisakaMonitor() {
const next = new Map(prev)
next.set(sessionId, {
sessionId,
sessionName: sessionId.slice(0, 8),
sessionName: resolveSessionDisplayName({
fallbackName: sessionName,
groupId,
isGroupChat,
sessionId,
userId,
}),
isGroupChat,
groupId,
userId,
platform,
lastActivity: timestamp,
eventCount: 1,
})
cachedSessions = next
return next
}
const next = new Map(prev)
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,
})
cachedSessions = next
return next
})
}
// 自动选中第一个会话
setSelectedSession((current) => current ?? sessionId)
setSelectedSessionState((current) => {
const next = current ?? sessionId
cachedSelectedSession = next
return next
})
}, [])
useEffect(() => {
@@ -124,9 +211,15 @@ export function useMaisakaMonitor() {
}, [handleEvent])
const clearTimeline = useCallback(() => {
cachedTimeline = []
setTimeline([])
}, [])
const setSelectedSession = useCallback((sessionId: string | null) => {
cachedSelectedSession = sessionId
setSelectedSessionState(sessionId)
}, [])
/** 当前选中会话的时间线 */
const filteredTimeline = selectedSession
? timeline.filter((e) => e.sessionId === selectedSession)

View File

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

View File

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

View File

@@ -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'
// ====== 步骤1Bot基础配置 ======
@@ -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
// ====== 步骤3API 提供商配置 ======
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>
)
}

View File

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

View File

@@ -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,
// 步骤3API 提供商配置
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
}

View File

@@ -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 {
// 步骤3API 提供商配置
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
}

View File

@@ -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[]
/** 插件分类(可选) */

View File

@@ -17,6 +17,10 @@ export default defineConfig({
cookieDomainRewrite: '', // 移除域名限制
cookiePathRewrite: '/', // 确保路径一致
},
'/maibot_statistics.html': {
target: 'http://127.0.0.1:8001',
changeOrigin: true,
},
},
},
resolve: {

View File

@@ -19,7 +19,7 @@ dependencies = [
"jieba>=0.42.1",
"json-repair>=0.47.6",
"maim-message>=0.6.2",
"maibot-dashboard==1.0.1.dev2026050251",
"maibot-dashboard>=1.0.2.dev2026050359",
"maibot-plugin-sdk>=2.4.0",
"matplotlib>=3.10.5",
"mcp",

View File

@@ -995,6 +995,14 @@ class TestManifestValidator:
assert len(validator.errors) == 0
assert validator.warnings == []
def test_manifest_id_allows_uppercase_and_underscore(self):
from src.plugin_runtime.runner.manifest_validator import ManifestValidator
validator = ManifestValidator(host_version="1.0.0", sdk_version="2.0.1")
manifest = build_test_manifest("XXXxx7258.google_search_plugin", capabilities=["send.text"])
assert validator.validate(manifest) is True
assert validator.errors == []
def test_missing_required_fields(self):
from src.plugin_runtime.runner.manifest_validator import ManifestValidator

View File

@@ -58,7 +58,7 @@ MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute(
LEGACY_ENV_PATH: Path = (PROJECT_ROOT / ".env").resolve().absolute()
A_MEMORIX_LEGACY_CONFIG_PATH: Path = (CONFIG_DIR / "a_memorix.toml").resolve().absolute()
MMC_VERSION: str = "1.0.0"
CONFIG_VERSION: str = "8.9.21"
CONFIG_VERSION: str = "8.10.1"
MODEL_CONFIG_VERSION: str = "1.14.6"
logger = get_logger("config")

View File

@@ -63,6 +63,7 @@ class BotConfig(ConfigBase):
json_schema_extra={
"x-widget": "custom",
"x-icon": "tags",
"advanced": True,
},
)
"""别名列表"""
@@ -472,6 +473,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={
"x-widget": "switch",
"x-icon": "message-circle-warning",
"advanced": True,
},
)
"""是否启用反馈驱动的延迟记忆纠错任务"""
@@ -482,6 +484,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={
"x-widget": "input",
"x-icon": "clock-4",
"advanced": True,
},
)
"""反馈窗口时长(小时),以 query_memory 执行时间为起点"""
@@ -492,6 +495,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={
"x-widget": "input",
"x-icon": "timer",
"advanced": True,
},
)
"""反馈纠错定时任务轮询间隔(分钟)"""
@@ -503,6 +507,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={
"x-widget": "input",
"x-icon": "list-ordered",
"advanced": True,
},
)
"""反馈纠错每轮最大处理任务数"""
@@ -515,6 +520,7 @@ class MemoryConfig(ConfigBase):
"x-widget": "slider",
"x-icon": "gauge",
"step": 0.01,
"advanced": True,
},
)
"""自动应用纠错动作的最低置信度阈值"""
@@ -526,6 +532,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={
"x-widget": "input",
"x-icon": "messages-square",
"advanced": True,
},
)
"""每个纠错任务最多使用的窗口内用户反馈消息数"""
@@ -535,6 +542,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={
"x-widget": "switch",
"x-icon": "filter",
"advanced": True,
},
)
"""是否启用纠错前置预筛(用于减少不必要的模型调用)"""
@@ -544,6 +552,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={
"x-widget": "switch",
"x-icon": "sticky-note",
"advanced": True,
},
)
"""是否为受影响 paragraph 写入已纠正旧事实标记"""
@@ -553,6 +562,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={
"x-widget": "switch",
"x-icon": "eye-off",
"advanced": True,
},
)
"""是否在用户侧查询中硬过滤带有 stale 标记的 paragraph"""
@@ -562,6 +572,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={
"x-widget": "switch",
"x-icon": "user-round-search",
"advanced": True,
},
)
"""是否在反馈纠错后将受影响人物画像加入刷新队列"""
@@ -571,6 +582,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={
"x-widget": "switch",
"x-icon": "refresh-ccw",
"advanced": True,
},
)
"""人物画像处于脏队列时,读取是否强制刷新而不直接复用旧快照"""
@@ -580,6 +592,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={
"x-widget": "switch",
"x-icon": "clapperboard",
"advanced": True,
},
)
"""是否在反馈纠错后将受影响 source 加入 episode 重建队列"""
@@ -589,6 +602,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={
"x-widget": "switch",
"x-icon": "ban",
"advanced": True,
},
)
"""episode source 处于重建队列时,是否对用户侧查询做屏蔽"""
@@ -599,6 +613,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={
"x-widget": "input",
"x-icon": "repeat",
"advanced": True,
},
)
"""反馈纠错二阶段一致性后台协调任务轮询间隔(分钟)"""
@@ -610,6 +625,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={
"x-widget": "input",
"x-icon": "list-restart",
"advanced": True,
},
)
"""反馈纠错二阶段一致性每轮处理 profile/episode 队列的批大小"""
@@ -1350,6 +1366,7 @@ class ChineseTypoConfig(ConfigBase):
"x-widget": "slider",
"x-icon": "percent",
"step": 0.01,
"advanced": True,
},
)
"""单字替换概率"""
@@ -1359,6 +1376,7 @@ class ChineseTypoConfig(ConfigBase):
json_schema_extra={
"x-widget": "input",
"x-icon": "hash",
"advanced": True,
},
)
"""最小字频阈值"""
@@ -1371,6 +1389,7 @@ class ChineseTypoConfig(ConfigBase):
"x-widget": "slider",
"x-icon": "percent",
"step": 0.1,
"advanced": True,
},
)
"""声调错误概率"""
@@ -1383,6 +1402,7 @@ class ChineseTypoConfig(ConfigBase):
"x-widget": "slider",
"x-icon": "percent",
"step": 0.001,
"advanced": True,
},
)
"""整词替换概率"""

View File

@@ -143,6 +143,33 @@ def _serialize_messages(messages: List[Any]) -> List[Dict[str, Any]]:
return [_serialize_message(message) for message in messages]
def _enrich_session_identity(data: Dict[str, Any]) -> Dict[str, Any]:
"""为监控事件补充会话展示所需的群/用户标识。"""
session_id = data.get("session_id")
if not session_id:
return data
try:
from src.chat.message_receive.chat_manager import chat_manager
chat_stream = chat_manager.get_session_by_session_id(str(session_id))
except Exception:
return data
if chat_stream is None:
return data
session_name = chat_manager.get_session_name(str(session_id))
if session_name:
data.setdefault("session_name", session_name)
data.setdefault("is_group_chat", chat_stream.is_group_session)
data.setdefault("group_id", chat_stream.group_id)
data.setdefault("user_id", chat_stream.user_id)
data.setdefault("platform", chat_stream.platform)
return data
def _serialize_tool_results(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""标准化最终 planner 卡中的工具结果列表。"""
@@ -266,6 +293,7 @@ async def _broadcast(event: str, data: Dict[str, Any]) -> None:
try:
from src.webui.routers.websocket.manager import websocket_manager
data = _enrich_session_identity(data)
subscription_key = f"{MONITOR_DOMAIN}:{MONITOR_TOPIC}"
total_connections = len(websocket_manager.connections)
subscriber_count = sum(
@@ -291,12 +319,24 @@ async def _broadcast(event: str, data: Dict[str, Any]) -> None:
logger.warning(f"MaiSaka 监控事件广播失败: {exc}", exc_info=True)
async def emit_session_start(session_id: str, session_name: str) -> None:
async def emit_session_start(
session_id: str,
session_name: str,
*,
is_group_chat: bool,
group_id: Optional[str],
user_id: Optional[str],
platform: str,
) -> None:
"""广播会话开始事件。"""
await _broadcast("session.start", {
"session_id": session_id,
"session_name": session_name,
"is_group_chat": is_group_chat,
"group_id": group_id,
"user_id": user_id,
"platform": platform,
"timestamp": time.time(),
})

View File

@@ -46,6 +46,7 @@ from .display.display_utils import build_tool_call_summary_lines, format_token_c
from .display.prompt_cli_renderer import PromptCLIVisualizer
from .display.stage_status_board import remove_stage_status, update_stage_status
from .history_utils import drop_leading_orphan_tool_results
from .monitor_events import emit_session_start
from .reasoning_engine import MaisakaReasoningEngine
from .reply_effect import ReplyEffectTracker
from .reply_effect.image_utils import extract_visual_attachments_from_sequence
@@ -136,6 +137,7 @@ class MaisakaHeartFlowChatting:
self._jargon_miner = JargonMiner(session_id, session_name=session_name)
self._reasoning_engine = MaisakaReasoningEngine(self)
self._monitor_session_start_task: Optional[asyncio.Task[None]] = None
self._tool_registry = ToolRegistry()
self._reply_effect_tracker = ReplyEffectTracker(
session_id=self.session_id,
@@ -144,6 +146,24 @@ class MaisakaHeartFlowChatting:
judge_runner=self._run_reply_effect_judge,
)
self._register_tool_providers()
self._emit_monitor_session_start()
def _emit_monitor_session_start(self) -> None:
"""向 WebUI 监控面板同步当前会话的展示标识。"""
try:
self._monitor_session_start_task = asyncio.create_task(
emit_session_start(
session_id=self.session_id,
session_name=self.session_name,
is_group_chat=self.chat_stream.is_group_session,
group_id=self.chat_stream.group_id,
user_id=self.chat_stream.user_id,
platform=self.chat_stream.platform,
)
)
except RuntimeError:
logger.debug("MaiSaka 监控会话开始事件未发送:当前没有运行中的事件循环")
@staticmethod
def _is_reply_effect_tracking_enabled() -> bool:

View File

@@ -24,7 +24,7 @@ from src.common.logger import get_logger
logger = get_logger("plugin_runtime.runner.manifest_validator")
_SEMVER_PATTERN = re.compile(r"^\d+\.\d+\.\d+$")
_PLUGIN_ID_PATTERN = re.compile(r"^[a-z0-9]+(?:[.-][a-z0-9]+)+$")
_PLUGIN_ID_PATTERN = re.compile(r"^[A-Za-z0-9_]+(?:[.-][A-Za-z0-9_]+)+$")
_PACKAGE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
_HTTP_URL_PATTERN = re.compile(r"^https?://.+$")
@@ -379,7 +379,7 @@ class PluginDependencyDefinition(_StrictManifestModel):
ValueError: 当 ID 不符合规则时抛出。
"""
if not _PLUGIN_ID_PATTERN.fullmatch(value):
raise ValueError("必须使用小写字母/数字,并以点号或横线分隔,例如 github.author.plugin")
raise ValueError("必须使用字母/数字/下划线,并以点号或横线分隔,例如 github.author.plugin")
return value
@field_validator("version_spec")
@@ -548,7 +548,7 @@ class PluginManifest(_StrictManifestModel):
if not value:
raise ValueError("不能为空")
if info.field_name == "id" and not _PLUGIN_ID_PATTERN.fullmatch(value):
raise ValueError("必须使用小写字母/数字,并以点号或横线分隔,例如 github.author.plugin")
raise ValueError("必须使用字母/数字/下划线,并以点号或横线分隔,例如 github.author.plugin")
return value
@field_validator("capabilities")

View File

@@ -185,6 +185,16 @@ def _setup_static_files(app: FastAPI):
logger.warning(t("startup.webui_dashboard_package_hint", command=_MANUAL_INSTALL_COMMAND))
return
@app.get("/maibot_statistics.html", include_in_schema=False)
async def serve_statistics_report():
report_path = (_get_project_root() / "maibot_statistics.html").resolve()
if not report_path.exists() or not report_path.is_file():
raise HTTPException(status_code=404, detail=t("core.not_found"))
response = FileResponse(report_path, media_type="text/html")
response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive"
return response
@app.get("/{full_path:path}", include_in_schema=False)
async def serve_spa(full_path: str):
if not full_path or full_path == "/":

View File

@@ -19,8 +19,6 @@ class ConfigSchemaGenerator:
for field_name, field_info in config_class.model_fields.items():
if field_name in {"field_docs", "_validate_any", "suppress_any_warning"}:
continue
if cls._is_advanced_field(field_info):
continue
field_schema = cls._build_field_schema(config_class, field_name, field_info.annotation, field_info)
fields.append(field_schema)
@@ -50,13 +48,6 @@ class ConfigSchemaGenerator:
return schema
@staticmethod
def _is_advanced_field(field_info: Any) -> bool:
extra = getattr(field_info, "json_schema_extra", None)
if not isinstance(extra, dict):
return False
return extra.get("advanced", False) is True
@classmethod
def _build_nested_schema(cls, annotation: Any) -> Dict[str, Any] | None:
origin = get_origin(annotation)