From 75e94534959a489962c464f15e86b525a5aa0992 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Mon, 4 May 2026 12:46:55 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E4=BC=98=E5=8C=96webui=E5=A4=9A?= =?UTF-8?q?=E4=B8=AA=E9=A1=B5=E9=9D=A2=E7=9A=84=E4=BA=BA=E6=9C=BA=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=8F=92=E4=BB=B6=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E9=97=AE=E9=A2=98=EF=BC=8C=E6=94=BE=E5=AE=BD=E6=8F=92?= =?UTF-8?q?=E4=BB=B6id=E9=99=90=E5=88=B6=EF=BC=8C=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E9=AB=98=E7=BA=A7=E9=A1=B5=E9=9D=A2=E7=BC=A9=E8=BF=9B=EF=BC=8C?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E9=A1=B5=E9=9D=A2=E5=BF=AB=E6=8D=B7=E6=8C=89?= =?UTF-8?q?=E9=92=AE=EF=BC=8C=E4=BC=98=E5=8C=96=E6=96=B0=E6=89=8B=E5=BC=95?= =?UTF-8?q?=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dynamic-form/DynamicConfigForm.tsx | 267 ++++++---- .../components/dynamic-form/DynamicField.tsx | 24 +- dashboard/src/components/layout/constants.ts | 4 +- dashboard/src/i18n/locales/en.json | 75 +-- dashboard/src/i18n/locales/ja.json | 77 +-- dashboard/src/i18n/locales/ko.json | 75 +-- dashboard/src/i18n/locales/zh.json | 85 ++-- dashboard/src/lib/maisaka-monitor-client.ts | 4 + dashboard/src/lib/plugin-api/marketplace.ts | 44 +- dashboard/src/routes/index.tsx | 9 + .../src/routes/monitor/maisaka-monitor.tsx | 15 +- .../src/routes/monitor/use-maisaka-monitor.ts | 113 ++++- dashboard/src/routes/plugin-detail.tsx | 18 +- dashboard/src/routes/plugins/index.tsx | 10 +- dashboard/src/routes/setup/StepForms.tsx | 470 +++++------------- dashboard/src/routes/setup/api.ts | 320 +++++++----- dashboard/src/routes/setup/index.tsx | 160 +++--- dashboard/src/routes/setup/types.ts | 32 +- dashboard/src/types/plugin.ts | 7 + dashboard/vite.config.ts | 4 + pyproject.toml | 2 +- pytests/test_plugin_runtime.py | 8 + src/config/config.py | 2 +- src/config/official_configs.py | 20 + src/maisaka/monitor_events.py | 42 +- src/maisaka/runtime.py | 20 + .../runner/manifest_validator.py | 6 +- src/webui/app.py | 10 + src/webui/config_schema.py | 9 - 29 files changed, 1101 insertions(+), 831 deletions(-) diff --git a/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx b/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx index 105d0f0e..db458bdb 100644 --- a/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx +++ b/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx @@ -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 +} + +function AdvancedSettingsButton({ + active, + onClick, +}: { + active: boolean + onClick: () => void +}) { + return ( + + ) +} + +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 +}) { + const [advancedVisible, setAdvancedVisible] = React.useState(false) + const hasAdvanced = hasTopLevelAdvancedFields(nestedSchema) + + return ( + + +
+
+
+ + {sectionTitle} +
+ {sectionDescription && ( + {sectionDescription} + )} +
+ {hasAdvanced && ( + setAdvancedVisible((current) => !current)} + /> + )} +
+
+ + + +
+ ) } /** * 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 = ({ 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 ( = ({ schema={field} /> ) - } else { - // wrapper 模式:包装默认渲染 - return ( - onChange(field.name, v)} + schema={field} + > + onChange(field.name, v)} - schema={field} - > - onChange(field.name, v)} - fieldPath={fieldPath} - /> - - ) - } + fieldPath={fieldPath} + /> + + ) } - // 无 Hook,使用默认渲染 return ( = ({ ) } - /** 渲染 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 - } - - // 过滤出不属于 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) => ( + + {index > 0 && field.type !== 'boolean' && fields[index - 1]?.type !== 'boolean' && ( + + )} +
{renderField(field)}
+
+ ))} + ) return (
- {/* 渲染顶层字段 */} {topLevelFields.length > 0 && (
- {topLevelFields.map((field, index) => ( - - {index > 0 && field.type !== 'boolean' && topLevelFields[index - 1]?.type !== 'boolean' && ( - - )} -
{renderField(field)}
-
- ))} + {advancedVisible === undefined && advancedFields.length > 0 && ( +
+ setLocalAdvancedVisible((current) => !current)} + /> +
+ )} + {renderFieldList(visibleFields)}
)} - {/* 渲染嵌套 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 = ({ ? nestedSchema.classDoc : undefined - // 一级嵌套:使用 Card 包裹,清晰的 section 边界 if (level === 0) { return ( - - -
- {renderSectionIcon(nestedSchema.uiIcon)} - {sectionTitle} -
- {sectionDescription && ( - {sectionDescription} - )} -
- - ) || {}} - onChange={(field, value) => onChange(`${key}.${field}`, value)} - basePath={nestedFieldPath} - hooks={hooks} - level={level + 1} - /> - -
+ ) || {}} + onChange={(field, value) => onChange(`${key}.${field}`, value)} + basePath={nestedFieldPath} + hooks={hooks} + level={level + 1} + sectionTitle={sectionTitle} + sectionDescription={sectionDescription} + /> ) } - // 二级及更深嵌套:使用左侧指示条 + 轻量分组 return (
-
-
- {renderSectionIcon(nestedSchema.uiIcon)} -

{sectionTitle}

+
+
+
+ +

{sectionTitle}

+
+ {sectionDescription && ( +

+ {sectionDescription} +

+ )}
- {sectionDescription && ( -

- {sectionDescription} -

- )}
= ({ return (
- {/* Label with icon */} - +
+ {/* Label with icon */} + + + {/* Description */} + {schema.description && ( +

{schema.description}

+ )} +
{/* Input component */} {renderInputComponent()} - - {/* Description */} - {schema.description && ( -

{schema.description}

- )}
) } diff --git a/dashboard/src/components/layout/constants.ts b/dashboard/src/components/layout/constants.ts index 7de21c3d..f908fd0f 100644 --- a/dashboard/src/components/layout/constants.ts +++ b/dashboard/src/components/layout/constants.ts @@ -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' }, diff --git a/dashboard/src/i18n/locales/en.json b/dashboard/src/i18n/locales/en.json index a3bc03cf..3a7c43a9 100644 --- a/dashboard/src/i18n/locales/en.json +++ b/dashboard/src/i18n/locales/en.json @@ -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." } } }, diff --git a/dashboard/src/i18n/locales/ja.json b/dashboard/src/i18n/locales/ja.json index 3dd08798..c011e655 100644 --- a/dashboard/src/i18n/locales/ja.json +++ b/dashboard/src/i18n/locales/ja.json @@ -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": "より詳細なタスク割り当ては後で設定できます。" } } }, diff --git a/dashboard/src/i18n/locales/ko.json b/dashboard/src/i18n/locales/ko.json index 5a6f294d..7bb1487a 100644 --- a/dashboard/src/i18n/locales/ko.json +++ b/dashboard/src/i18n/locales/ko.json @@ -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": "더 자세한 작업 할당은 나중에 설정할 수 있습니다." } } }, diff --git a/dashboard/src/i18n/locales/zh.json b/dashboard/src/i18n/locales/zh.json index 4325f589..d15fc01a 100644 --- a/dashboard/src/i18n/locales/zh.json +++ b/dashboard/src/i18n/locales/zh.json @@ -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": "你可以稍后配置更详细的任务分配。" } } }, diff --git a/dashboard/src/lib/maisaka-monitor-client.ts b/dashboard/src/lib/maisaka-monitor-client.ts index 76d3f972..065cf97a 100644 --- a/dashboard/src/lib/maisaka-monitor-client.ts +++ b/dashboard/src/lib/maisaka-monitor-client.ts @@ -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 } diff --git a/dashboard/src/lib/plugin-api/marketplace.ts b/dashboard/src/lib/plugin-api/marketplace.ts index a7054088..0842a91a 100644 --- a/dashboard/src/lib/plugin-api/marketplace.ts +++ b/dashboard/src/lib/plugin-api/marketplace.ts @@ -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> { }) .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, diff --git a/dashboard/src/routes/index.tsx b/dashboard/src/routes/index.tsx index 30a88c3d..86555e85 100644 --- a/dashboard/src/routes/index.tsx +++ b/dashboard/src/routes/index.tsx @@ -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')} +
diff --git a/dashboard/src/routes/monitor/maisaka-monitor.tsx b/dashboard/src/routes/monitor/maisaka-monitor.tsx index 8591c3eb..84acfd24 100644 --- a/dashboard/src/routes/monitor/maisaka-monitor.tsx +++ b/dashboard/src/routes/monitor/maisaka-monitor.tsx @@ -104,10 +104,17 @@ function SessionSidebar({ )} >
- - {session.sessionName} - - +
+ {session.isGroupChat !== undefined && ( + + {session.isGroupChat ? '群' : '私'} + + )} + + {session.sessionName} + +
+ {session.eventCount}
diff --git a/dashboard/src/routes/monitor/use-maisaka-monitor.ts b/dashboard/src/routes/monitor/use-maisaka-monitor.ts index 3ccf705d..bba94207 100644 --- a/dashboard/src/routes/monitor/use-maisaka-monitor.ts +++ b/dashboard/src/routes/monitor/use-maisaka-monitor.ts @@ -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 = new Map() +let cachedSelectedSession: string | null = null export function useMaisakaMonitor() { - const [timeline, setTimeline] = useState([]) - const [sessions, setSessions] = useState>(new Map()) - const [selectedSession, setSelectedSession] = useState(null) + const [timeline, setTimeline] = useState(cachedTimeline) + const [sessions, setSessions] = useState>(new Map(cachedSessions)) + const [selectedSession, setSelectedSessionState] = useState(cachedSelectedSession) const [connected, setConnected] = useState(false) const unsubRef = useRef<(() => Promise) | null>(null) const handleEvent = useCallback((event: MaisakaMonitorEvent) => { - const sessionId = (event.data as unknown as Record).session_id as string - const timestamp = (event.data as unknown as Record).timestamp as number + const dataRecord = event.data as unknown as Record + 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) diff --git a/dashboard/src/routes/plugin-detail.tsx b/dashboard/src/routes/plugin-detail.tsx index 90e964fd..a2cff384 100644 --- a/dashboard/src/routes/plugin-detail.tsx +++ b/dashboard/src/routes/plugin-detail.tsx @@ -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({ diff --git a/dashboard/src/routes/plugins/index.tsx b/dashboard/src/routes/plugins/index.tsx index 51013669..ed529474 100644 --- a/dashboard/src/routes/plugins/index.tsx +++ b/dashboard/src/routes/plugins/index.tsx @@ -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' ) diff --git a/dashboard/src/routes/setup/StepForms.tsx b/dashboard/src/routes/setup/StepForms.tsx index d5fc1055..833d3085 100644 --- a/dashboard/src/routes/setup/StepForms.tsx +++ b/dashboard/src/routes/setup/StepForms.tsx @@ -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 (
@@ -254,53 +235,6 @@ export function BotBasicForm({ config, onChange }: BotBasicFormProps) { {t('setupPage.forms.botBasic.nickname.description')}

- -
- -
- {config.alias_names.map((alias, index) => ( - - {alias} - - - ))} -
-
- { - if (e.key === 'Enter') { - handleAddAlias((e.target as HTMLInputElement).value) - ;(e.target as HTMLInputElement).value = '' - } - }} - /> - -
-

- {t('setupPage.forms.botBasic.alias.description')} -

-
) } @@ -313,7 +247,6 @@ interface PersonalityFormProps { export function PersonalityForm({ config, onChange }: PersonalityFormProps) { const { t } = useTranslation() - const multipleReplyStyleText = config.multiple_reply_style.join('\n') return (
@@ -344,276 +277,61 @@ export function PersonalityForm({ config, onChange }: PersonalityFormProps) { {t('setupPage.forms.personality.replyStyle.description')}

- -
- -