merge: 同步 upstream/r-dev 并解决冲突

This commit is contained in:
DawnARC
2026-04-03 19:56:45 +08:00
186 changed files with 14212 additions and 6705 deletions

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ data/
!pytests/A_memorix_test/data/real_dialogues/private_alice_weekend.json
pytests/A_memorix_test/data/benchmarks/results/
data1/
mai_knowledge/knowledge.json
mongodb/
NapCat.Framework.Windows.Once/
NapCat.Framework.Windows.OneKey/

View File

@@ -1,5 +1,14 @@
import * as React from 'react'
import * as LucideIcons from 'lucide-react'
import {
Card,
CardContent,
CardDescription,
CardHeader,
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'
@@ -9,7 +18,10 @@ export interface DynamicConfigFormProps {
schema: ConfigSchema
values: Record<string, unknown>
onChange: (field: string, value: unknown) => void
basePath?: string
hooks?: FieldHookRegistry
/** 嵌套层级0 = tab 内容层, 1 = section 内容层, 2+ = 更深嵌套 */
level?: number
}
/**
@@ -19,21 +31,32 @@ export interface DynamicConfigFormProps {
* 1. Hook 系统:通过 FieldHookRegistry 自定义字段渲染
* - replace 模式:完全替换默认渲染
* - wrapper 模式:包装默认渲染(通过 children 传递)
* 2. 嵌套 schema递归渲染 schema.nested 中的子配置
* 2. 嵌套 schema递归渲染 schema.nested 中的子配置,使用 Card 容器区分层级
* 3. 默认渲染:使用 DynamicField 组件
*/
export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
schema,
values,
onChange,
basePath = '',
hooks = fieldHooks, // 默认使用全局单例
level = 0,
}) => {
const fieldMap = React.useMemo(
() => new Map(schema.fields.map((field) => [field.name, field])),
[schema.fields]
)
const buildFieldPath = (fieldName: string) => {
return basePath ? `${basePath}.${fieldName}` : fieldName
}
/**
* 渲染单个字段
* 检查是否有注册的 Hook根据 Hook 类型选择渲染方式
*/
const renderField = (field: FieldSchema) => {
const fieldPath = field.name
const fieldPath = buildFieldPath(field.name)
// 检查是否有注册的 Hook
if (hooks.has(fieldPath)) {
@@ -49,6 +72,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
fieldPath={fieldPath}
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
schema={field}
/>
)
} else {
@@ -58,6 +82,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
fieldPath={fieldPath}
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
schema={field}
>
<DynamicField
schema={field}
@@ -81,34 +106,146 @@ 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]
)
return (
<div className="space-y-4">
<div className="space-y-6">
{/* 渲染顶层字段 */}
{schema.fields.map((field) => (
<div key={field.name}>{renderField(field)}</div>
))}
{topLevelFields.length > 0 && (
<div className="space-y-1">
{topLevelFields.map((field, index) => (
<React.Fragment key={field.name}>
{index > 0 && field.type !== 'boolean' && topLevelFields[index - 1]?.type !== 'boolean' && (
<Separator className="my-1" />
)}
<div>{renderField(field)}</div>
</React.Fragment>
))}
</div>
)}
{/* 渲染嵌套 schema */}
{schema.nested &&
Object.entries(schema.nested).map(([key, nestedSchema]) => (
<div key={key} className="mt-6 space-y-4">
{/* 嵌套 schema 标题 */}
<div className="border-b pb-2">
<h3 className="text-lg font-semibold">{nestedSchema.className}</h3>
{nestedSchema.classDoc && (
<p className="text-sm text-muted-foreground">{nestedSchema.classDoc}</p>
)}
</div>
Object.entries(schema.nested).map(([key, nestedSchema]) => {
const nestedField = fieldMap.get(key)
const nestedFieldPath = buildFieldPath(key)
{/* 递归渲染嵌套表单 */}
<DynamicConfigForm
schema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
onChange={(field, value) => onChange(`${key}.${field}`, value)}
hooks={hooks}
/>
</div>
))}
// Hook 系统处理
if (hooks.has(nestedFieldPath)) {
const hookEntry = hooks.get(nestedFieldPath)
if (!hookEntry) return null
const HookComponent = hookEntry.component
if (hookEntry.type === 'replace') {
return (
<div key={key}>
<HookComponent
fieldPath={nestedFieldPath}
value={values[key]}
onChange={(v) => onChange(key, v)}
schema={nestedField ?? nestedSchema}
/>
</div>
)
}
return (
<div key={key}>
<HookComponent
fieldPath={nestedFieldPath}
value={values[key]}
onChange={(v) => onChange(key, v)}
schema={nestedField ?? nestedSchema}
>
<DynamicConfigForm
schema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
onChange={(field, value) => onChange(`${key}.${field}`, value)}
basePath={nestedFieldPath}
hooks={hooks}
level={level + 1}
/>
</HookComponent>
</div>
)
}
const sectionTitle =
nestedSchema.uiLabel || nestedSchema.classDoc || nestedSchema.className
const sectionDescription =
nestedSchema.classDoc && nestedSchema.classDoc !== sectionTitle
? 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>
)
}
// 二级及更深嵌套:使用左侧指示条 + 轻量分组
return (
<div
key={key}
className="relative space-y-4 rounded-lg border-l-2 border-muted-foreground/20 pl-4 pt-1 pb-1"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
{renderSectionIcon(nestedSchema.uiIcon)}
<h4 className="text-sm font-semibold">{sectionTitle}</h4>
</div>
{sectionDescription && (
<p className="text-xs text-muted-foreground">
{sectionDescription}
</p>
)}
</div>
<DynamicConfigForm
schema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
onChange={(field, value) => onChange(`${key}.${field}`, value)}
basePath={nestedFieldPath}
hooks={hooks}
level={level + 1}
/>
</div>
)
})}
</div>
)
}

View File

@@ -2,6 +2,7 @@ import * as React from "react"
import * as LucideIcons from "lucide-react"
import { Input } from "@/components/ui/input"
import { KeyValueEditor } from "@/components/ui/key-value-editor"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Slider } from "@/components/ui/slider"
@@ -29,6 +30,57 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
value,
onChange,
}) => {
const renderPrimitiveArrayEditor = () => {
const itemType = schema.items?.type ?? 'string'
const arrayValue = Array.isArray(value)
? value
: Array.isArray(schema.default)
? schema.default
: []
const textareaValue = arrayValue.map((item) => String(item ?? '')).join('\n')
return (
<Textarea
value={textareaValue}
onChange={(e) => {
const nextItems = e.target.value
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => {
if (itemType === 'integer') {
return parseInt(line, 10) || 0
}
if (itemType === 'number') {
return parseFloat(line) || 0
}
if (itemType === 'boolean') {
return line === 'true'
}
return line
})
onChange(nextItems)
}}
rows={Math.max(4, arrayValue.length || 4)}
/>
)
}
const renderObjectEditor = () => {
const objectValue =
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
return (
<KeyValueEditor
value={objectValue}
onChange={onChange}
/>
)
}
/**
* 渲染字段图标
*/
@@ -53,6 +105,12 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
switch (widget) {
case 'slider':
return renderSlider()
case 'input':
return renderTextInput()
case 'number':
return renderNumberInput()
case 'password':
return renderTextInput('password')
case 'switch':
return renderSwitch()
case 'textarea':
@@ -60,6 +118,12 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
case 'select':
return renderSelect()
case 'custom':
if (type === 'array' && schema.items && schema.items.type !== 'object') {
return renderPrimitiveArrayEditor()
}
if (type === 'object') {
return renderObjectEditor()
}
return (
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
Custom field requires Hook
@@ -83,17 +147,16 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
case 'select':
return renderSelect()
case 'array':
return (
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
Array fields not yet supported
</div>
)
if (!schema.items || schema.items.type === 'object') {
return (
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
Complex array requires Hook
</div>
)
}
return renderPrimitiveArrayEditor()
case 'object':
return (
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
Object fields not yet supported
</div>
)
return renderObjectEditor()
case 'textarea':
return renderTextarea()
default:
@@ -107,14 +170,27 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
/**
* 渲染 Switch 组件(用于 boolean 类型)
* 使用水平布局:标签+描述在左,开关在右
*/
const renderSwitch = () => {
const checked = Boolean(value)
return (
<Switch
checked={checked}
onCheckedChange={(checked) => onChange(checked)}
/>
<div className="flex items-center justify-between rounded-lg border p-3 sm:p-4">
<div className="space-y-0.5 pr-4">
<Label className="text-sm font-medium flex items-center gap-2">
{renderIcon()}
{schema.label}
{schema.required && <span className="text-destructive">*</span>}
</Label>
{schema.description && (
<p className="text-[13px] text-muted-foreground">{schema.description}</p>
)}
</div>
<Switch
checked={checked}
onCheckedChange={(checked) => onChange(checked)}
/>
</div>
)
}
@@ -169,11 +245,11 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
/**
* 渲染 Input[type="text"] 组件(用于 string 类型)
*/
const renderTextInput = () => {
const renderTextInput = (type: 'password' | 'text' = 'text') => {
const strValue = typeof value === 'string' ? value : (schema.default as string ?? '')
return (
<Input
type="text"
type={type}
value={strValue}
onChange={(e) => onChange(e.target.value)}
/>
@@ -225,6 +301,16 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
)
}
// 判断当前字段是否为 Switch/Boolean 类型(独立处理布局)
const isBoolean =
schema['x-widget'] === 'switch' ||
(!schema['x-widget'] && schema.type === 'boolean')
// Switch/Boolean 字段自带完整布局,直接返回
if (isBoolean) {
return renderInputComponent()
}
return (
<div className="space-y-2">
{/* Label with icon */}
@@ -239,7 +325,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
{/* Description */}
{schema.description && (
<p className="text-sm text-muted-foreground">{schema.description}</p>
<p className="text-[13px] text-muted-foreground">{schema.description}</p>
)}
</div>
)

View File

@@ -85,7 +85,6 @@ describe('DynamicConfigForm', () => {
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
expect(screen.getByText('Top Field')).toBeInTheDocument()
expect(screen.getByText('SubConfig')).toBeInTheDocument()
expect(screen.getByText('Sub configuration')).toBeInTheDocument()
expect(screen.getByText('Nested Field')).toBeInTheDocument()
})
@@ -305,6 +304,71 @@ describe('DynamicConfigForm', () => {
expect(onChange).toHaveBeenCalledWith('hooked_field', 'hook_value')
})
it('renders nested Hook component with full field path', async () => {
const NestedHookComponent: React.FC<FieldHookComponentProps> = ({ fieldPath, onChange }) => {
return (
<button onClick={() => onChange?.([{ enabled: true }])}>
{fieldPath}
</button>
)
}
const hooks = new FieldHookRegistry()
hooks.register('mcp.servers', NestedHookComponent, 'replace')
const schema: ConfigSchema = {
className: 'RootConfig',
classDoc: 'Root configuration',
fields: [],
nested: {
mcp: {
className: 'MCPConfig',
classDoc: 'MCP 配置',
fields: [
{
name: 'enable',
type: 'boolean',
label: '启用 MCP',
description: '是否启用 MCP',
required: false,
},
{
name: 'servers',
type: 'array',
label: '服务器列表',
description: '复杂对象数组',
required: false,
items: {
type: 'object',
},
},
],
nested: {
servers: {
className: 'MCPServerItemConfig',
classDoc: 'MCP 服务器项',
fields: [],
},
},
},
},
}
const values = {
mcp: {
enable: true,
servers: [],
},
}
const onChange = vi.fn()
const user = userEvent.setup()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
await user.click(screen.getByRole('button', { name: 'mcp.servers' }))
expect(onChange).toHaveBeenCalledWith('mcp.servers', [{ enabled: true }])
})
})
describe('edge cases', () => {
@@ -334,7 +398,7 @@ describe('DynamicConfigForm', () => {
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
expect(screen.getByText('SubConfig')).toBeInTheDocument()
expect(screen.getByText('Sub configuration')).toBeInTheDocument()
expect(screen.getByText('Nested Field')).toBeInTheDocument()
})

View File

@@ -207,22 +207,25 @@ describe('DynamicField', () => {
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
it('renders placeholder for array type', () => {
it('renders textarea editor for primitive array type', () => {
const schema: FieldSchema = {
name: 'test_array',
type: 'array',
label: 'Test Array',
description: 'A test array',
required: false,
items: {
type: 'string',
},
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={[]} onChange={onChange} />)
render(<DynamicField schema={schema} value={['a', 'b']} onChange={onChange} />)
expect(screen.getByText('Array fields not yet supported')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toHaveValue('a\nb')
})
it('renders placeholder for object type', () => {
it('renders key-value editor for object type', () => {
const schema: FieldSchema = {
name: 'test_object',
type: 'object',
@@ -232,9 +235,10 @@ describe('DynamicField', () => {
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={{}} onChange={onChange} />)
render(<DynamicField schema={schema} value={{ foo: 'bar' }} onChange={onChange} />)
expect(screen.getByText('Object fields not yet supported')).toBeInTheDocument()
expect(screen.getByText('可视化编辑')).toBeInTheDocument()
expect(screen.getByDisplayValue('foo')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,161 @@
import { unifiedWsClient, type ConnectionStatus } from './unified-ws'
interface ChatSessionOpenPayload {
group_id?: string
group_name?: string
person_id?: string
platform?: string
user_id?: string
user_name?: string
}
type ChatSessionListener = (message: Record<string, unknown>) => void
class ChatWsClient {
private initialized = false
private listeners: Map<string, Set<ChatSessionListener>> = new Map()
private sessionPayloads: Map<string, ChatSessionOpenPayload> = new Map()
private initialize(): void {
if (this.initialized) {
return
}
unifiedWsClient.addEventListener((message) => {
if (message.domain !== 'chat' || !message.session) {
return
}
const sessionListeners = this.listeners.get(message.session)
if (!sessionListeners) {
return
}
sessionListeners.forEach((listener) => {
try {
listener(message.data)
} catch (error) {
console.error('聊天会话监听器执行失败:', error)
}
})
})
unifiedWsClient.onReconnect(() => {
void this.reopenSessions()
})
this.initialized = true
}
private async reopenSessions(): Promise<void> {
const reopenTargets = Array.from(this.sessionPayloads.entries())
for (const [sessionId, payload] of reopenTargets) {
try {
await unifiedWsClient.call({
domain: 'chat',
method: 'session.open',
session: sessionId,
data: {
...payload,
restore: true,
} as Record<string, unknown>,
})
} catch (error) {
console.error(`恢复聊天会话失败 (${sessionId}):`, error)
}
}
}
async openSession(sessionId: string, payload: ChatSessionOpenPayload): Promise<void> {
this.initialize()
this.sessionPayloads.set(sessionId, payload)
await unifiedWsClient.call({
domain: 'chat',
method: 'session.open',
session: sessionId,
data: payload as Record<string, unknown>,
})
}
async closeSession(sessionId: string): Promise<void> {
this.sessionPayloads.delete(sessionId)
if (unifiedWsClient.getStatus() !== 'connected') {
return
}
try {
await unifiedWsClient.call({
domain: 'chat',
method: 'session.close',
session: sessionId,
data: {},
})
} catch (error) {
console.warn(`关闭聊天会话失败 (${sessionId}):`, error)
}
}
async sendMessage(sessionId: string, content: string, userName: string): Promise<void> {
await unifiedWsClient.call({
domain: 'chat',
method: 'message.send',
session: sessionId,
data: {
content,
user_name: userName,
},
})
}
async updateNickname(sessionId: string, userName: string): Promise<void> {
const currentPayload = this.sessionPayloads.get(sessionId)
if (currentPayload) {
this.sessionPayloads.set(sessionId, {
...currentPayload,
user_name: userName,
})
}
await unifiedWsClient.call({
domain: 'chat',
method: 'session.update_nickname',
session: sessionId,
data: {
user_name: userName,
},
})
}
onSessionMessage(sessionId: string, listener: ChatSessionListener): () => void {
this.initialize()
const sessionListeners = this.listeners.get(sessionId) ?? new Set<ChatSessionListener>()
sessionListeners.add(listener)
this.listeners.set(sessionId, sessionListeners)
return () => {
const currentListeners = this.listeners.get(sessionId)
if (!currentListeners) {
return
}
currentListeners.delete(listener)
if (currentListeners.size === 0) {
this.listeners.delete(sessionId)
}
}
}
onConnectionChange(listener: (connected: boolean) => void): () => void {
return unifiedWsClient.onConnectionChange(listener)
}
onStatusChange(listener: (status: ConnectionStatus) => void): () => void {
return unifiedWsClient.onStatusChange(listener)
}
async restart(): Promise<void> {
await unifiedWsClient.restart()
}
}
export const chatWsClient = new ChatWsClient()

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from 'react'
import type { ConfigSchema, FieldSchema } from '@/types/config-schema'
/**
* Hook type for field-level customization
@@ -13,6 +14,7 @@ export interface FieldHookComponentProps {
value: unknown
onChange?: (value: unknown) => void
children?: ReactNode
schema?: ConfigSchema | FieldSchema
}
/**

View File

@@ -1,13 +1,11 @@
/**
* 全局日志 WebSocket 管理器
* 确保整个应用只有一个 WebSocket 连接
* 确保整个应用只通过统一连接层订阅日志流
*/
import { checkAuthStatus } from './fetch-with-auth'
import { getSetting } from './settings-manager'
import { createReconnectingWebSocket } from './ws-utils'
import { getWsBaseUrl } from '@/lib/api-base'
import { unifiedWsClient } from './unified-ws'
export interface LogEntry {
id: string
@@ -17,165 +15,79 @@ export interface LogEntry {
message: string
}
type LogCallback = (log: LogEntry) => void
type LogCallback = () => void
type ConnectionCallback = (connected: boolean) => void
class LogWebSocketManager {
private wsControl: ReturnType<typeof createReconnectingWebSocket> | null = null
// 订阅者
private logCallbacks: Set<LogCallback> = new Set()
private connectionCallbacks: Set<ConnectionCallback> = new Set()
private initialized = false
private isConnected = false
// 日志缓存 - 保存所有接收到的日志
private logCache: LogEntry[] = []
private logCallbacks: Set<LogCallback> = new Set()
private subscriptionActive = false
/**
* 获取最大缓存大小(从设置读取)
*/
private getMaxCacheSize(): number {
return getSetting('logCacheSize')
}
/**
* 获取最大重连次数(从设置读取)
*/
private getMaxReconnectAttempts(): number {
return getSetting('wsMaxReconnectAttempts')
}
/**
* 获取重连间隔(从设置读取)
*/
private getReconnectInterval(): number {
return getSetting('wsReconnectInterval')
}
/**
* 获取 WebSocket URL不含 token 参数)
*/
private async getWebSocketUrl(): Promise<string> {
const wsBase = await getWsBaseUrl()
return `${wsBase}/ws/logs`
}
/**
* 连接 WebSocket会先检查登录状态
*/
async connect() {
// 检查是否在登录页面
if (window.location.pathname === '/auth') {
console.log('📡 在登录页面,跳过 WebSocket 连接')
private initialize(): void {
if (this.initialized) {
return
}
// 检查登录状态,避免未登录时尝试连接
const isAuthenticated = await checkAuthStatus()
if (!isAuthenticated) {
console.log('📡 未登录,跳过 WebSocket 连接')
return
}
unifiedWsClient.addEventListener((message) => {
if (message.domain !== 'logs') {
return
}
const wsUrl = await this.getWebSocketUrl()
if (message.event === 'snapshot') {
const entries = Array.isArray(message.data.entries)
? (message.data.entries as LogEntry[])
: []
this.logCache = entries.slice(-this.getMaxCacheSize())
this.notifyLogChange()
return
}
// 使用 ws-utils 创建 WebSocket
this.wsControl = createReconnectingWebSocket(wsUrl, {
onMessage: (data: string) => {
try {
const log: LogEntry = JSON.parse(data)
this.notifyLog(log)
} catch (error) {
console.error('解析日志消息失败:', error)
}
},
onOpen: () => {
this.isConnected = true
this.notifyConnection(true)
},
onClose: () => {
this.isConnected = false
this.notifyConnection(false)
},
onError: (error) => {
console.error('❌ WebSocket 错误:', error)
this.isConnected = false
this.notifyConnection(false)
},
heartbeatInterval: 30000,
maxRetries: this.getMaxReconnectAttempts(),
backoffBase: this.getReconnectInterval(),
maxBackoff: 30000,
if (message.event === 'entry' && message.data.entry) {
this.appendLog(message.data.entry as LogEntry)
}
})
// 启动连接
await this.wsControl.connect()
unifiedWsClient.onConnectionChange((connected) => {
this.isConnected = connected
this.notifyConnection(connected)
})
this.initialized = true
}
/**
* 断开连接
*/
disconnect() {
if (this.wsControl) {
this.wsControl.disconnect()
this.wsControl = null
}
this.isConnected = false
}
/**
* 订阅日志消息
*/
onLog(callback: LogCallback) {
this.logCallbacks.add(callback)
return () => this.logCallbacks.delete(callback)
}
/**
* 订阅连接状态
*/
onConnectionChange(callback: ConnectionCallback) {
this.connectionCallbacks.add(callback)
// 立即通知当前状态
callback(this.isConnected)
return () => this.connectionCallbacks.delete(callback)
}
/**
* 通知所有订阅者新日志
*/
private notifyLog(log: LogEntry) {
// 检查是否已存在(通过 id 去重)
private appendLog(log: LogEntry): void {
const exists = this.logCache.some(existingLog => existingLog.id === log.id)
if (!exists) {
// 添加到缓存
this.logCache.push(log)
// 限制缓存大小(动态读取配置)
const maxCacheSize = this.getMaxCacheSize()
if (this.logCache.length > maxCacheSize) {
this.logCache = this.logCache.slice(-maxCacheSize)
}
// 只有新日志才通知订阅者
this.logCallbacks.forEach(callback => {
try {
callback(log)
} catch (error) {
console.error('日志回调执行失败:', error)
}
})
if (exists) {
return
}
this.logCache.push(log)
const maxCacheSize = this.getMaxCacheSize()
if (this.logCache.length > maxCacheSize) {
this.logCache = this.logCache.slice(-maxCacheSize)
}
this.notifyLogChange()
}
/**
* 通知所有订阅者连接状态变化
*/
private notifyConnection(connected: boolean) {
this.connectionCallbacks.forEach(callback => {
private notifyLogChange(): void {
this.logCallbacks.forEach((callback) => {
try {
callback()
} catch (error) {
console.error('日志回调执行失败:', error)
}
})
}
private notifyConnection(connected: boolean): void {
this.connectionCallbacks.forEach((callback) => {
try {
callback(connected)
} catch (error) {
@@ -184,35 +96,65 @@ class LogWebSocketManager {
})
}
/**
* 获取缓存的所有日志
*/
async connect(): Promise<void> {
if (window.location.pathname === '/auth') {
return
}
const isAuthenticated = await checkAuthStatus()
if (!isAuthenticated) {
return
}
this.initialize()
if (this.subscriptionActive) {
return
}
try {
await unifiedWsClient.subscribe('logs', 'main', { replay: 100 })
this.subscriptionActive = true
} catch (error) {
console.error('订阅日志流失败:', error)
}
}
disconnect(): void {
this.subscriptionActive = false
void unifiedWsClient.unsubscribe('logs', 'main')
this.isConnected = false
this.notifyConnection(false)
}
onLog(callback: LogCallback): () => void {
this.logCallbacks.add(callback)
return () => this.logCallbacks.delete(callback)
}
onConnectionChange(callback: ConnectionCallback): () => void {
this.connectionCallbacks.add(callback)
callback(this.isConnected)
return () => this.connectionCallbacks.delete(callback)
}
getAllLogs(): LogEntry[] {
return [...this.logCache]
}
/**
* 清空日志缓存
*/
clearLogs() {
clearLogs(): void {
this.logCache = []
this.notifyLogChange()
}
/**
* 获取当前连接状态
*/
getConnectionStatus(): boolean {
return this.isConnected
}
}
// 导出单例
export const logWebSocket = new LogWebSocketManager()
// 自动连接(应用启动时)
if (typeof window !== 'undefined') {
// 延迟一下确保页面加载完成
setTimeout(() => {
logWebSocket.connect()
void logWebSocket.connect()
}, 100)
}

View File

@@ -1,9 +1,9 @@
import type { ApiResponse } from '@/types/api'
import type { PluginInfo } from '@/types/plugin'
import { getWsBaseUrl } from '@/lib/api-base'
import { fetchWithAuth } from '@/lib/fetch-with-auth'
import { parseResponse } from '@/lib/api-helpers'
import { pluginProgressClient } from '@/lib/plugin-progress-client'
import type { GitStatus, MaimaiVersion } from './types'
/**
@@ -211,41 +211,13 @@ export function isPluginCompatible(
*/
export async function connectPluginProgressWebSocket(
onProgress: (progress: import('./types').PluginLoadProgress) => void,
onError?: (error: Event) => void
): Promise<WebSocket | null> {
const wsBase = await getWsBaseUrl()
const wsUrl = `${wsBase}/api/webui/ws/plugin-progress`
// 使用 ws-utils 创建 WebSocket
const { createReconnectingWebSocket } = await import('@/lib/ws-utils')
const wsControl = createReconnectingWebSocket(wsUrl, {
onMessage: (data: string) => {
try {
const progressData = JSON.parse(data) as import('./types').PluginLoadProgress
onProgress(progressData)
} catch (error) {
console.error('Failed to parse progress data:', error)
}
},
onOpen: () => {
console.log('Plugin progress WebSocket connected')
},
onClose: () => {
console.log('Plugin progress WebSocket disconnected')
},
onError: (error) => {
console.error('Plugin progress WebSocket error:', error)
onError?.(error)
},
heartbeatInterval: 30000,
maxRetries: 10,
backoffBase: 1000,
maxBackoff: 30000,
})
// 启动连接
await wsControl.connect()
// 返回 WebSocket 实例(用于外部检查连接状态)
return wsControl.getWebSocket()
onError?: (error: Error) => void
): Promise<() => Promise<void>> {
try {
return await pluginProgressClient.subscribe(onProgress)
} catch (error) {
const normalizedError = error instanceof Error ? error : new Error('插件进度订阅失败')
onError?.(normalizedError)
return async () => {}
}
}

View File

@@ -0,0 +1,58 @@
import type { PluginLoadProgress } from '@/lib/plugin-api/types'
import { unifiedWsClient } from './unified-ws'
type ProgressListener = (progress: PluginLoadProgress) => void
class PluginProgressClient {
private initialized = false
private listeners: Set<ProgressListener> = new Set()
private subscriptionActive = false
private initialize(): void {
if (this.initialized) {
return
}
unifiedWsClient.addEventListener((message) => {
if (message.domain !== 'plugin_progress') {
return
}
const progress = message.data.progress as PluginLoadProgress | undefined
if (!progress) {
return
}
this.listeners.forEach((listener) => {
try {
listener(progress)
} catch (error) {
console.error('插件进度监听器执行失败:', error)
}
})
})
this.initialized = true
}
async subscribe(listener: ProgressListener): Promise<() => Promise<void>> {
this.initialize()
this.listeners.add(listener)
if (!this.subscriptionActive) {
await unifiedWsClient.subscribe('plugin_progress', 'main')
this.subscriptionActive = true
}
return async () => {
this.listeners.delete(listener)
if (this.listeners.size === 0 && this.subscriptionActive) {
this.subscriptionActive = false
await unifiedWsClient.unsubscribe('plugin_progress', 'main')
}
}
}
}
export const pluginProgressClient = new PluginProgressClient()

View File

@@ -0,0 +1,495 @@
import { fetchWithAuth } from './fetch-with-auth'
import { getSetting } from './settings-manager'
import { getWsBaseUrl } from '@/lib/api-base'
export type ConnectionStatus = 'idle' | 'connecting' | 'connected'
export interface WsErrorPayload {
code?: string
message: string
}
export interface WsEventEnvelope {
op: 'event'
domain: string
event: string
session?: string
topic?: string
data: Record<string, unknown>
}
interface WsResponseEnvelope {
op: 'response'
id?: string
ok: boolean
data?: Record<string, unknown>
error?: WsErrorPayload
}
interface WsPongEnvelope {
op: 'pong'
ts: number
}
type WsServerEnvelope = WsEventEnvelope | WsPongEnvelope | WsResponseEnvelope
interface PendingRequest {
reject: (error: Error) => void
resolve: (data: Record<string, unknown>) => void
timeoutId: number
}
interface SubscriptionDefinition {
data?: Record<string, unknown>
domain: string
topic: string
}
type EventListener = (message: WsEventEnvelope) => void
type ConnectionListener = (connected: boolean) => void
type StatusListener = (status: ConnectionStatus) => void
type ReconnectListener = () => void
function isResponseEnvelope(message: WsServerEnvelope): message is WsResponseEnvelope {
return message.op === 'response'
}
function isEventEnvelope(message: WsServerEnvelope): message is WsEventEnvelope {
return message.op === 'event'
}
async function getWsToken(): Promise<string | null> {
try {
const response = await fetchWithAuth('/api/webui/ws-token', {
method: 'GET',
credentials: 'include',
})
if (!response.ok) {
return null
}
const data = await response.json()
if (data.success && data.token) {
return data.token as string
}
return null
} catch (error) {
console.error('获取统一 WebSocket token 失败:', error)
return null
}
}
class UnifiedWebSocketClient {
private connectPromise: Promise<void> | null = null
private connectionListeners: Set<ConnectionListener> = new Set()
private eventListeners: Set<EventListener> = new Set()
private hasConnectedOnce = false
private heartbeatIntervalId: number | null = null
private manualDisconnect = false
private pendingRequests: Map<string, PendingRequest> = new Map()
private reconnectAttempts = 0
private reconnectListeners: Set<ReconnectListener> = new Set()
private reconnectTimeout: number | null = null
private requestCounter = 0
private status: ConnectionStatus = 'idle'
private statusListeners: Set<StatusListener> = new Set()
private subscriptions: Map<string, SubscriptionDefinition> = new Map()
private ws: WebSocket | null = null
private getReconnectDelay(): number {
const baseDelay = getSetting('wsReconnectInterval')
return Math.min(baseDelay * Math.max(this.reconnectAttempts, 1), 30000)
}
private getMaxReconnectAttempts(): number {
return getSetting('wsMaxReconnectAttempts')
}
private getSubscriptionKey(domain: string, topic: string): string {
return `${domain}:${topic}`
}
private nextRequestId(): string {
this.requestCounter += 1
return `ws-${Date.now()}-${this.requestCounter}`
}
private setStatus(status: ConnectionStatus): void {
if (this.status === status) {
return
}
this.status = status
this.statusListeners.forEach((listener) => {
try {
listener(status)
} catch (error) {
console.error('WebSocket 状态监听器执行失败:', error)
}
})
const connected = status === 'connected'
this.connectionListeners.forEach((listener) => {
try {
listener(connected)
} catch (error) {
console.error('WebSocket 连接监听器执行失败:', error)
}
})
}
private stopHeartbeat(): void {
if (this.heartbeatIntervalId !== null) {
clearInterval(this.heartbeatIntervalId)
this.heartbeatIntervalId = null
}
}
private startHeartbeat(): void {
this.stopHeartbeat()
this.heartbeatIntervalId = window.setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ op: 'ping' }))
}
}, 30000)
}
private clearReconnectTimer(): void {
if (this.reconnectTimeout !== null) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
}
private rejectPendingRequests(error: Error): void {
this.pendingRequests.forEach((pendingRequest, requestId) => {
clearTimeout(pendingRequest.timeoutId)
pendingRequest.reject(error)
this.pendingRequests.delete(requestId)
})
}
private scheduleReconnect(): void {
if (this.manualDisconnect) {
return
}
if (this.reconnectAttempts >= this.getMaxReconnectAttempts()) {
console.warn(`统一 WebSocket 达到最大重连次数 (${this.getMaxReconnectAttempts()}),停止重连`)
return
}
this.reconnectAttempts += 1
const delay = this.getReconnectDelay()
this.clearReconnectTimer()
this.reconnectTimeout = window.setTimeout(() => {
void this.connect().catch((error) => {
console.error('统一 WebSocket 重连失败:', error)
})
}, delay)
}
private async createWebSocketUrl(): Promise<string | null> {
const wsBaseUrl = await getWsBaseUrl()
const wsToken = await getWsToken()
if (!wsBaseUrl || !wsToken) {
return null
}
return `${wsBaseUrl}/api/webui/ws?token=${encodeURIComponent(wsToken)}`
}
private async sendRequest(
payload: Record<string, unknown>,
timeoutMs = 10000,
): Promise<Record<string, unknown>> {
if (this.ws?.readyState !== WebSocket.OPEN) {
throw new Error('统一 WebSocket 尚未连接')
}
const requestId = payload.id as string
return await new Promise<Record<string, unknown>>((resolve, reject) => {
const timeoutId = window.setTimeout(() => {
this.pendingRequests.delete(requestId)
reject(new Error(`统一 WebSocket 请求超时: ${requestId}`))
}, timeoutMs)
this.pendingRequests.set(requestId, {
resolve,
reject,
timeoutId,
})
this.ws?.send(JSON.stringify(payload))
})
}
private async restoreState(shouldNotifyReconnect: boolean): Promise<void> {
const subscriptions = Array.from(this.subscriptions.values())
for (const subscription of subscriptions) {
try {
await this.sendRequest({
op: 'subscribe',
id: this.nextRequestId(),
domain: subscription.domain,
topic: subscription.topic,
data: subscription.data ?? {},
})
} catch (error) {
console.error('恢复统一 WebSocket 订阅失败:', error)
}
}
if (shouldNotifyReconnect) {
this.reconnectListeners.forEach((listener) => {
try {
listener()
} catch (error) {
console.error('统一 WebSocket 重连监听器执行失败:', error)
}
})
}
}
private handleServerMessage(rawData: string): void {
let message: WsServerEnvelope
try {
message = JSON.parse(rawData) as WsServerEnvelope
} catch (error) {
console.error('解析统一 WebSocket 消息失败:', error)
return
}
if (message.op === 'pong') {
return
}
if (isResponseEnvelope(message)) {
const requestId = message.id
if (!requestId) {
return
}
const pendingRequest = this.pendingRequests.get(requestId)
if (!pendingRequest) {
return
}
clearTimeout(pendingRequest.timeoutId)
this.pendingRequests.delete(requestId)
if (message.ok) {
pendingRequest.resolve(message.data ?? {})
} else {
pendingRequest.reject(new Error(message.error?.message ?? '统一 WebSocket 请求失败'))
}
return
}
if (isEventEnvelope(message)) {
this.eventListeners.forEach((listener) => {
try {
listener(message)
} catch (error) {
console.error('统一 WebSocket 事件监听器执行失败:', error)
}
})
}
}
private handleClose(event: CloseEvent): void {
this.stopHeartbeat()
this.ws = null
this.connectPromise = null
this.setStatus('idle')
this.rejectPendingRequests(new Error(`统一 WebSocket 已关闭 (${event.code})`))
if (event.code === 4001) {
this.manualDisconnect = true
if (window.location.pathname !== '/auth') {
window.location.href = '/auth'
}
return
}
this.scheduleReconnect()
}
async connect(): Promise<void> {
if (this.ws?.readyState === WebSocket.OPEN) {
return
}
if (this.connectPromise) {
return await this.connectPromise
}
this.manualDisconnect = false
this.setStatus('connecting')
this.connectPromise = (async () => {
const wsUrl = await this.createWebSocketUrl()
if (!wsUrl) {
this.setStatus('idle')
throw new Error('无法建立统一 WebSocket 连接')
}
await new Promise<void>((resolve, reject) => {
let settled = false
const socket = new WebSocket(wsUrl)
this.ws = socket
socket.onopen = () => {
settled = true
const shouldNotifyReconnect = this.hasConnectedOnce
this.hasConnectedOnce = true
this.reconnectAttempts = 0
this.startHeartbeat()
this.setStatus('connected')
resolve()
void this.restoreState(shouldNotifyReconnect)
}
socket.onmessage = (event) => {
this.handleServerMessage(event.data)
}
socket.onerror = () => {
if (!settled) {
settled = true
reject(new Error('统一 WebSocket 连接失败'))
}
}
socket.onclose = (event) => {
if (!settled) {
settled = true
reject(new Error(`统一 WebSocket 已关闭 (${event.code})`))
}
this.handleClose(event)
}
})
})()
try {
await this.connectPromise
} finally {
if (this.status !== 'connected') {
this.connectPromise = null
}
}
}
disconnect(): void {
this.manualDisconnect = true
this.clearReconnectTimer()
this.stopHeartbeat()
this.rejectPendingRequests(new Error('统一 WebSocket 已手动断开'))
this.connectPromise = null
if (this.ws) {
this.ws.close()
this.ws = null
}
this.setStatus('idle')
}
async restart(): Promise<void> {
this.manualDisconnect = false
this.clearReconnectTimer()
if (this.ws) {
this.ws.close()
return
}
await this.connect()
}
async call(params: {
data?: Record<string, unknown>
domain: string
method: string
session?: string
}): Promise<Record<string, unknown>> {
await this.connect()
const requestId = this.nextRequestId()
return await this.sendRequest({
op: 'call',
id: requestId,
domain: params.domain,
method: params.method,
session: params.session,
data: params.data ?? {},
})
}
async subscribe(
domain: string,
topic: string,
data?: Record<string, unknown>,
): Promise<Record<string, unknown>> {
await this.connect()
this.subscriptions.set(this.getSubscriptionKey(domain, topic), {
domain,
topic,
data,
})
return await this.sendRequest({
op: 'subscribe',
id: this.nextRequestId(),
domain,
topic,
data: data ?? {},
})
}
async unsubscribe(domain: string, topic: string): Promise<Record<string, unknown> | null> {
this.subscriptions.delete(this.getSubscriptionKey(domain, topic))
if (this.ws?.readyState !== WebSocket.OPEN) {
return null
}
return await this.sendRequest({
op: 'unsubscribe',
id: this.nextRequestId(),
domain,
topic,
data: {},
})
}
addEventListener(listener: EventListener): () => void {
this.eventListeners.add(listener)
return () => {
this.eventListeners.delete(listener)
}
}
onConnectionChange(listener: ConnectionListener): () => void {
this.connectionListeners.add(listener)
listener(this.status === 'connected')
return () => {
this.connectionListeners.delete(listener)
}
}
onStatusChange(listener: StatusListener): () => void {
this.statusListeners.add(listener)
listener(this.status)
return () => {
this.statusListeners.delete(listener)
}
}
onReconnect(listener: ReconnectListener): () => void {
this.reconnectListeners.add(listener)
return () => {
this.reconnectListeners.delete(listener)
}
}
getStatus(): ConnectionStatus {
return this.status
}
}
export const unifiedWsClient = new UnifiedWebSocketClient()

View File

@@ -1,211 +0,0 @@
import { fetchWithAuth } from './fetch-with-auth'
/**
* WebSocket 配置选项
*/
export interface WebSocketOptions {
onMessage?: (data: string) => void
onOpen?: () => void
onClose?: () => void
onError?: (error: Event) => void
heartbeatInterval?: number // 心跳间隔(毫秒)
maxRetries?: number // 最大重连次数
backoffBase?: number // 重连基础间隔(毫秒)
maxBackoff?: number // 最大重连间隔(毫秒)
}
/**
* 获取 WebSocket 临时认证 token
*/
export async function getWsToken(): Promise<string | null> {
try {
// 使用相对路径,让前端代理处理请求,避免 CORS 问题
const response = await fetchWithAuth('/api/webui/ws-token', {
method: 'GET',
credentials: 'include', // 携带 Cookie
})
if (!response.ok) {
console.error('获取 WebSocket token 失败:', response.status)
return null
}
const data = await response.json()
if (data.success && data.token) {
return data.token
}
return null
} catch (error) {
console.error('获取 WebSocket token 失败:', error)
return null
}
}
/**
* 创建带重连、心跳的 WebSocket 封装
*
* @param url WebSocket URL不含 token 参数)
* @param options 配置选项
* @returns WebSocket 控制对象,包含 connect、disconnect、send 方法
*/
export function createReconnectingWebSocket(
url: string,
options: WebSocketOptions = {}
) {
const {
onMessage,
onOpen,
onClose,
onError,
heartbeatInterval = 30000,
maxRetries = 10,
backoffBase = 1000,
maxBackoff = 30000,
} = options
let ws: WebSocket | null = null
let reconnectTimeout: number | null = null
let reconnectAttempts = 0
let heartbeatIntervalId: number | null = null
let isManualDisconnect = false
/**
* 启动心跳
*/
function startHeartbeat() {
stopHeartbeat()
heartbeatIntervalId = window.setInterval(() => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send('ping')
}
}, heartbeatInterval)
}
/**
* 停止心跳
*/
function stopHeartbeat() {
if (heartbeatIntervalId !== null) {
clearInterval(heartbeatIntervalId)
heartbeatIntervalId = null
}
}
/**
* 尝试重连
*/
function attemptReconnect() {
if (isManualDisconnect) {
return
}
if (reconnectAttempts >= maxRetries) {
console.warn(`WebSocket 达到最大重连次数 (${maxRetries}),停止重连`)
return
}
reconnectAttempts += 1
const delay = Math.min(backoffBase * reconnectAttempts, maxBackoff)
console.log(`WebSocket 将在 ${delay}ms 后重连(第 ${reconnectAttempts} 次)`)
reconnectTimeout = window.setTimeout(() => {
connect()
}, delay)
}
/**
* 连接 WebSocket
*/
async function connect() {
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) {
return
}
// 先获取临时认证 token
const wsToken = await getWsToken()
if (!wsToken) {
console.warn('无法获取 WebSocket token跳过连接')
return
}
const wsUrl = `${url}?token=${encodeURIComponent(wsToken)}`
try {
ws = new WebSocket(wsUrl)
ws.onopen = () => {
reconnectAttempts = 0
startHeartbeat()
onOpen?.()
}
ws.onmessage = (event) => {
// 忽略心跳响应
if (event.data === 'pong') {
return
}
onMessage?.(event.data)
}
ws.onerror = (error) => {
console.error('WebSocket 错误:', error)
onError?.(error)
}
ws.onclose = () => {
stopHeartbeat()
onClose?.()
attemptReconnect()
}
} catch (error) {
console.error('创建 WebSocket 连接失败:', error)
attemptReconnect()
}
}
/**
* 断开连接
*/
function disconnect() {
isManualDisconnect = true
if (reconnectTimeout !== null) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
stopHeartbeat()
if (ws) {
ws.close()
ws = null
}
reconnectAttempts = 0
}
/**
* 发送消息
*/
function send(data: string) {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(data)
} else {
console.warn('WebSocket 未连接,无法发送消息')
}
}
/**
* 获取当前 WebSocket 实例
*/
function getWebSocket(): WebSocket | null {
return ws
}
return {
connect,
disconnect,
send,
getWebSocket,
}
}

View File

@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useToast } from '@/hooks/use-toast'
import { getWsBaseUrl } from '@/lib/api-base'
import { chatWsClient } from '@/lib/chat-ws-client'
import { fetchWithAuth } from '@/lib/fetch-with-auth'
import { cn } from '@/lib/utils'
import { Bot, Edit2, Loader2, RefreshCw, User, Send, Wifi, WifiOff, UserCircle2 } from 'lucide-react'
@@ -85,14 +85,17 @@ export function ChatPage() {
// 持久化用户 ID
const userIdRef = useRef(getOrCreateUserId())
// 每个标签页的 WebSocket 连接
const wsMapRef = useRef<Map<string, WebSocket>>(new Map())
const messagesEndRef = useRef<HTMLDivElement>(null)
const reconnectTimeoutMapRef = useRef<Map<string, number>>(new Map())
const messageIdCounterRef = useRef(0)
const processedMessagesMapRef = useRef<Map<string, Set<string>>>(new Map())
const sessionUnsubscribeMapRef = useRef<Map<string, () => void>>(new Map())
const tabsRef = useRef<ChatTab[]>([])
const { toast } = useToast()
useEffect(() => {
tabsRef.current = tabs
}, [tabs])
// 生成唯一消息 ID
const generateMessageId = (prefix: string) => {
messageIdCounterRef.current += 1
@@ -197,357 +200,218 @@ export function ChatPage() {
}
}, [tempVirtualConfig.platform, personSearchQuery, fetchPersons])
// 加载聊天历史到指定标签页
const loadChatHistoryForTab = useCallback(async (tabId: string, groupId?: string) => {
const handleSessionMessage = useCallback((
tabId: string,
tabType: 'webui' | 'virtual',
config: VirtualIdentityConfig | undefined,
data: WsMessage,
) => {
switch (data.type) {
case 'session_info':
updateTab(tabId, {
sessionInfo: {
session_id: data.session_id,
user_id: data.user_id,
user_name: data.user_name,
bot_name: data.bot_name,
}
})
break
case 'system':
addMessageToTab(tabId, {
id: generateMessageId('sys'),
type: 'system',
content: data.content || '',
timestamp: data.timestamp || Date.now() / 1000,
})
break
case 'user_message': {
const senderUserId = data.sender?.user_id
const currentUserId = tabType === 'virtual' && config
? config.userId
: userIdRef.current
const normalizeSenderId = senderUserId ? senderUserId.replace(/^webui_user_/, '') : ''
const normalizeCurrentId = currentUserId ? currentUserId.replace(/^webui_user_/, '') : ''
if (normalizeSenderId && normalizeCurrentId && normalizeSenderId === normalizeCurrentId) {
break
}
const processedSet = processedMessagesMapRef.current.get(tabId) || new Set()
const contentHash = `user-${data.content}-${Math.floor((data.timestamp || 0) * 1000)}`
if (processedSet.has(contentHash)) {
break
}
processedSet.add(contentHash)
processedMessagesMapRef.current.set(tabId, processedSet)
if (processedSet.size > 100) {
const firstKey = processedSet.values().next().value
if (firstKey) processedSet.delete(firstKey)
}
addMessageToTab(tabId, {
id: data.message_id || generateMessageId('user'),
type: 'user',
content: data.content || '',
timestamp: data.timestamp || Date.now() / 1000,
sender: data.sender,
})
break
}
case 'bot_message': {
updateTab(tabId, { isTyping: false })
const processedSet = processedMessagesMapRef.current.get(tabId) || new Set()
const contentHash = `bot-${data.content}-${Math.floor((data.timestamp || 0) * 1000)}`
if (processedSet.has(contentHash)) {
break
}
processedSet.add(contentHash)
processedMessagesMapRef.current.set(tabId, processedSet)
if (processedSet.size > 100) {
const firstKey = processedSet.values().next().value
if (firstKey) processedSet.delete(firstKey)
}
setTabs(prev => prev.map(tab => {
if (tab.id !== tabId) return tab
const filteredMessages = tab.messages.filter(msg => msg.type !== 'thinking')
const newMessage: ChatMessage = {
id: generateMessageId('bot'),
type: 'bot',
content: data.content || '',
message_type: (data.message_type === 'rich' ? 'rich' : 'text') as 'text' | 'rich',
segments: data.segments,
timestamp: data.timestamp || Date.now() / 1000,
sender: data.sender,
}
return {
...tab,
messages: [...filteredMessages, newMessage]
}
}))
break
}
case 'typing':
updateTab(tabId, { isTyping: data.is_typing || false })
break
case 'error':
setTabs(prev => prev.map(tab => {
if (tab.id !== tabId) return tab
const filteredMessages = tab.messages.filter(msg => msg.type !== 'thinking')
return {
...tab,
messages: [...filteredMessages, {
id: generateMessageId('error'),
type: 'error' as const,
content: data.content || '发生错误',
timestamp: data.timestamp || Date.now() / 1000,
}]
}
}))
toast({
title: '错误',
description: data.content,
variant: 'destructive',
})
break
case 'history': {
const historyMessages = data.messages || []
const processedSet = new Set<string>()
const formattedMessages: ChatMessage[] = historyMessages.map((msg: {
id?: string
content: string
timestamp: number
sender_name?: string
sender_id?: string
is_bot?: boolean
}) => {
const isBot = msg.is_bot || false
const msgId = msg.id || generateMessageId(isBot ? 'bot' : 'user')
const contentHash = `${isBot ? 'bot' : 'user'}-${msg.content}-${Math.floor(msg.timestamp * 1000)}`
processedSet.add(contentHash)
return {
id: msgId,
type: isBot ? 'bot' : 'user' as const,
content: msg.content,
timestamp: msg.timestamp,
sender: {
name: msg.sender_name || (isBot ? '麦麦' : '用户'),
user_id: msg.sender_id,
is_bot: isBot,
},
}
})
processedMessagesMapRef.current.set(tabId, processedSet)
updateTab(tabId, { messages: formattedMessages })
setIsLoadingHistory(false)
break
}
default:
break
}
}, [addMessageToTab, toast, updateTab])
const ensureSessionListener = useCallback((
tabId: string,
tabType: 'webui' | 'virtual',
config?: VirtualIdentityConfig,
) => {
if (sessionUnsubscribeMapRef.current.has(tabId)) {
return
}
const unsubscribe = chatWsClient.onSessionMessage(tabId, (message) => {
handleSessionMessage(tabId, tabType, config, message as unknown as WsMessage)
})
sessionUnsubscribeMapRef.current.set(tabId, unsubscribe)
}, [handleSessionMessage])
const openSessionForTab = useCallback(async (
tabId: string,
tabType: 'webui' | 'virtual',
config?: VirtualIdentityConfig,
) => {
ensureSessionListener(tabId, tabType, config)
setIsLoadingHistory(true)
try {
const params = new URLSearchParams()
params.append('user_id', userIdRef.current)
params.append('limit', '50')
if (groupId) {
params.append('group_id', groupId)
if (tabType === 'virtual' && config) {
await chatWsClient.openSession(tabId, {
user_id: config.userId,
user_name: config.userName,
platform: config.platform,
person_id: config.personId,
group_name: config.groupName || 'WebUI虚拟群聊',
group_id: config.groupId,
})
} else {
await chatWsClient.openSession(tabId, {
user_id: userIdRef.current,
user_name: userName,
})
}
const url = `/api/chat/history?${params.toString()}`
console.log('[Chat] 正在加载历史消息:', url)
const response = await fetchWithAuth(url)
if (response.ok) {
const text = await response.text()
try {
const data = JSON.parse(text)
if (data.messages && data.messages.length > 0) {
const historyMessages: ChatMessage[] = data.messages.map((msg: {
id: string
type: string
content: string
timestamp: number
sender_name?: string
user_id?: string
is_bot?: boolean
}) => ({
id: msg.id,
type: msg.type as 'user' | 'bot' | 'system' | 'error',
content: msg.content,
timestamp: msg.timestamp,
sender: {
name: msg.sender_name || (msg.is_bot ? '麦麦' : 'WebUI用户'),
user_id: msg.user_id,
is_bot: msg.is_bot
}
}))
// 更新标签页的消息
updateTab(tabId, { messages: historyMessages })
// 将历史消息添加到去重缓存
const processedSet = processedMessagesMapRef.current.get(tabId) || new Set()
historyMessages.forEach(msg => {
if (msg.type === 'bot') {
const contentHash = `bot-${msg.content}-${Math.floor(msg.timestamp * 1000)}`
processedSet.add(contentHash)
}
})
processedMessagesMapRef.current.set(tabId, processedSet)
}
} catch (parseError) {
console.error('[Chat] JSON 解析失败:', parseError)
}
}
} catch (e) {
console.error('[Chat] 加载历史消息失败:', e)
} finally {
setIsLoadingHistory(false)
}
}, [updateTab])
// 为指定标签页连接 WebSocket异步需要先获取认证 token
const connectWebSocketForTab = useCallback(async (tabId: string, tabType: 'webui' | 'virtual', config?: VirtualIdentityConfig) => {
// 如果已经有连接,不要重复创建
const existingWs = wsMapRef.current.get(tabId)
if (existingWs?.readyState === WebSocket.OPEN ||
existingWs?.readyState === WebSocket.CONNECTING) {
console.log(`[Tab ${tabId}] WebSocket 已存在,跳过连接`)
return
}
setIsConnecting(true)
// 先获取临时 WebSocket token
let wsToken: string | null = null
try {
const tokenResponse = await fetchWithAuth('/api/webui/ws-token')
if (tokenResponse.ok) {
const tokenData = await tokenResponse.json()
if (tokenData.success && tokenData.token) {
wsToken = tokenData.token
} else {
console.warn(`[Tab ${tabId}] 获取 WebSocket token 失败: ${tokenData.message || '未登录'}`)
setIsConnecting(false)
return
}
}
updateTab(tabId, { isConnected: true })
} catch (error) {
console.error(`[Tab ${tabId}] 获取 WebSocket token 失败:`, error)
setIsConnecting(false)
return
console.error(`[Tab ${tabId}] 打开聊天会话失败:`, error)
setIsLoadingHistory(false)
toast({
title: '连接失败',
description: '无法建立聊天会话,请稍后重试',
variant: 'destructive',
})
}
// 此时 wsToken 一定有值(前面已经 return
if (!wsToken) {
setIsConnecting(false)
return
}
const wsBase = await getWsBaseUrl()
const params = new URLSearchParams()
// 添加 token 到参数
params.append('token', wsToken)
if (tabType === 'virtual' && config) {
params.append('user_id', config.userId)
params.append('user_name', config.userName)
params.append('platform', config.platform)
params.append('person_id', config.personId)
params.append('group_name', config.groupName || 'WebUI虚拟群聊')
// 传递稳定的 group_id确保历史记录能正确加载
if (config.groupId) {
params.append('group_id', config.groupId)
}
} else {
params.append('user_id', userIdRef.current)
params.append('user_name', userName)
}
const wsUrl = `${wsBase}/api/chat/ws?${params.toString()}`
console.log(`[Tab ${tabId}] 正在连接 WebSocket:`, wsUrl)
try {
const ws = new WebSocket(wsUrl)
wsMapRef.current.set(tabId, ws)
ws.onopen = () => {
updateTab(tabId, { isConnected: true })
setIsConnecting(false)
console.log(`[Tab ${tabId}] WebSocket 已连接`)
}
ws.onmessage = (event) => {
try {
const data: WsMessage = JSON.parse(event.data)
switch (data.type) {
case 'session_info':
updateTab(tabId, {
sessionInfo: {
session_id: data.session_id,
user_id: data.user_id,
user_name: data.user_name,
bot_name: data.bot_name,
}
})
break
case 'system':
addMessageToTab(tabId, {
id: generateMessageId('sys'),
type: 'system',
content: data.content || '',
timestamp: data.timestamp || Date.now() / 1000,
})
break
case 'user_message': {
// 检查是否是自己发的消息(已在发送时显示,跳过广播回来的)
const senderUserId = data.sender?.user_id
const currentUserId = tabType === 'virtual' && config
? config.userId
: userIdRef.current
console.log(`[Tab ${tabId}] 收到 user_message, sender: ${senderUserId}, current: ${currentUserId}`)
// 标准化 user_id去掉可能的前缀
const normalizeSenderId = senderUserId ? senderUserId.replace(/^webui_user_/, '') : ''
const normalizeCurrentId = currentUserId ? currentUserId.replace(/^webui_user_/, '') : ''
// 如果是自己发的消息,跳过(避免重复显示)
if (normalizeSenderId && normalizeCurrentId && normalizeSenderId === normalizeCurrentId) {
console.log(`[Tab ${tabId}] 跳过自己的消息user_id 匹配)`)
break
}
// 额外的消息去重:检查内容和时间戳
const processedSet = processedMessagesMapRef.current.get(tabId) || new Set()
const contentHash = `user-${data.content}-${Math.floor((data.timestamp || 0) * 1000)}`
if (processedSet.has(contentHash)) {
console.log(`[Tab ${tabId}] 跳过自己的消息(内容去重)`)
break
}
processedSet.add(contentHash)
processedMessagesMapRef.current.set(tabId, processedSet)
if (processedSet.size > 100) {
const firstKey = processedSet.values().next().value
if (firstKey) processedSet.delete(firstKey)
}
addMessageToTab(tabId, {
id: data.message_id || generateMessageId('user'),
type: 'user',
content: data.content || '',
timestamp: data.timestamp || Date.now() / 1000,
sender: data.sender,
})
break
}
case 'bot_message': {
updateTab(tabId, { isTyping: false })
const processedSet = processedMessagesMapRef.current.get(tabId) || new Set()
const contentHash = `bot-${data.content}-${Math.floor((data.timestamp || 0) * 1000)}`
if (processedSet.has(contentHash)) {
break
}
processedSet.add(contentHash)
processedMessagesMapRef.current.set(tabId, processedSet)
if (processedSet.size > 100) {
const firstKey = processedSet.values().next().value
if (firstKey) processedSet.delete(firstKey)
}
// 移除"思考中"占位消息,添加真实的机器人回复
setTabs(prev => prev.map(tab => {
if (tab.id !== tabId) return tab
// 过滤掉 thinking 类型的消息
const filteredMessages = tab.messages.filter(msg => msg.type !== 'thinking')
const newMessage: ChatMessage = {
id: generateMessageId('bot'),
type: 'bot',
content: data.content || '',
message_type: (data.message_type === 'rich' ? 'rich' : 'text') as 'text' | 'rich',
segments: data.segments,
timestamp: data.timestamp || Date.now() / 1000,
sender: data.sender,
}
return {
...tab,
messages: [...filteredMessages, newMessage]
}
}))
break
}
case 'typing':
updateTab(tabId, { isTyping: data.is_typing || false })
break
case 'error':
// 移除"思考中"占位消息,显示错误
setTabs(prev => prev.map(tab => {
if (tab.id !== tabId) return tab
const filteredMessages = tab.messages.filter(msg => msg.type !== 'thinking')
return {
...tab,
messages: [...filteredMessages, {
id: generateMessageId('error'),
type: 'error' as const,
content: data.content || '发生错误',
timestamp: data.timestamp || Date.now() / 1000,
}]
}
}))
toast({
title: '错误',
description: data.content,
variant: 'destructive',
})
break
case 'pong':
break
case 'history': {
// 处理服务端发送的历史消息
const historyMessages = data.messages || []
if (historyMessages.length > 0) {
const processedSet = processedMessagesMapRef.current.get(tabId) || new Set()
const formattedMessages: ChatMessage[] = historyMessages.map((msg: {
id?: string
content: string
timestamp: number
sender_name?: string
sender_id?: string
is_bot?: boolean
}) => {
const isBot = msg.is_bot || false
const msgId = msg.id || generateMessageId(isBot ? 'bot' : 'user')
// 添加到去重集合
const contentHash = `${isBot ? 'bot' : 'user'}-${msg.content}-${Math.floor(msg.timestamp * 1000)}`
processedSet.add(contentHash)
return {
id: msgId,
type: isBot ? 'bot' : 'user' as const,
content: msg.content,
timestamp: msg.timestamp,
sender: {
name: msg.sender_name || (isBot ? '麦麦' : '用户'),
user_id: msg.sender_id,
is_bot: isBot,
},
}
})
processedMessagesMapRef.current.set(tabId, processedSet)
// 替换当前标签页的所有消息
updateTab(tabId, { messages: formattedMessages })
console.log(`[Tab ${tabId}] 已加载 ${formattedMessages.length} 条历史消息`)
}
break
}
default:
console.log('未知消息类型:', data.type)
}
} catch (e) {
console.error('解析消息失败:', e)
}
}
ws.onclose = () => {
updateTab(tabId, { isConnected: false })
setIsConnecting(false)
wsMapRef.current.delete(tabId)
console.log(`[Tab ${tabId}] WebSocket 已断开`)
// 清除旧的重连定时器
const oldTimeout = reconnectTimeoutMapRef.current.get(tabId)
if (oldTimeout) {
clearTimeout(oldTimeout)
}
// 5秒后尝试重连
const timeout = window.setTimeout(() => {
if (!isUnmountedRef.current) {
const tab = tabs.find(t => t.id === tabId)
if (tab) {
connectWebSocketForTab(tabId, tab.type, tab.virtualConfig)
}
}
}, 5000)
reconnectTimeoutMapRef.current.set(tabId, timeout)
}
ws.onerror = (error) => {
console.error(`[Tab ${tabId}] WebSocket 错误:`, error)
setIsConnecting(false)
}
} catch (e) {
console.error(`[Tab ${tabId}] 创建 WebSocket 失败:`, e)
setIsConnecting(false)
}
}, [userName, updateTab, addMessageToTab, toast, tabs])
}, [ensureSessionListener, toast, updateTab, userName])
// 用于追踪组件是否已卸载
const isUnmountedRef = useRef(false)
@@ -555,69 +419,49 @@ export function ChatPage() {
// 初始化连接(默认 WebUI 标签页)
useEffect(() => {
isUnmountedRef.current = false
// 保存 ref 的当前值,用于清理
const wsMap = wsMapRef.current
const reconnectTimeoutMap = reconnectTimeoutMapRef.current
const processedMessagesMap = processedMessagesMapRef.current
// 加载默认标签页历史消息
loadChatHistoryForTab('webui-default')
// 延迟连接
const connectTimer = setTimeout(() => {
if (!isUnmountedRef.current) {
connectWebSocketForTab('webui-default', 'webui')
// 恢复的虚拟标签页也需要建立连接
tabs.forEach(tab => {
if (tab.type === 'virtual' && tab.virtualConfig) {
// 初始化去重缓存
processedMessagesMap.set(tab.id, new Set())
// 建立 WebSocket 连接
setTimeout(() => {
if (!isUnmountedRef.current) {
connectWebSocketForTab(tab.id, 'virtual', tab.virtualConfig)
}
}, 200)
}
})
}
}, 100)
// 心跳定时器 - 向所有活动连接发送
const heartbeat = setInterval(() => {
wsMap.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }))
}
})
}, 30000)
const unsubscribeConnection = chatWsClient.onConnectionChange((connected) => {
if (isUnmountedRef.current) {
return
}
setTabs(prev => prev.map(tab => ({
...tab,
isConnected: connected,
})))
})
const unsubscribeStatus = chatWsClient.onStatusChange((status) => {
if (!isUnmountedRef.current) {
setIsConnecting(status === 'connecting')
}
})
tabs.forEach(tab => {
processedMessagesMapRef.current.set(tab.id, new Set())
void openSessionForTab(tab.id, tab.type, tab.virtualConfig)
})
return () => {
isUnmountedRef.current = true
clearTimeout(connectTimer)
clearInterval(heartbeat)
// 清理所有重连定时器
reconnectTimeoutMap.forEach((timeout) => {
clearTimeout(timeout)
unsubscribeConnection()
unsubscribeStatus()
sessionUnsubscribeMapRef.current.forEach((unsubscribe) => {
unsubscribe()
})
reconnectTimeoutMap.clear()
// 关闭所有 WebSocket 连接
wsMap.forEach((ws) => {
ws.close()
sessionUnsubscribeMapRef.current.clear()
tabsRef.current.forEach(tab => {
void chatWsClient.closeSession(tab.id)
})
wsMap.clear()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 发送消息到当前活动标签页
const sendMessage = useCallback(() => {
const ws = wsMapRef.current.get(activeTabId)
if (!inputValue.trim() || !ws || ws.readyState !== WebSocket.OPEN) {
const sendMessage = useCallback(async () => {
if (!inputValue.trim() || !activeTab?.isConnected) {
return
}
@@ -628,12 +472,6 @@ export function ChatPage() {
const messageContent = inputValue.trim()
const currentTimestamp = Date.now() / 1000
ws.send(JSON.stringify({
type: 'message',
content: messageContent,
user_name: displayName,
}))
// 添加到去重缓存,防止服务器广播回来的消息重复显示
const processedSet = processedMessagesMapRef.current.get(activeTabId) || new Set()
const contentHash = `user-${messageContent}-${Math.floor(currentTimestamp * 1000)}`
@@ -672,13 +510,32 @@ export function ChatPage() {
addMessageToTab(activeTabId, thinkingMessage)
setInputValue('')
}, [inputValue, userName, activeTabId, activeTab, addMessageToTab])
try {
await chatWsClient.sendMessage(activeTabId, messageContent, displayName)
} catch (error) {
console.error('发送聊天消息失败:', error)
setTabs(prev => prev.map(tab => {
if (tab.id !== activeTabId) return tab
return {
...tab,
isTyping: false,
messages: tab.messages.filter(msg => msg.type !== 'thinking')
}
}))
toast({
title: '发送失败',
description: '当前聊天会话不可用,请稍后重试',
variant: 'destructive',
})
}
}, [activeTab, activeTabId, addMessageToTab, inputValue, toast, userName])
// 处理键盘事件
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
void sendMessage()
}
}
@@ -693,13 +550,9 @@ export function ChatPage() {
setUserName(newName)
saveUserName(newName)
setIsEditingName(false)
// 通知当前标签页的后端昵称变更
const ws = wsMapRef.current.get(activeTabId)
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'update_nickname',
user_name: newName
}))
if (activeTab?.isConnected) {
void chatWsClient.updateNickname(activeTabId, newName)
}
}
@@ -719,12 +572,7 @@ export function ChatPage() {
// 重新连接当前标签页
const handleReconnect = () => {
const ws = wsMapRef.current.get(activeTabId)
if (ws) {
ws.close()
wsMapRef.current.delete(activeTabId)
}
connectWebSocketForTab(activeTabId, activeTab?.type || 'webui', activeTab?.virtualConfig)
void chatWsClient.restart()
}
// 打开虚拟身份配置对话框(新建标签页用)
@@ -795,10 +643,10 @@ export function ChatPage() {
// 初始化去重缓存
processedMessagesMapRef.current.set(newTabId, new Set())
// 连接 WebSocket
setTimeout(() => {
connectWebSocketForTab(newTabId, 'virtual', tempVirtualConfig)
}, 100)
void openSessionForTab(newTabId, 'virtual', {
...tempVirtualConfig,
groupId: stableGroupId,
})
toast({
title: '虚拟身份标签页',
@@ -814,20 +662,14 @@ export function ChatPage() {
if (tabId === 'webui-default') {
return
}
// 关闭 WebSocket 连接
const ws = wsMapRef.current.get(tabId)
if (ws) {
ws.close()
wsMapRef.current.delete(tabId)
}
// 清理重连定时器
const timeout = reconnectTimeoutMapRef.current.get(tabId)
if (timeout) {
clearTimeout(timeout)
reconnectTimeoutMapRef.current.delete(tabId)
const unsubscribe = sessionUnsubscribeMapRef.current.get(tabId)
if (unsubscribe) {
unsubscribe()
sessionUnsubscribeMapRef.current.delete(tabId)
}
void chatWsClient.closeSession(tabId)
// 清理去重缓存
processedMessagesMapRef.current.delete(tabId)
@@ -1133,7 +975,7 @@ export function ChatPage() {
className="flex-1 h-10 sm:h-10"
/>
<Button
onClick={sendMessage}
onClick={() => { void sendMessage() }}
disabled={!activeTab?.isConnected || !inputValue.trim()}
size="icon"
className="h-10 w-10 shrink-0"

View File

@@ -27,55 +27,37 @@ import { RestartProvider, useRestart } from '@/lib/restart-context'
import { Code2, Info, Layout, Power, Save } from 'lucide-react'
import type { ConfigSchema } from '@/types/config-schema'
import type {
BotConfig,
ChatConfig,
ChineseTypoConfig,
DebugConfig,
DreamConfig,
EmojiConfig,
ExperimentalConfig,
ExpressionConfig,
KeywordReactionConfig,
LogConfig,
LPMMKnowledgeConfig,
MaimMessageConfig,
MemoryConfig,
MessageReceiveConfig,
PersonalityConfig,
ResponsePostProcessConfig,
ResponseSplitterConfig,
TelemetryConfig,
ToolConfig,
VoiceConfig,
WebUIConfig,
} from './bot/types'
import { useAutoSave, useConfigAutoSave } from './bot/hooks'
import { ChatSectionHook } from './bot/hooks'
import {
BotInfoSection,
DebugSection,
DreamSection,
ExperimentalSection,
ExpressionSection,
FeaturesSection,
LogSection,
LPMMSection,
MaimMessageSection,
MessageReceiveSection,
PersonalitySection,
ProcessingSection,
TelemetrySection,
WebUISection,
} from './bot/sections'
ChatTalkValueRulesHook,
ExperimentalChatPromptsHook,
ExpressionGroupsHook,
ExpressionLearningListHook,
KeywordRulesHook,
MCPRootItemsHook,
MCPServersHook,
RegexRulesHook,
useAutoSave,
useConfigAutoSave,
} from './bot/hooks'
type ConfigSectionData = Record<string, unknown>
// ==================== 常量定义 ====================
/** Toast 显示前的延迟时间 (毫秒) */
const TOAST_DISPLAY_DELAY = 500
/** Tab 标签页的首选排列顺序 (host field name) */
const TAB_ORDER = [
'bot', 'personality', 'chat', 'expression', 'emoji',
'response_post_process', 'dream', 'lpmm_knowledge', 'webui', 'debug',
'bot',
'personality',
'chat',
'expression',
'emoji',
'response_post_process',
'lpmm_knowledge',
'webui',
'maisaka',
'plugin_runtime',
'debug',
]
// ==================== Tab 分组类型与构建 ====================
@@ -88,30 +70,51 @@ interface TabGroup {
/**
* 从 schema 的 nested 字段解析出 tab 分组信息。
* - 有 uiLabel 且无 uiParent → 独立 tab (host)
* - 有 uiParent → 归入对应 host tab 的 sections
* - 有 uiLabel 且无 uiParent → 独立 tab
* - 有 uiParent → 递归找到最终 host并归入对应 tab
*/
function buildTabGroupsFromSchema(schema: ConfigSchema): TabGroup[] {
const nested = schema.nested || {}
const nestedEntries = Object.entries(nested)
const hosts = new Map<string, TabGroup>()
const children: Array<{ fieldName: string; parentId: string }> = []
for (const [fieldName, fieldSchema] of Object.entries(nested)) {
if (fieldSchema.uiLabel && !fieldSchema.uiParent) {
const resolveHostId = (fieldName: string, visited: Set<string> = new Set()): string | null => {
if (visited.has(fieldName)) {
return null
}
const fieldSchema = nested[fieldName]
if (!fieldSchema) {
return null
}
if (!fieldSchema.uiParent) {
return fieldSchema.uiLabel && fieldSchema.uiIcon ? fieldName : null
}
visited.add(fieldName)
return resolveHostId(fieldSchema.uiParent, visited)
}
for (const [fieldName, fieldSchema] of nestedEntries) {
if (fieldSchema.uiLabel && fieldSchema.uiIcon && !fieldSchema.uiParent) {
hosts.set(fieldName, {
id: fieldName,
label: fieldSchema.uiLabel,
icon: fieldSchema.uiIcon || '',
sections: [fieldName],
})
} else if (fieldSchema.uiParent) {
children.push({ fieldName, parentId: fieldSchema.uiParent })
}
}
for (const { fieldName, parentId } of children) {
const parent = hosts.get(parentId)
if (parent) {
for (const [fieldName] of nestedEntries) {
const hostId = resolveHostId(fieldName)
if (!hostId || hostId === fieldName) {
continue
}
const parent = hosts.get(hostId)
if (parent && !parent.sections.includes(fieldName)) {
parent.sections.push(fieldName)
}
}
@@ -147,27 +150,29 @@ function BotConfigPageContent() {
const { triggerRestart, isRestarting } = useRestart()
// 配置状态
const [botConfig, setBotConfig] = useState<BotConfig | null>(null)
const [personalityConfig, setPersonalityConfig] = useState<PersonalityConfig | null>(null)
const [chatConfig, setChatConfig] = useState<ChatConfig | null>(null)
const [expressionConfig, setExpressionConfig] = useState<ExpressionConfig | null>(null)
const [emojiConfig, setEmojiConfig] = useState<EmojiConfig | null>(null)
const [memoryConfig, setMemoryConfig] = useState<MemoryConfig | null>(null)
const [toolConfig, setToolConfig] = useState<ToolConfig | null>(null)
const [voiceConfig, setVoiceConfig] = useState<VoiceConfig | null>(null)
const [messageReceiveConfig, setMessageReceiveConfig] = useState<MessageReceiveConfig | null>(null)
const [dreamConfig, setDreamConfig] = useState<DreamConfig | null>(null)
const [lpmmConfig, setLpmmConfig] = useState<LPMMKnowledgeConfig | null>(null)
const [keywordReactionConfig, setKeywordReactionConfig] = useState<KeywordReactionConfig | null>(null)
const [responsePostProcessConfig, setResponsePostProcessConfig] = useState<ResponsePostProcessConfig | null>(null)
const [chineseTypoConfig, setChineseTypoConfig] = useState<ChineseTypoConfig | null>(null)
const [responseSplitterConfig, setResponseSplitterConfig] = useState<ResponseSplitterConfig | null>(null)
const [logConfig, setLogConfig] = useState<LogConfig | null>(null)
const [debugConfig, setDebugConfig] = useState<DebugConfig | null>(null)
const [experimentalConfig, setExperimentalConfig] = useState<ExperimentalConfig | null>(null)
const [maimMessageConfig, setMaimMessageConfig] = useState<MaimMessageConfig | null>(null)
const [telemetryConfig, setTelemetryConfig] = useState<TelemetryConfig | null>(null)
const [webuiConfig, setWebuiConfig] = useState<WebUIConfig | null>(null)
const [botConfig, setBotConfig] = useState<ConfigSectionData | null>(null)
const [personalityConfig, setPersonalityConfig] = useState<ConfigSectionData | null>(null)
const [chatConfig, setChatConfig] = useState<ConfigSectionData | null>(null)
const [expressionConfig, setExpressionConfig] = useState<ConfigSectionData | null>(null)
const [emojiConfig, setEmojiConfig] = useState<ConfigSectionData | null>(null)
const [memoryConfig, setMemoryConfig] = useState<ConfigSectionData | null>(null)
const [relationshipConfig, setRelationshipConfig] = useState<ConfigSectionData | null>(null)
const [voiceConfig, setVoiceConfig] = useState<ConfigSectionData | null>(null)
const [messageReceiveConfig, setMessageReceiveConfig] = useState<ConfigSectionData | null>(null)
const [lpmmConfig, setLpmmConfig] = useState<ConfigSectionData | null>(null)
const [keywordReactionConfig, setKeywordReactionConfig] = useState<ConfigSectionData | null>(null)
const [responsePostProcessConfig, setResponsePostProcessConfig] = useState<ConfigSectionData | null>(null)
const [chineseTypoConfig, setChineseTypoConfig] = useState<ConfigSectionData | null>(null)
const [responseSplitterConfig, setResponseSplitterConfig] = useState<ConfigSectionData | null>(null)
const [debugConfig, setDebugConfig] = useState<ConfigSectionData | null>(null)
const [experimentalConfig, setExperimentalConfig] = useState<ConfigSectionData | null>(null)
const [maimMessageConfig, setMaimMessageConfig] = useState<ConfigSectionData | null>(null)
const [telemetryConfig, setTelemetryConfig] = useState<ConfigSectionData | null>(null)
const [webuiConfig, setWebuiConfig] = useState<ConfigSectionData | null>(null)
const [databaseConfig, setDatabaseConfig] = useState<ConfigSectionData | null>(null)
const [maisakaConfig, setMaisakaConfig] = useState<ConfigSectionData | null>(null)
const [mcpConfig, setMcpConfig] = useState<ConfigSectionData | null>(null)
const [pluginRuntimeConfig, setPluginRuntimeConfig] = useState<ConfigSectionData | null>(null)
// Schema 状态(用于动态 tab 分组)
const [configSchema, setConfigSchema] = useState<ConfigSchema | null>(null)
@@ -242,34 +247,29 @@ function BotConfigPageContent() {
const parseAndSetConfig = useCallback((config: Record<string, unknown>) => {
configRef.current = config
setBotConfig(config.bot as BotConfig)
setPersonalityConfig(config.personality as PersonalityConfig)
// 确保 chat 配置和 talk_value_rules 有默认值
const chatConfigData = (config.chat ?? {}) as ChatConfig
if (!chatConfigData.talk_value_rules) {
chatConfigData.talk_value_rules = []
}
setChatConfig(chatConfigData)
setExpressionConfig(config.expression as ExpressionConfig)
setEmojiConfig(config.emoji as EmojiConfig)
setMemoryConfig(config.memory as MemoryConfig)
setToolConfig(config.tool as ToolConfig)
setVoiceConfig(config.voice as VoiceConfig)
setMessageReceiveConfig(config.message_receive as MessageReceiveConfig)
setDreamConfig(config.dream as DreamConfig)
setLpmmConfig(config.lpmm_knowledge as LPMMKnowledgeConfig)
setKeywordReactionConfig(config.keyword_reaction as KeywordReactionConfig)
setResponsePostProcessConfig(config.response_post_process as ResponsePostProcessConfig)
setChineseTypoConfig(config.chinese_typo as ChineseTypoConfig)
setResponseSplitterConfig(config.response_splitter as ResponseSplitterConfig)
setLogConfig(config.log as LogConfig)
setDebugConfig(config.debug as DebugConfig)
setExperimentalConfig(config.experimental as ExperimentalConfig)
setMaimMessageConfig(config.maim_message as MaimMessageConfig)
setTelemetryConfig(config.telemetry as TelemetryConfig)
setWebuiConfig(config.webui as WebUIConfig)
setBotConfig((config.bot ?? {}) as ConfigSectionData)
setPersonalityConfig((config.personality ?? {}) as ConfigSectionData)
setChatConfig((config.chat ?? {}) as ConfigSectionData)
setExpressionConfig((config.expression ?? {}) as ConfigSectionData)
setEmojiConfig((config.emoji ?? {}) as ConfigSectionData)
setMemoryConfig((config.memory ?? {}) as ConfigSectionData)
setRelationshipConfig((config.relationship ?? {}) as ConfigSectionData)
setVoiceConfig((config.voice ?? {}) as ConfigSectionData)
setMessageReceiveConfig((config.message_receive ?? {}) as ConfigSectionData)
setLpmmConfig((config.lpmm_knowledge ?? {}) as ConfigSectionData)
setKeywordReactionConfig((config.keyword_reaction ?? {}) as ConfigSectionData)
setResponsePostProcessConfig((config.response_post_process ?? {}) as ConfigSectionData)
setChineseTypoConfig((config.chinese_typo ?? {}) as ConfigSectionData)
setResponseSplitterConfig((config.response_splitter ?? {}) as ConfigSectionData)
setDebugConfig((config.debug ?? {}) as ConfigSectionData)
setExperimentalConfig((config.experimental ?? {}) as ConfigSectionData)
setMaimMessageConfig((config.maim_message ?? {}) as ConfigSectionData)
setTelemetryConfig((config.telemetry ?? {}) as ConfigSectionData)
setWebuiConfig((config.webui ?? {}) as ConfigSectionData)
setDatabaseConfig((config.database ?? {}) as ConfigSectionData)
setMaisakaConfig((config.maisaka ?? {}) as ConfigSectionData)
setMcpConfig((config.mcp ?? {}) as ConfigSectionData)
setPluginRuntimeConfig((config.plugin_runtime ?? {}) as ConfigSectionData)
}, [])
/**
@@ -285,28 +285,48 @@ function BotConfigPageContent() {
expression: expressionConfig,
emoji: emojiConfig,
memory: memoryConfig,
tool: toolConfig,
relationship: relationshipConfig,
voice: voiceConfig,
message_receive: messageReceiveConfig,
dream: dreamConfig,
lpmm_knowledge: lpmmConfig,
keyword_reaction: keywordReactionConfig,
response_post_process: responsePostProcessConfig,
chinese_typo: chineseTypoConfig,
response_splitter: responseSplitterConfig,
log: logConfig,
debug: debugConfig,
experimental: experimentalConfig,
maim_message: maimMessageConfig,
telemetry: telemetryConfig,
webui: webuiConfig,
database: databaseConfig,
maisaka: maisakaConfig,
mcp: mcpConfig,
plugin_runtime: pluginRuntimeConfig,
}
}, [
botConfig, personalityConfig, chatConfig, expressionConfig,
emojiConfig, memoryConfig, toolConfig,
voiceConfig, messageReceiveConfig, dreamConfig, lpmmConfig, keywordReactionConfig, responsePostProcessConfig,
chineseTypoConfig, responseSplitterConfig, logConfig, debugConfig, experimentalConfig,
maimMessageConfig, telemetryConfig, webuiConfig
botConfig,
personalityConfig,
chatConfig,
expressionConfig,
emojiConfig,
memoryConfig,
relationshipConfig,
voiceConfig,
messageReceiveConfig,
lpmmConfig,
keywordReactionConfig,
responsePostProcessConfig,
chineseTypoConfig,
responseSplitterConfig,
debugConfig,
experimentalConfig,
maimMessageConfig,
telemetryConfig,
webuiConfig,
databaseConfig,
maisakaConfig,
mcpConfig,
pluginRuntimeConfig,
])
// 加载源代码
@@ -384,9 +404,25 @@ function BotConfigPageContent() {
}, [loadConfig])
useEffect(() => {
fieldHooks.register('chat', ChatSectionHook, 'replace')
const hookEntries = [
['chat.talk_value_rules', ChatTalkValueRulesHook],
['experimental.chat_prompts', ExperimentalChatPromptsHook],
['expression.expression_groups', ExpressionGroupsHook],
['expression.learning_list', ExpressionLearningListHook],
['keyword_reaction.keyword_rules', KeywordRulesHook],
['keyword_reaction.regex_rules', RegexRulesHook],
['mcp.client.roots.items', MCPRootItemsHook],
['mcp.servers', MCPServersHook],
] as const
for (const [fieldPath, hookComponent] of hookEntries) {
fieldHooks.register(fieldPath, hookComponent, 'replace')
}
return () => {
fieldHooks.unregister('chat')
for (const [fieldPath] of hookEntries) {
fieldHooks.unregister(fieldPath)
}
}
}, [])
@@ -406,19 +442,23 @@ function BotConfigPageContent() {
useConfigAutoSave(expressionConfig, 'expression', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(emojiConfig, 'emoji', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(memoryConfig, 'memory', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(toolConfig, 'tool', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(relationshipConfig, 'relationship', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(voiceConfig, 'voice', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(dreamConfig, 'dream', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(messageReceiveConfig, 'message_receive', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(lpmmConfig, 'lpmm_knowledge', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(keywordReactionConfig, 'keyword_reaction', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(responsePostProcessConfig, 'response_post_process', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(chineseTypoConfig, 'chinese_typo', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(responseSplitterConfig, 'response_splitter', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(logConfig, 'log', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(debugConfig, 'debug', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(experimentalConfig, 'experimental', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(maimMessageConfig, 'maim_message', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(telemetryConfig, 'telemetry', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(webuiConfig, 'webui', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(databaseConfig, 'database', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(maisakaConfig, 'maisaka', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(mcpConfig, 'mcp', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(pluginRuntimeConfig, 'plugin_runtime', initialLoadRef.current, triggerAutoSave)
// 保存源代码
const saveSourceCode = async () => {
@@ -609,6 +649,89 @@ function BotConfigPageContent() {
return buildTabGroupsFromSchema(configSchema)
}, [configSchema])
const sectionValues = useMemo<Record<string, ConfigSectionData | null>>(
() => ({
bot: botConfig,
personality: personalityConfig,
chat: chatConfig,
expression: expressionConfig,
emoji: emojiConfig,
memory: memoryConfig,
relationship: relationshipConfig,
voice: voiceConfig,
message_receive: messageReceiveConfig,
lpmm_knowledge: lpmmConfig,
keyword_reaction: keywordReactionConfig,
response_post_process: responsePostProcessConfig,
chinese_typo: chineseTypoConfig,
response_splitter: responseSplitterConfig,
debug: debugConfig,
experimental: experimentalConfig,
maim_message: maimMessageConfig,
telemetry: telemetryConfig,
webui: webuiConfig,
database: databaseConfig,
maisaka: maisakaConfig,
mcp: mcpConfig,
plugin_runtime: pluginRuntimeConfig,
}),
[
botConfig,
personalityConfig,
chatConfig,
expressionConfig,
emojiConfig,
memoryConfig,
relationshipConfig,
voiceConfig,
messageReceiveConfig,
lpmmConfig,
keywordReactionConfig,
responsePostProcessConfig,
chineseTypoConfig,
responseSplitterConfig,
debugConfig,
experimentalConfig,
maimMessageConfig,
telemetryConfig,
webuiConfig,
databaseConfig,
maisakaConfig,
mcpConfig,
pluginRuntimeConfig,
]
)
const setSectionValue = useCallback((sectionName: string, value: ConfigSectionData) => {
const sectionSetterMap: Record<string, (nextValue: ConfigSectionData) => void> = {
bot: setBotConfig,
personality: setPersonalityConfig,
chat: setChatConfig,
expression: setExpressionConfig,
emoji: setEmojiConfig,
memory: setMemoryConfig,
relationship: setRelationshipConfig,
voice: setVoiceConfig,
message_receive: setMessageReceiveConfig,
lpmm_knowledge: setLpmmConfig,
keyword_reaction: setKeywordReactionConfig,
response_post_process: setResponsePostProcessConfig,
chinese_typo: setChineseTypoConfig,
response_splitter: setResponseSplitterConfig,
debug: setDebugConfig,
experimental: setExperimentalConfig,
maim_message: setMaimMessageConfig,
telemetry: setTelemetryConfig,
webui: setWebuiConfig,
database: setDatabaseConfig,
maisaka: setMaisakaConfig,
mcp: setMcpConfig,
plugin_runtime: setPluginRuntimeConfig,
}
sectionSetterMap[sectionName]?.(value)
}, [])
if (loading) {
return (
<ScrollArea className="h-full">
@@ -748,28 +871,10 @@ function BotConfigPageContent() {
{/* 可视化模式 */}
{editMode === 'visual' && (
<DynamicConfigTabs
configSchema={configSchema}
tabGroups={tabGroups}
botConfig={botConfig} setBotConfig={setBotConfig}
personalityConfig={personalityConfig} setPersonalityConfig={setPersonalityConfig}
chatConfig={chatConfig} setChatConfig={setChatConfig}
expressionConfig={expressionConfig} setExpressionConfig={setExpressionConfig}
emojiConfig={emojiConfig} setEmojiConfig={setEmojiConfig}
memoryConfig={memoryConfig} setMemoryConfig={setMemoryConfig}
toolConfig={toolConfig} setToolConfig={setToolConfig}
voiceConfig={voiceConfig} setVoiceConfig={setVoiceConfig}
messageReceiveConfig={messageReceiveConfig} setMessageReceiveConfig={setMessageReceiveConfig}
dreamConfig={dreamConfig} setDreamConfig={setDreamConfig}
lpmmConfig={lpmmConfig} setLpmmConfig={setLpmmConfig}
keywordReactionConfig={keywordReactionConfig} setKeywordReactionConfig={setKeywordReactionConfig}
responsePostProcessConfig={responsePostProcessConfig} setResponsePostProcessConfig={setResponsePostProcessConfig}
chineseTypoConfig={chineseTypoConfig} setChineseTypoConfig={setChineseTypoConfig}
responseSplitterConfig={responseSplitterConfig} setResponseSplitterConfig={setResponseSplitterConfig}
logConfig={logConfig} setLogConfig={setLogConfig}
debugConfig={debugConfig} setDebugConfig={setDebugConfig}
experimentalConfig={experimentalConfig} setExperimentalConfig={setExperimentalConfig}
maimMessageConfig={maimMessageConfig} setMaimMessageConfig={setMaimMessageConfig}
telemetryConfig={telemetryConfig} setTelemetryConfig={setTelemetryConfig}
webuiConfig={webuiConfig} setWebuiConfig={setWebuiConfig}
sectionValues={sectionValues}
setSectionValue={setSectionValue}
setHasUnsavedChanges={setHasUnsavedChanges}
/>
)}
@@ -783,133 +888,90 @@ function BotConfigPageContent() {
// ==================== 动态 Tab 渲染组件 ====================
function updateNestedValue(
target: ConfigSectionData | null | undefined,
pathSegments: string[],
value: unknown
): ConfigSectionData {
const currentTarget = target && typeof target === 'object' && !Array.isArray(target) ? target : {}
const [currentPath, ...restPath] = pathSegments
if (!currentPath) {
return currentTarget
}
if (restPath.length === 0) {
return {
...currentTarget,
[currentPath]: value,
}
}
return {
...currentTarget,
[currentPath]: updateNestedValue(currentTarget[currentPath] as ConfigSectionData | undefined, restPath, value),
}
}
interface DynamicConfigTabsProps {
configSchema: ConfigSchema | null
tabGroups: TabGroup[]
botConfig: BotConfig | null
setBotConfig: (c: BotConfig) => void
personalityConfig: PersonalityConfig | null
setPersonalityConfig: (c: PersonalityConfig) => void
chatConfig: ChatConfig | null
setChatConfig: (c: ChatConfig) => void
expressionConfig: ExpressionConfig | null
setExpressionConfig: (c: ExpressionConfig) => void
emojiConfig: EmojiConfig | null
setEmojiConfig: (c: EmojiConfig) => void
memoryConfig: MemoryConfig | null
setMemoryConfig: (c: MemoryConfig) => void
toolConfig: ToolConfig | null
setToolConfig: (c: ToolConfig) => void
voiceConfig: VoiceConfig | null
setVoiceConfig: (c: VoiceConfig) => void
messageReceiveConfig: MessageReceiveConfig | null
setMessageReceiveConfig: (c: MessageReceiveConfig) => void
dreamConfig: DreamConfig | null
setDreamConfig: (c: DreamConfig) => void
lpmmConfig: LPMMKnowledgeConfig | null
setLpmmConfig: (c: LPMMKnowledgeConfig) => void
keywordReactionConfig: KeywordReactionConfig | null
setKeywordReactionConfig: (c: KeywordReactionConfig) => void
responsePostProcessConfig: ResponsePostProcessConfig | null
setResponsePostProcessConfig: (c: ResponsePostProcessConfig) => void
chineseTypoConfig: ChineseTypoConfig | null
setChineseTypoConfig: (c: ChineseTypoConfig) => void
responseSplitterConfig: ResponseSplitterConfig | null
setResponseSplitterConfig: (c: ResponseSplitterConfig) => void
logConfig: LogConfig | null
setLogConfig: (c: LogConfig) => void
debugConfig: DebugConfig | null
setDebugConfig: (c: DebugConfig) => void
experimentalConfig: ExperimentalConfig | null
setExperimentalConfig: (c: ExperimentalConfig) => void
maimMessageConfig: MaimMessageConfig | null
setMaimMessageConfig: (c: MaimMessageConfig) => void
telemetryConfig: TelemetryConfig | null
setTelemetryConfig: (c: TelemetryConfig) => void
webuiConfig: WebUIConfig | null
setWebuiConfig: (c: WebUIConfig) => void
sectionValues: Record<string, ConfigSectionData | null>
setSectionValue: (sectionName: string, value: ConfigSectionData) => void
setHasUnsavedChanges: (v: boolean) => void
}
function DynamicConfigTabs(props: DynamicConfigTabsProps) {
const { tabGroups } = props
const { configSchema, sectionValues, setHasUnsavedChanges, setSectionValue, tabGroups } = props
// 每个 tab host field name → 对应的 ReactNode 内容
const tabContentMap: Record<string, React.ReactNode> = {
bot: props.botConfig && (
<BotInfoSection config={props.botConfig} onChange={props.setBotConfig} />
),
personality: props.personalityConfig && (
<PersonalitySection config={props.personalityConfig} onChange={props.setPersonalityConfig} />
),
chat: props.chatConfig && (
if (tabGroups.length === 0 || !configSchema?.nested) {
return null
}
const renderTabContent = (tab: TabGroup) => {
const tabNestedEntries = tab.sections
.map((sectionName) => [sectionName, configSchema.nested?.[sectionName]] as const)
.filter((entry): entry is readonly [string, ConfigSchema] => Boolean(entry[1]))
if (tabNestedEntries.length === 0) {
return null
}
const values = Object.fromEntries(
tabNestedEntries.map(([sectionName]) => [sectionName, sectionValues[sectionName] ?? {}])
)
const tabSchema: ConfigSchema = {
className: tab.id,
classDoc: tab.label,
fields: [],
nested: Object.fromEntries(tabNestedEntries),
}
return (
<DynamicConfigForm
schema={{ className: 'ChatConfig', classDoc: '聊天配置', fields: [{ name: 'chat', type: 'object', label: '聊天', description: '聊天配置', required: false }], nested: {} }}
values={{ chat: props.chatConfig }}
onChange={(field, value) => {
if (field === 'chat') {
props.setChatConfig(value as ChatConfig)
props.setHasUnsavedChanges(true)
schema={tabSchema}
values={values}
onChange={(fieldPath, value) => {
const [sectionName, ...restPath] = fieldPath.split('.')
if (!sectionName) {
return
}
const currentSectionValue = sectionValues[sectionName] ?? {}
const nextSectionValue =
restPath.length === 0
? (value as ConfigSectionData)
: updateNestedValue(currentSectionValue, restPath, value)
setSectionValue(sectionName, nextSectionValue)
setHasUnsavedChanges(true)
}}
hooks={fieldHooks}
/>
),
expression: props.expressionConfig && (
<ExpressionSection config={props.expressionConfig} onChange={props.setExpressionConfig} />
),
emoji: props.emojiConfig && props.memoryConfig && props.toolConfig && props.voiceConfig && (
<FeaturesSection
emojiConfig={props.emojiConfig}
memoryConfig={props.memoryConfig}
toolConfig={props.toolConfig}
voiceConfig={props.voiceConfig}
onEmojiChange={props.setEmojiConfig}
onMemoryChange={props.setMemoryConfig}
onToolChange={props.setToolConfig}
onVoiceChange={props.setVoiceConfig}
/>
),
response_post_process: (
<>
{props.keywordReactionConfig && props.responsePostProcessConfig && props.chineseTypoConfig && props.responseSplitterConfig && (
<ProcessingSection
keywordReactionConfig={props.keywordReactionConfig}
responsePostProcessConfig={props.responsePostProcessConfig}
chineseTypoConfig={props.chineseTypoConfig}
responseSplitterConfig={props.responseSplitterConfig}
onKeywordReactionChange={props.setKeywordReactionConfig}
onResponsePostProcessChange={props.setResponsePostProcessConfig}
onChineseTypoChange={props.setChineseTypoConfig}
onResponseSplitterChange={props.setResponseSplitterConfig}
/>
)}
{props.messageReceiveConfig && (
<MessageReceiveSection config={props.messageReceiveConfig} onChange={props.setMessageReceiveConfig} />
)}
</>
),
dream: props.dreamConfig && (
<DreamSection config={props.dreamConfig} onChange={props.setDreamConfig} />
),
lpmm_knowledge: props.lpmmConfig && (
<LPMMSection config={props.lpmmConfig} onChange={props.setLpmmConfig} />
),
webui: props.webuiConfig && (
<WebUISection config={props.webuiConfig} onChange={props.setWebuiConfig} />
),
debug: (
<>
{props.logConfig && <LogSection config={props.logConfig} onChange={props.setLogConfig} />}
{props.debugConfig && <DebugSection config={props.debugConfig} onChange={props.setDebugConfig} />}
{props.experimentalConfig && <ExperimentalSection config={props.experimentalConfig} onChange={props.setExperimentalConfig} />}
{props.maimMessageConfig && <MaimMessageSection config={props.maimMessageConfig} onChange={props.setMaimMessageConfig} />}
{props.telemetryConfig && <TelemetrySection config={props.telemetryConfig} onChange={props.setTelemetryConfig} />}
</>
),
)
}
if (tabGroups.length === 0) return null
return (
<Tabs defaultValue={tabGroups[0].id} className="w-full">
<TabsList className="flex flex-wrap h-auto gap-1 p-1">
@@ -925,7 +987,7 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
</TabsList>
{tabGroups.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="space-y-4">
{tabContentMap[tab.id]}
{renderTabContent(tab)}
</TabsContent>
))}
</Tabs>

View File

@@ -0,0 +1,103 @@
import { useEffect, useMemo, useState } from 'react'
import { Textarea } from '@/components/ui/textarea'
import type { FieldHookComponent } from '@/lib/field-hooks'
import type { ConfigSchema, FieldSchema } from '@/types/config-schema'
interface JsonFieldHookOptions {
emptyValue: unknown
helperText: string
placeholder: string
}
function resolveLabel(schema?: ConfigSchema | FieldSchema, fieldPath?: string): string {
if (!schema) {
return fieldPath?.split('.').at(-1) || 'JSON 配置'
}
if ('label' in schema && schema.label) {
return schema.label
}
if ('uiLabel' in schema && schema.uiLabel) {
return schema.uiLabel
}
if ('classDoc' in schema && schema.classDoc) {
return schema.classDoc
}
if ('className' in schema && schema.className) {
return schema.className
}
return fieldPath?.split('.').at(-1) || 'JSON 配置'
}
function resolveDescription(schema?: ConfigSchema | FieldSchema): string {
if (!schema) {
return ''
}
if ('description' in schema) {
return schema.description || ''
}
if ('classDoc' in schema) {
return schema.classDoc || ''
}
return ''
}
export function createJsonFieldHook(options: JsonFieldHookOptions): FieldHookComponent {
const JsonFieldHook: FieldHookComponent = ({ fieldPath, onChange, schema, value }) => {
const normalizedValue = useMemo(() => {
if (value === undefined) {
return options.emptyValue
}
return value
}, [value])
const [editorValue, setEditorValue] = useState(() => JSON.stringify(normalizedValue, null, 2))
const [errorMessage, setErrorMessage] = useState('')
useEffect(() => {
setEditorValue(JSON.stringify(normalizedValue, null, 2))
setErrorMessage('')
}, [normalizedValue])
const label = resolveLabel(schema, fieldPath)
const description = resolveDescription(schema)
return (
<div className="space-y-3 rounded-lg border bg-card p-4 sm:p-6">
<div className="space-y-1">
<h3 className="text-base font-semibold">{label}</h3>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
<p className="text-xs text-muted-foreground">{options.helperText}</p>
</div>
<Textarea
className="min-h-[220px] font-mono text-sm"
placeholder={options.placeholder}
value={editorValue}
onChange={(event) => {
const nextValue = event.target.value
setEditorValue(nextValue)
try {
const parsed = JSON.parse(nextValue)
setErrorMessage('')
onChange?.(parsed)
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'JSON 格式错误')
}
}}
/>
{errorMessage ? (
<p className="text-sm text-destructive">JSON {errorMessage}</p>
) : (
<p className="text-sm text-muted-foreground">JSON 稿</p>
)}
</div>
)
}
return JsonFieldHook
}

View File

@@ -0,0 +1,49 @@
import { createJsonFieldHook } from './JsonFieldHookFactory'
export const ChatTalkValueRulesHook = createJsonFieldHook({
emptyValue: [],
helperText: '复杂对象数组使用 JSON 编辑。每一项对应一个聊天频率规则对象。',
placeholder: '[\n {\n "platform": "",\n "item_id": "",\n "rule_type": "group",\n "time": "00:00-23:59",\n "value": 1.0\n }\n]',
})
export const ExpressionLearningListHook = createJsonFieldHook({
emptyValue: [],
helperText: '表达学习配置较复杂,使用 JSON 编辑更稳妥。每一项对应一个学习规则。',
placeholder: '[\n {\n "platform": "",\n "item_id": "",\n "rule_type": "group",\n "use_expression": true,\n "enable_learning": true,\n "enable_jargon_learning": true\n }\n]',
})
export const ExpressionGroupsHook = createJsonFieldHook({
emptyValue: [],
helperText: '表达互通组使用 JSON 编辑。每一项包含一个 expression_groups 数组。',
placeholder: '[\n {\n "expression_groups": [\n {\n "platform": "qq",\n "item_id": "123456",\n "rule_type": "group"\n }\n ]\n }\n]',
})
export const ExperimentalChatPromptsHook = createJsonFieldHook({
emptyValue: [],
helperText: '实验配置中的定向 Prompt 列表使用 JSON 编辑。每一项应包含 platform、item_id、rule_type、prompt。',
placeholder: '[\n {\n "platform": "qq",\n "item_id": "123456",\n "rule_type": "group",\n "prompt": "这里填写额外提示词"\n }\n]',
})
export const KeywordRulesHook = createJsonFieldHook({
emptyValue: [],
helperText: '关键词规则为对象数组,建议直接编辑 JSON。',
placeholder: '[\n {\n "keywords": ["早安"],\n "regex": [],\n "reaction": "早安呀"\n }\n]',
})
export const RegexRulesHook = createJsonFieldHook({
emptyValue: [],
helperText: '正则规则为对象数组,建议直接编辑 JSON。',
placeholder: '[\n {\n "keywords": [],\n "regex": ["https?://[^\\\\s]+"],\n "reaction": "检测到链接:[0]"\n }\n]',
})
export const MCPRootItemsHook = createJsonFieldHook({
emptyValue: [],
helperText: 'MCP Roots 条目为对象数组,使用 JSON 编辑。',
placeholder: '[\n {\n "enabled": true,\n "uri": "file:///Users/example/project",\n "name": "project-root"\n }\n]',
})
export const MCPServersHook = createJsonFieldHook({
emptyValue: [],
helperText: 'MCP 服务器配置结构较复杂,使用 JSON 编辑。',
placeholder: '[\n {\n "name": "example-server",\n "enabled": true,\n "transport": "stdio",\n "command": "uvx",\n "args": ["example-server"],\n "env": {},\n "url": "",\n "headers": {},\n "http_timeout_seconds": 30.0,\n "read_timeout_seconds": 300.0,\n "authorization": {\n "mode": "none",\n "bearer_token": ""\n }\n }\n]',
})

View File

@@ -10,6 +10,16 @@ export type {
UseAutoSaveConfig,
UseAutoSaveReturnGeneric,
} from './useAutoSave'
export {
ChatTalkValueRulesHook,
ExperimentalChatPromptsHook,
ExpressionGroupsHook,
ExpressionLearningListHook,
KeywordRulesHook,
MCPRootItemsHook,
MCPServersHook,
RegexRulesHook,
} from './complexFieldHooks'
export { ChatSectionHook } from './ChatSectionHook'
export { PersonalitySectionHook } from './PersonalitySectionHook'
export { DebugSectionHook } from './DebugSectionHook'

View File

@@ -261,6 +261,7 @@ export type ConfigSectionName =
| 'expression'
| 'emoji'
| 'memory'
| 'relationship'
| 'tool'
| 'voice'
| 'message_receive'
@@ -276,3 +277,7 @@ export type ConfigSectionName =
| 'maim_message'
| 'telemetry'
| 'webui'
| 'database'
| 'maisaka'
| 'mcp'
| 'plugin_runtime'

View File

@@ -93,12 +93,12 @@ function PluginsPageContent() {
// 统一管理 WebSocket 和数据加载
useEffect(() => {
let ws: WebSocket | null = null
let unsubscribeProgress: (() => Promise<void>) | null = null
let isUnmounted = false
const init = async () => {
// 1. 先连接 WebSocket异步获取 token
ws = await connectPluginProgressWebSocket(
unsubscribeProgress = await connectPluginProgressWebSocket(
(progress) => {
if (isUnmounted) return
@@ -128,29 +128,7 @@ function PluginsPageContent() {
}
)
// 2. 等待 WebSocket 连接建立
await new Promise<void>((resolve) => {
if (!ws) {
resolve()
return
}
const checkConnection = () => {
if (ws && ws.readyState === WebSocket.OPEN) {
console.log('WebSocket connected, starting to load plugins')
resolve()
} else if (ws && ws.readyState === WebSocket.CLOSED) {
console.warn('WebSocket closed before loading plugins')
resolve()
} else {
setTimeout(checkConnection, 100)
}
}
checkConnection()
})
// 3. 检查 Git 状态
// 2. 检查 Git 状态
if (!isUnmounted) {
const statusResult = await checkGitStatus()
if (!statusResult.success) {
@@ -173,7 +151,7 @@ function PluginsPageContent() {
}
}
// 4. 获取麦麦版本
// 3. 获取麦麦版本
if (!isUnmounted) {
const versionResult = await getMaimaiVersion()
if (!versionResult.success) {
@@ -186,7 +164,7 @@ function PluginsPageContent() {
setMaimaiVersion(versionResult.data)
}
}
// 5. 加载插件列表(包含已安装信息)
// 4. 加载插件列表(包含已安装信息)
if (!isUnmounted) {
try {
setLoading(true)
@@ -282,8 +260,8 @@ function PluginsPageContent() {
return () => {
isUnmounted = true
if (ws) {
ws.close()
if (unsubscribeProgress) {
void unsubscribeProgress()
}
}
}, [toast])

View File

@@ -12,7 +12,15 @@ export type FieldType =
| 'object'
| 'textarea'
export type XWidgetType = 'slider' | 'select' | 'textarea' | 'switch' | 'custom'
export type XWidgetType =
| 'custom'
| 'input'
| 'number'
| 'password'
| 'select'
| 'slider'
| 'switch'
| 'textarea'
export interface FieldSchema {
name: string

View File

@@ -1,887 +0,0 @@
{
"1": [
{
"id": "know_1_1774770946.623486",
"content": "备战中考",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:55:46.623486"
},
{
"id": "know_1_1774771765.051286",
"content": "性别为女性",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:09:25.051286"
},
{
"id": "know_1_1774771851.333504",
"content": "用户是I人内向型人格",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:10:51.333504"
},
{
"id": "know_1_1774771894.517183",
"content": "用户名为小千,被他人称为“宝宝”,结合语境推测为女性",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:11:34.517183"
},
{
"id": "know_1_1774771923.859455",
"content": "小千是I人内向型人格",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:12:03.859455"
},
{
"id": "know_1_1774771993.479732",
"content": "小千是女性",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:13:13.479732"
},
{
"id": "know_1_1774772079.496335",
"content": "用户名为小千,被他人称为“宝宝”,推测为女性或处于亲密社交语境中(注:性别非明确陈述,但基于昵称高频使用及语境,高置信度归纳为女性或女性化称呼偏好,若严格遵循“明确表达”则此项存疑。鉴于指令要求“高置信度可归纳”,且群内互动模式符合典型女性向昵称习惯,此处提取为倾向性事实)",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:14:39.496335"
},
{
"id": "know_1_1774773435.68612",
"content": "用户名为小千",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:37:15.686120"
},
{
"id": "know_1_1774773676.69252",
"content": "用户自称猫娘(二次元人设)",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:41:16.692520"
}
],
"2": [
{
"id": "know_2_1774768612.298128",
"content": "性格自信,常以“真理在我这边”自居",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:16:52.298128"
},
{
"id": "know_2_1774768645.029561",
"content": "性格自信且带有自嘲精神,喜欢用轻松调侃的方式应对他人评价",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:17:25.029561"
},
{
"id": "know_2_1774771068.355999",
"content": "喜欢用夸张、幽默或古风修辞表达观点",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:57:48.355999"
},
{
"id": "know_2_1774771397.764996",
"content": "性格幽默,喜欢使用夸张比喻和古风表达",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:03:17.764996"
},
{
"id": "know_2_1774771471.03367",
"content": "幽默风趣,喜欢使用夸张比喻和玩梗",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:04:31.033670"
},
{
"id": "know_2_1774771765.052285",
"content": "性格不孤僻,社交圈较广",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:09:25.052285"
},
{
"id": "know_2_1774771851.33601",
"content": "用户表现出社恐倾向,喜欢回避社交互动",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:10:51.336010"
},
{
"id": "know_2_1774771894.520185",
"content": "性格偏向内向I人有社恐倾向喜欢回避社交压力",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:11:34.520185"
},
{
"id": "know_2_1774771958.585244",
"content": "小千是内向型人格I人",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:12:38.585244"
},
{
"id": "know_2_1774771993.481732",
"content": "小千性格内向I人",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:13:13.481732"
}
],
"3": [
{
"id": "know_3_1774773676.695521",
"content": "喜欢冰淇淋",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:41:16.695521"
}
],
"4": [],
"5": [],
"6": [
{
"id": "know_6_1774768486.451792",
"content": "正在搭建 RAG 测试集",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:14:46.451792"
},
{
"id": "know_6_1774768517.122405",
"content": "熟悉 NapCat、RAG 等技术工具及互联网梗文化",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:15:17.122405"
},
{
"id": "know_6_1774769406.247087",
"content": "喜欢动漫风格插画",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:30:06.247087"
},
{
"id": "know_6_1774770487.207364",
"content": "关注显卡硬件参数(如显存、型号)及深度学习/炼丹应用",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:48:07.207364"
},
{
"id": "know_6_1774770487.209372",
"content": "对游戏光影效果感兴趣",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:48:07.209372"
},
{
"id": "know_6_1774770603.063873",
"content": "喜欢玩《我的世界》和VRChat",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:50:03.063873"
},
{
"id": "know_6_1774770654.654349",
"content": "关注显卡硬件参数如4090、48G显存、5090",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:50:54.654349"
},
{
"id": "know_6_1774770654.655356",
"content": "使用VRChat进行社交娱乐",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:50:54.655356"
},
{
"id": "know_6_1774770734.287947",
"content": "关注显卡硬件如4090、3050及AI炼丹技术",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:52:14.287947"
},
{
"id": "know_6_1774770734.289944",
"content": "玩《我的世界》并配置光影效果",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:52:14.289944"
},
{
"id": "know_6_1774770734.291944",
"content": "计划游玩VRChat",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:52:14.291944"
},
{
"id": "know_6_1774771033.111011",
"content": "喜欢玩VRChat",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:57:13.111011"
},
{
"id": "know_6_1774771068.358999",
"content": "关注VRChat等虚拟现实游戏及硬件性能话题",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:57:48.358999"
},
{
"id": "know_6_1774771233.980219",
"content": "使用VRChatVRC",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:00:33.980219"
},
{
"id": "know_6_1774771397.766996",
"content": "对VRChatVRC及虚拟形象社交感兴趣",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:03:17.766996"
},
{
"id": "know_6_1774771471.03567",
"content": "对VRChat等虚拟社交游戏感兴趣",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:04:31.035670"
},
{
"id": "know_6_1774771894.521183",
"content": "熟悉二次元文化、动漫角色及互联网流行梗Meme",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:11:34.521183"
},
{
"id": "know_6_1774771923.861534",
"content": "小千玩CS:GO游戏",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:12:03.861534"
},
{
"id": "know_6_1774771958.587243",
"content": "回声者_Echoderd喜欢玩CS:GO游戏",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:12:38.587243"
},
{
"id": "know_6_1774771993.483732",
"content": "小千喜欢二次元文化及动漫游戏圈梗",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:13:13.483732"
},
{
"id": "know_6_1774772079.499335",
"content": "熟悉并喜爱二次元文化、动漫角色及互联网梗图(如阴间美学、病娇系、黑长直萌妹等风格)",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:14:39.499335"
},
{
"id": "know_6_1774772112.716455",
"content": "小千关注CS:GO游戏及中考备考话题",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:15:12.716455"
},
{
"id": "know_6_1774772154.873237",
"content": "用户玩CS:GO游戏",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:15:54.873237"
},
{
"id": "know_6_1774772186.438797",
"content": "玩CS:GO游戏",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:16:26.438797"
},
{
"id": "know_6_1774772730.867535",
"content": "熟悉《我的青春恋爱物语果然有问题》及二次元表情包文化",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:25:30.867535"
},
{
"id": "know_6_1774773338.849271",
"content": "熟悉《原神》等二次元游戏及网络梗文化",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:35:38.849271"
},
{
"id": "know_6_1774773371.406209",
"content": "关注高分屏字体显示效果",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:36:11.406209"
},
{
"id": "know_6_1774773401.48921",
"content": "熟悉电脑显示技术(如高分屏字体选择)",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:36:41.489210"
},
{
"id": "know_6_1774773435.688119",
"content": "关注高分屏显示效果与字体选择(无衬线/衬线体)",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:37:15.688119"
},
{
"id": "know_6_1774773608.256103",
"content": "关注屏幕字体与分辨率(无衬线/有衬线)",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:40:08.256103"
},
{
"id": "know_6_1774773645.671546",
"content": "关注屏幕分辨率与字体显示效果(高分屏/无衬线体)",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:40:45.671546"
},
{
"id": "know_6_1774773676.698035",
"content": "关注字体设计(无衬线体)",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:41:16.698035"
},
{
"id": "know_6_1774773740.83822",
"content": "喜欢二次元文化及 VTuber 风格内容",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:42:20.838220"
}
],
"7": [
{
"id": "know_7_1774768517.120403",
"content": "从事 RAG 测试集搭建或相关技术工作",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:15:17.120403"
},
{
"id": "know_7_1774768573.741823",
"content": "从事 RAG检索增强生成测试集搭建相关工作",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:16:13.741823"
},
{
"id": "know_7_1774770603.062873",
"content": "备战中考",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:50:03.062873"
},
{
"id": "know_7_1774771471.036668",
"content": "正在备战中考的学生",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:04:31.036668"
},
{
"id": "know_7_1774771923.862535",
"content": "小千正在备战中考",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:12:03.862535"
},
{
"id": "know_7_1774771958.588749",
"content": "回声者_Echoderd正在备战中考",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:12:38.588749"
},
{
"id": "know_7_1774772112.714455",
"content": "小千使用AI模型进行对话",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:15:12.714455"
},
{
"id": "know_7_1774772154.870238",
"content": "用户正在备战中考",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:15:54.870238"
},
{
"id": "know_7_1774773185.194069",
"content": "使用 NapCat 框架",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:33:05.194069"
},
{
"id": "know_7_1774773338.851275",
"content": "使用 NapCat 框架,具备技术平台认知能力",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:35:38.851275"
},
{
"id": "know_7_1774773371.403696",
"content": "熟悉 NapCat 框架",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:36:11.403696"
}
],
"8": [
{
"id": "know_8_1774770946.624486",
"content": "日常逛游戏地图",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:55:46.624486"
},
{
"id": "know_8_1774771397.769034",
"content": "备考中考期间仍保持日常游戏娱乐习惯",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:03:17.769034"
},
{
"id": "know_8_1774771851.338018",
"content": "用户有备考中考的学习任务",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:10:51.338018"
},
{
"id": "know_8_1774771894.523189",
"content": "备考中(备战中考)",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:11:34.523189"
},
{
"id": "know_8_1774771993.484733",
"content": "小千有打CS:GO的游戏习惯",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:13:13.484733"
},
{
"id": "know_8_1774772079.501334",
"content": "有在高压环境下如中考前进行游戏娱乐CS:GO的习惯自称或认同“摆烂”的生活态度",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:14:39.501334"
},
{
"id": "know_8_1774772154.875743",
"content": "用户在备考期间有打游戏摸鱼的习惯",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:15:54.875743"
},
{
"id": "know_8_1774773435.690121",
"content": "习惯使用表情包表达情绪或进行网络互动",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:37:15.690121"
},
{
"id": "know_8_1774773676.701034",
"content": "备战中考",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:41:16.701034"
}
],
"9": [],
"10": [
{
"id": "know_10_1774768486.452792",
"content": "沟通风格带有调侃和自信,习惯用反问句表达观点",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:14:46.452792"
},
{
"id": "know_10_1774768517.121403",
"content": "沟通风格带有较强的好胜心和防御性,习惯用反问和调侃回应质疑",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:15:17.121403"
},
{
"id": "know_10_1774768573.742824",
"content": "沟通风格幽默,擅长使用逻辑闭环和反问句式进行辩论或调侃",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:16:13.742824"
},
{
"id": "know_10_1774768612.299126",
"content": "沟通风格幽默风趣,擅长使用网络梗和表情包互动",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:16:52.299126"
},
{
"id": "know_10_1774768612.299845",
"content": "偶尔会文绉绉地表达(自称“文青病犯了”),但能迅速切换回口语化",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:16:52.299845"
},
{
"id": "know_10_1774768645.028561",
"content": "沟通风格幽默风趣,偶尔会文青病发作使用古风表达",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:17:25.028561"
},
{
"id": "know_10_1774769406.249584",
"content": "沟通中常使用文言文或半文言表达",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:30:06.249584"
},
{
"id": "know_10_1774769406.251097",
"content": "习惯用反问句和夸张语气进行互动",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:30:06.251097"
},
{
"id": "know_10_1774770487.211056",
"content": "沟通风格幽默,常使用网络梗和夸张表达",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:48:07.211056"
},
{
"id": "know_10_1774771471.038677",
"content": "沟通风格轻松随意,善于接话和调侃",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:04:31.038677"
},
{
"id": "know_10_1774771765.053285",
"content": "沟通风格活泼,喜欢使用语气词和表情符号撒娇",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:09:25.053285"
},
{
"id": "know_10_1774772079.503333",
"content": "沟通风格幽默调侃,擅长用反话(如“烦到了”)和夸张修辞(如“耳朵起茧子”、“要报警了”)表达情绪",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:14:39.503333"
},
{
"id": "know_10_1774773338.853274",
"content": "沟通风格幽默风趣,擅长玩梗与自嘲",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:35:38.853274"
},
{
"id": "know_10_1774773371.408719",
"content": "喜欢用幽默调侃的方式回应他人",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:36:11.408719"
},
{
"id": "know_10_1774773401.491209",
"content": "沟通风格幽默风趣,擅长玩梗和角色扮演",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:36:41.491209"
},
{
"id": "know_10_1774773435.693121",
"content": "沟通风格幽默、喜欢玩梗和自嘲,擅长接话茬",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:37:15.693121"
},
{
"id": "know_10_1774773532.488374",
"content": "沟通风格幽默,喜欢使用网络梗和表情包活跃气氛",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:38:52.488374"
},
{
"id": "know_10_1774773532.490959",
"content": "在争论中倾向于据理力争,并自嘲或调侃对方阅读理解能力",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:38:52.490959"
},
{
"id": "know_10_1774773569.709356",
"content": "喜欢用幽默、夸张和自嘲的方式活跃气氛",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:39:29.709356"
}
],
"11": [
{
"id": "know_11_1774771068.360999",
"content": "乐于接受并学习新的技术技巧(如加速器用法)",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:57:48.360999"
}
],
"12": [
{
"id": "know_12_1774770654.657355",
"content": "面对网络延迟问题倾向于寻找加速器解决方案",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T15:50:54.657355"
},
{
"id": "know_12_1774773185.196068",
"content": "备战中考",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:33:05.196068"
},
{
"id": "know_12_1774773740.836223",
"content": "面对压力或冲突时,倾向于通过撒娇、耍赖和寻求盟友支持来应对",
"metadata": {
"session_id": "628336b082552269377e9d0648e26c60",
"source": "maisaka_learning"
},
"created_at": "2026-03-29T16:42:20.836223"
}
]
}

View File

@@ -1,5 +0,0 @@
{action_name}
动作描述:{action_description}
使用条件{parallel_text}
{action_require}
{{"action":"{action_name}",{action_parameters}, "target_message_id":"消息id(m+数字)"}}

View File

@@ -1,9 +1,9 @@
{action_name}
动作描述:{action_description}
使用条件:
Action description: {action_description}
Usage conditions:
{action_require}
{{
"action": "{action_name}",{action_parameters},
"target_message_id":"触发action的消息id",
"reason":"触发action的原因"
}}
"target_message_id":"message ID that triggered the action",
"reason":"reason for triggering the action"
}}

View File

@@ -1,77 +1,37 @@
{time_block}
{name_block}
{chat_context_description},以下是具体的聊天内容
Your task is to analyze the conversation and the interactions happening in the chat.
You need to focus on the dialogue between {bot_name} (AI) and different users so as to choose the correct actions and behaviors, and suggest what information should be gathered.
**聊天内容**
{chat_content_block}
[Reference Information]
{bot_name}'s persona: {identity}
[End of Reference Information]
**动作记录**
{actions_before_now_block}
You need to analyze based on the provided reference information, the current scenario, and the output rules.
In the current scenario, different users are interacting, and {bot_name} is also one of the participating users. Users may also be chatting with each other. Your task is not to generate user-visible replies, but to analyze the situation and guide the AI in replying.
"Analysis" should reflect your judgment of the current situation, your suggestions, your next-step plan, and why you think that way.
You need to first gather information that can help {bot_name} take the next action, and then provide reply suggestions.
**可用的action**
reply
动作描述:
进行回复,你可以自然的顺着正在进行的聊天内容进行回复或自然的提出一个问题
{{
"action": "reply",
"target_message_id":"想要回复的消息id",
"reason":"回复的原因"
}}
You can use these tools:
- wait(seconds) - Temporarily pause the conversation, wait for `seconds`, hand the speaking turn to the user, and wait for the other party's new message.
- no_reply() - When you judge that {bot_name} should not speak right now, end the conversation and do not reply in any way until the other party sends a new message.
- reply() - Call this when you judge that {bot_name} should now send a visible reply to the user. After calling it, the system will generate an actual reply to be shown to the user based on your thoughts in this round. You may reply to a specific user or to all users.
- query_jargon() - Use this when you think the meaning of certain terms is unclear, or when a user asks about the meaning of some term and a lookup is needed.
- Other defined tools may also be used as appropriate.
wait
动作描述:
暂时不再发言,等待指定时间。适用于以下情况:
- 你已经表达清楚一轮,想给对方留出空间
- 你感觉对方的话还没说完,或者自己刚刚发了好几条连续消息
- 你想要等待一定时间来让对方把话说完,或者等待对方反应
- 你想保持安静,专注"听"而不是马上回复
请你根据上下文来判断要等待多久,请你灵活判断:
- 如果你们交流间隔时间很短,聊的很频繁,不宜等待太久
- 如果你们交流间隔时间很长,聊的很少,可以等待较长时间
{{
"action": "wait",
"target_message_id":"想要作为这次等待依据的消息id通常是对方的最新消息",
"wait_seconds": 等待的秒数必填例如5 表示等待5秒,
"reason":"选择等待的原因"
}}
Tool usage rules:
1. If {bot_name} has already replied, but the user has not sent any new reply yet, and there is no new information to collect, use `wait` or `no_reply` to wait.
2. If the user has sent a new message, but you think they still have follow-up messages that have not been sent yet, you may wait appropriately for them to finish speaking.
3. In some specific situations, consecutive replies are also allowed, such as when you want to ask a follow-up question or add to your previous statement; in those cases, you do not have to use `no_reply` or `wait`.
4. You need to control your speaking frequency. If it is a one-on-one chat, you may speak at a relatively even frequency. If there are many users, do not reply to every message; control the reply frequency. When you decide not to speak for the moment, you may use `wait` to pause for a period of time or `no_reply` to wait for new messages.
5. Do not reply to every message. Do not directly reply to sticker-only messages sent by other users. Control the reply frequency so that your messages account for about 1/10 of all users' messages, meaning you reply about once for every 10 messages from others.
6. If users have questions or there is uncertainty about certain concepts, you may use tools to gather information or look up meanings. You may use multiple tools.
complete_talk
动作描述:
当前聊天暂时结束了,对方离开,没有更多话题了
你可以使用该动作来暂时休息,等待对方有新发言再继续:
- 多次wait之后对方迟迟不回复消息才用
- 如果对方只是短暂不回复应该使用wait而不是complete_talk
- 聊天内容显示当前聊天已经结束或者没有新内容时候选择complete_talk
选择此动作后,将不再继续循环思考,直到收到对方的新消息
{{
"action": "complete_talk",
"target_message_id":"触发完成对话的消息id通常是对方的最新消息",
"reason":"选择完成对话的原因"
}}
Your analysis rules:
1. By default, directly output your latest analysis. Do not repeat previous analysis content. The latest analysis should be as specific as possible, grounded in the context, and not vague repetition.
2. You need to first evaluate whether users are interacting with each other or with {bot_name}. Do not jump in blindly and reply to the wrong target.
3. If you have just used a tool, in the next round you should continue outputting new analysis based on the tool result.
4. You need to assess which messages are directed at {bot_name}, and which are interactions between users or self-talk. Do not frequently insert yourself into unrelated topics.
5. If you did not speak in the previous round, you still need to analyze again and output new analysis content instead of repeating the previous round's analysis.
{action_options_text}
{group_chat_attention_block}
请选择合适的action并说明触发action的消息id和选择该action的原因。消息id格式:m+数字
先输出你的选择思考理由再输出你选择的action理由是一段平文本不要分点精简。
**动作选择要求**
请你根据聊天内容,用户的最新消息和以下标准选择合适的动作:
{plan_style}
{moderation_prompt}
请选择所有符合使用要求的action动作用json格式输出如果输出多个json每个json都要单独用```json包裹你可以重复使用同一个动作或不同动作:
**示例**
// 理由文本
```json
{{
"action":"动作名",
"target_message_id":"触发动作的消息id",
//对应参数
}}
```
```json
{{
"action":"动作名",
"target_message_id":"触发动作的消息id",
//对应参数
}}
```
Now, please output your analysis of how {bot_name} should speak. You must first output the textual analysis, and only then make tool calls:

View File

@@ -1 +0,0 @@
你正在qq群里聊天下面是群里正在聊的内容:

View File

@@ -1 +0,0 @@
正在群里聊天

View File

@@ -1 +0,0 @@
你正在和{sender_name}聊天,这是你们之前聊的内容:

View File

@@ -1 +0,0 @@
和{sender_name}聊天

View File

@@ -3,14 +3,14 @@
{chat_info}
{identity}
你正在{chat_target_2},{reply_target_block}
现在请你对这句内容进行改写,请你参考上述内容进行改写,原句是:{raw_reply}
原因是:{reason}
现在请你将这条具体内容改写成一条适合在群聊中发送的回复消息。
你需要使用合适的语法和句法,参考聊天内容,组织一条日常且口语化的回复。请你修改你想表达的原句,符合你的表达风格和语言习惯
You are currently in {chat_target_2},{reply_target_block}
Now please rewrite this sentence. Please refer to the content above when rewriting. The original sentence is: {raw_reply}:
The reason is: {reason}
Now please rewrite this specific content into a reply message suitable for sending in a group chat.
You need to use appropriate grammar and syntax, refer to the chat content, and organize a natural, colloquial reply for daily conversation. Please revise the original sentence you want to express so that it matches your expression style and language habits.
{reply_style}
你可以完全重组回复,保留最基本的表达含义就好,但重组后保持语意通顺。
You may completely restructure the reply. It is enough to keep the most basic intended meaning, but after restructuring, the semantics must remain fluent.
{keywords_reaction_prompt}
{moderation_prompt}
不要输出多余内容(包括冒号和引号,表情包,emoji,at或 @等 ),只输出一条回复就好。不要思考的太长。
改写后的回复:
Do not output any extra content (including colons, quotation marks, stickers, emoji, at, or @). Only output a single reply. Do not think for too long.
Rewritten reply:

View File

@@ -1,5 +1,5 @@
这是一个聊天场景中的表情包描述:"{description}"
This is a description of a sticker used in a chat scene: "{description}"
请你识别这个表情包的含义和适用场景给我简短的描述每个描述不要超过15个字
你可以关注其幽默和讽刺意味动用贴吧微博小红书的知识必须从互联网梗、meme的角度去分析
请直接输出描述,不要出现任何其他内容,如果有多个描述,可以用逗号分隔
Please identify the meaning and suitable usage scenarios of this sticker, and give me brief descriptions. Each description should not exceed 15 words.
You may focus on its humorous and sarcastic meaning, draw on knowledge from Tieba, Weibo, and Xiaohongshu, and you must analyze it from the perspective of internet slang and memes.
Please directly output the descriptions without any other content. If there are multiple descriptions, separate them with commas.

View File

@@ -1,6 +1,6 @@
这是一个表情包,请对这个表情包进行审核,标准如下:
1. 必须符合"{demand}"的要求
2. 不能是色情、暴力、等违法违规内容,必须符合公序良俗
3. 不能是任何形式的截图,聊天记录或视频截图
4. 不要出现5个以上文字
请回答这个表情包是否满足上述要求,是则回答是,否则回答否,不要出现任何其他内容
This is a sticker. Please review it according to the following criteria:
1. It must meet the requirement of "{demand}"
2. It must not contain pornography, violence, or other illegal or non-compliant content, and it must conform to public order and good morals
3. It must not be any form of screenshot, chat record, or video screenshot
4. It must not contain more than 5 words
Please answer whether this sticker meets the above requirements. If it does, answer yes; otherwise answer no. Do not output any other content.

View File

@@ -1,12 +1,12 @@
{nickname}的表情包存储已满({emoji_num}/{emoji_num_max}),需要决定是否删除一个旧表情包来为新表情包腾出空间。
{nickname}'s sticker storage is full ({emoji_num}/{emoji_num_max}), and it is necessary to decide whether to delete an old sticker to make room for a new sticker.
新表情包信息:
描述: {description}
New sticker information:
Description: {description}
现有表情包列表:
Existing sticker list:
{emoji_list}
请决定:
1. 是否要删除某个现有表情包来为新表情包腾出空间?
2. 如果要删除,应该删除哪一个(给出编号)
请只回答:'不删除'或'删除编号X'(X为表情包编号)。
Please decide:
1. Whether to delete an existing sticker to make room for the new sticker
2. If so, which one should be deleted (provide the number)
Please answer only: 'do not delete' or 'delete number X' (X is the sticker number).

View File

@@ -1,15 +1,15 @@
请评估以下表达方式或语言风格以及使用条件或使用情景是否合适:
使用条件或使用情景:{situation}
表达方式或言语风格:{style}
Please evaluate whether the following expression or language style, together with its usage condition or usage scenario, is appropriate:
Usage condition or usage scenario: {situation}
Expression or language style: {style}
请从以下方面进行评估:
Please evaluate from the following aspects:
{criteria_list}
请以JSON格式输出评估结果
Please output the evaluation result in JSON format:
{{
"suitable": true/false,
"reason": "评估理由(如果不合适,请说明原因)"
"reason": "Reason for evaluation (if inappropriate, explain why)"
}}
如果合适suitable设为true如果不合适suitable设为false并在reason中说明原因。
请严格按照JSON格式输出不要包含其他内容。
If it is appropriate, set suitable to true. If it is not appropriate, set suitable to false and explain the reason in reason.
Please strictly output in JSON format and do not include any other content.

View File

@@ -1,22 +1,22 @@
{chat_observe_info}
你的名字是{bot_name}{target_message}
Your name is {bot_name}{target_message}
{reply_reason_block}
以下是可选的表达情境:
The following are optional expression situations:
{all_situations}
请你分析聊天内容的语境、情绪、话题类型,从上述情境中选择最适合当前聊天情境的,最多{max_num}个情境。
考虑因素包括:
1.聊天的情绪氛围(轻松、严肃、幽默等)
2.话题类型(日常、技术、游戏、情感等)
3.情境与当前语境的匹配度
Please analyze the context, emotional tone, and topic type of the chat content, and select the situations above that best fit the current chat situation, up to {max_num} situations.
Factors to consider include:
1. The emotional atmosphere of the chat (relaxed, serious, humorous, etc.)
2. The topic type (daily life, technology, games, emotions, etc.)
3. The degree of match between the situation and the current context
{target_message_extra_block}
请以JSON格式输出只需要输出选中的情境编号
例如:
Please output in JSON format. You only need to output the selected situation numbers:
For example:
{{
"selected_situations": [2, 3, 5, 7, 19]
}}
请严格按照JSON格式输出不要包含其他内容
Please strictly output in JSON format and do not include any other content:

View File

@@ -1,27 +1,27 @@
【历史话题标题列表】(仅标题,不含具体内容):
[Historical Topic Title List] (titles only, no specific content):
{history_topics_block}
【历史话题标题列表结束】
[End of Historical Topic Title List]
【本次聊天记录】(每条消息前有编号,用于后续引用):
[Current Chat Log] (each message has an index before it for later reference):
{messages_block}
【本次聊天记录结束】
[End of Current Chat Log]
请完成以下任务:
**识别话题**
1. 识别【本次聊天记录】中正在进行的一个或多个话题;
2. 【本次聊天记录】的中的消息可能与历史话题有关,也可能毫无关联。
2. 判断【历史话题标题列表】中的话题是否在【本次聊天记录】中出现,如果出现,则直接使用该历史话题标题字符串;
Please complete the following tasks:
**Identify topics**
1. Identify one or more ongoing topics in the [Current Chat Log];
2. Messages in the [Current Chat Log] may be related to historical topics, or they may be completely unrelated;
3. Determine whether the topics in the [Historical Topic Title List] appear in the [Current Chat Log]. If they do, directly use that historical topic title string;
**选取消息**
1. 对于每个话题(新话题或历史话题),从上述带编号的消息中选出与该话题强相关的消息编号列表;
2. 每个话题用一句话清晰地描述正在发生的事件,必须包含时间(大致即可)、人物、主要事件和主题,保证精准且有区分度;
**Select messages**
1. For each topic (whether new or historical), select a list of message indices from the numbered messages above that are strongly related to that topic;
2. For each topic, use one sentence to clearly describe the event that is happening. It must include time (approximate is fine), people, the main event, and the theme, ensuring accuracy and distinction;
请先输出一段简短思考,说明有什么话题,哪些是不包含在历史话题中的,哪些是包含在历史话题中的,并说明为什么;
然后严格以 JSON 格式输出【本次聊天记录】中涉及的话题,格式如下:
Please first output a short piece of reasoning explaining what topics exist, which are not included in the historical topics, which are included in the historical topics, and why;
Then strictly output the topics involved in the [Current Chat Log] in JSON format as follows:
[
{{
"topic": "话题",
"topic": "topic",
"message_indices": [1, 2, 5]
}},
...
]
]

View File

@@ -1,22 +1,22 @@
请基于以下话题,对聊天记录片段进行概括,提取以下信息:
Please summarize the following chat record segment based on the topic and extract the following information:
**话题**{topic}
**Topic**: {topic}
**要求**
1. 关键词提取与话题相关的关键词用列表形式返回3-10个关键词
2. 概括对这段话的平文本概括50-200字要求
- 仔细地转述发生的事件和聊天内容;
- 重点突出事件的发展过程和结果;
- 围绕话题这个中心进行概括。
- 提取话题中的关键信息点,关键信息点应该简洁明了。
**Requirements**:
1. Keywords: extract keywords related to the topic and return them as a list (3-10 keywords)
2. Summary: provide a plain-text summary of this segment (50-200 words). Requirements:
- Carefully retell the event and the chat content;
- Highlight the development process and the result of the event;
- Summarize around the central topic;
- Extract the key information points in the topic, and keep them concise and clear.
请以JSON格式返回格式如下
Please return in JSON format as follows:
{{
"keywords": ["关键词1", "关键词2", ...],
"summary": "概括内容"
"keywords": ["keyword1", "keyword2", ...],
"summary": "summary content"
}}
聊天记录:
Chat record:
{original_text}
请直接返回JSON不要包含其他内容。
Please return JSON directly and do not include any other content.

View File

@@ -1,15 +1,15 @@
**推断结果1基于上下文**
**Inference Result 1 (based on context)**
{inference1}
**推断结果2仅基于词条**
**Inference Result 2 (based only on the term itself)**
{inference2}
请比较这两个推断结果,判断它们是否相同或类似。
- 如果两个推断结果的"含义"相同或类似,说明这个词条不是黑话(含义明确)
- 如果两个推断结果有差异,说明这个词条可能是黑话(需要上下文才能理解)
Please compare these two inference results and determine whether they are the same or similar.
- If the "meaning" in the two inference results is the same or similar, it means this term is not jargon (its meaning is clear)
- If the two inference results differ, it means this term may be jargon (it can only be understood with context)
以 JSON 格式输出:
Output in JSON format:
{{
"is_similar": true/false,
"reason": "判断理由"
}}
"reason": "Reason for the judgment"
}}

View File

@@ -1,11 +1,11 @@
上下文聊天内容:
Context chat content:
{chat_context}
在上下文中提取到的黑话及其含义:
Jargon extracted from the context and their meanings:
{jargon_explanations}
请根据上述信息,对黑话解释进行概括和整理。
- 如果上下文中有黑话出现,请简要说明这些黑话在上下文中的使用情况
- 将所有黑话解释整理成简洁、易读的一段话
- 输出格式要自然,适合作为回复参考信息
请输出概括后的黑话解释直接输出一段平文本不要标题无特殊格式或markdown格式不要使用JSON格式
Please summarize and organize the jargon explanations based on the information above.
- If jargon appears in the context, briefly explain how the jargon is used in the context
- Organize all jargon explanations into a concise and readable paragraph
- The output format should be natural and suitable as reference information for replies
Please output the summarized jargon explanations (directly output one plain-text paragraph, no title, no special formatting or Markdown, and do not use JSON):

View File

@@ -1,11 +1,11 @@
**词条内容**
**Term content**
{content}
请仅根据这个词条本身,推断其含义。
- 如果这是一个黑话、俚语或网络用语,请推断其含义
- 如果含义明确(常规词汇),也请说明
Please infer its meaning based only on this term itself.
- If this is jargon, slang, or internet language, please infer its meaning
- If the meaning is clear (ordinary vocabulary), please also explain it
以 JSON 格式输出:
Output in JSON format:
{{
"meaning": "详细含义说明(包含使用场景、来源、具体解释等)"
}}
"meaning": "Detailed explanation of the meaning (including usage scenarios, source, specific explanation, etc.)"
}}

View File

@@ -1,19 +1,19 @@
**词条内容**
**Term content**
{content}
**词条出现的上下文。其中的{bot_name}的发言内容是你自己的发言**
**The context in which the term appears. The lines spoken by {bot_name} are your own lines**
{raw_content_list}
{previous_meaning_section}
请根据上下文,推断"{content}"这个词条的含义。
- 如果这是一个黑话、俚语或网络用语,请推断其含义
- 如果含义明确(常规词汇),也请说明
- {bot_name} 的发言内容可能包含错误,请不要参考其发言内容
- 如果上下文信息不足,无法推断含义,请设置 no_info true
Please infer the meaning of the term "{content}" based on the context.
- If this is jargon, slang, or internet language, please infer its meaning
- If the meaning is clear (ordinary vocabulary), please also explain it
- The lines spoken by {bot_name} may contain mistakes, so do not rely on those lines
- If the contextual information is insufficient and the meaning cannot be inferred, please set no_info to true
{previous_meaning_instruction}
以 JSON 格式输出:
Output in JSON format:
{{
"meaning": "详细含义说明(包含使用场景、来源、具体解释等)",
"meaning": "Detailed explanation of the meaning (including usage scenarios, source, specific explanation, etc.)",
"no_info": false
}}
注意:如果信息不足无法推断,请设置 "no_info": true此时 meaning 可以为空字符串
Note: If there is insufficient information to infer the meaning, please set "no_info": true. In that case, meaning may be an empty string.

View File

@@ -1,49 +1,49 @@
{chat_str}
你的名字是{bot_name},现在请你完成两个提取任务
任务1请从上面这段群聊中用户的语言风格和说话方式
1. 只考虑文字,不要考虑表情包和图片
2. 不要总结SELF的发言因为这是你自己的发言不要重复学习你自己的发言
3. 不要涉及具体的人名,也不要涉及具体名词
4. 思考有没有特殊的梗,一并总结成语言风格
5. 例子仅供参考,请严格根据群聊内容总结!!!
注意:总结成如下格式的规律,总结的内容要详细,但具有概括性:
例如:当"AAAAA"时,可以"BBBBB", AAAAA代表某个场景不超过20个字。BBBBB代表对应的语言风格特定句式或表达方式不超过20个字。
表达方式在3-5个左右不要超过10
Your name is {bot_name}. Now please complete two extraction tasks.
Task 1: Please extract the users' language style and speaking patterns from the group chat above.
1. Only consider text; do not consider stickers or images
2. Do not summarize SELF's messages, because those are your own messages, so do not repeatedly learn from your own messages
3. Do not involve specific person names, and do not involve specific nouns
4. Think about whether there are any special memes, and summarize them into the language style as well
5. The examples are for reference only. Please summarize strictly according to the group chat content!!!
Note: Summarize them into rules in the following format. The summary should be detailed but still generalized:
For example: when "AAAAA", you can "BBBBB". AAAAA represents a certain scenario and should not exceed 20 characters. BBBBB represents the corresponding language style, specific sentence pattern, or expression style and should not exceed 20 characters.
There should be around 3-5 expression styles, and no more than 10.
任务2请从上面这段聊天内容中提取"可能是黑话"的候选项(黑话/俚语/网络缩写/口头禅)。
- 必须为对话中真实出现过的短词或短语
- 必须是你无法理解含义的词语,没有明确含义的词语,请不要选择有明确含义,或者含义清晰的词语
- 排除:人名、@、表情包/图片中的内容、纯标点、常规功能词(如的、了、呢、啊等)
- 每个词条长度建议 2-8 个字符(不强制),尽量短小
- 请你提取出可能的黑话最多30个黑话请尽量提取所有
Task 2: Please extract candidate items from the chat content above that "may be jargon" (jargon/slang/internet abbreviations/catchphrases).
- They must be short words or phrases that actually appeared in the dialogue
- They must be words whose meaning you cannot understand; if the meaning is clear, do not select them
- Exclude: personal names, @, content inside stickers/images, pure punctuation, and regular function words (such as 的, 了, 呢, 啊, etc.)
- Each term is recommended to be 2-8 characters long (not mandatory), and should be as short as possible
- Please extract as many possible jargon items as you can, up to 30 in total
黑话必须为以下几种类型:
- 由字母构成的汉语拼音首字母的简写词例如nb、yydsxswl
- 英文词语的缩写,用英文字母概括一个词汇或含义,例如:CPUGPUAPI
- 中文词语的缩写,用几个汉字概括一个词汇或含义,例如:社死、内卷
The jargon must be one of the following types:
- Abbreviations made of letters and formed from the initials of Chinese pinyin, such as: nb, yyds, xswl
- English abbreviations that summarize a word or meaning with letters, such as: CPU, GPU, API
- Chinese abbreviations that summarize a word or meaning with a few Chinese characters, such as: 社死, 内卷
输出要求:
将表达方式,语言风格和黑话以 JSON 数组输出,每个元素为一个对象,结构如下(注意字段名):
注意请不要输出重复内容,请对表达方式和黑话进行去重。
Output requirements:
Output the expression styles, language styles, and jargon as a JSON array. Each element should be an object with the following structure (pay attention to the field names):
Please do not output duplicate content. Deduplicate both expression styles and jargon.
[
{{"situation": "AAAAA", "style": "BBBBB", "source_id": "3"}},
{{"situation": "CCCC", "style": "DDDD", "source_id": "7"}}
{{"situation": "对某件事表示十分惊叹", "style": "使用 我嘞个xxxx", "source_id": "[消息编号]"}},
{{"situation": "表示讽刺的赞同,不讲道理", "style": "对对对", "source_id": "[消息编号]"}},
{{"situation": "当涉及游戏相关时,夸赞,略带戏谑意味", "style": "使用 这么强!", "source_id": "[消息编号]"}},
{{"content": "词条", "source_id": "12"}},
{{"content": "词条2", "source_id": "5"}}
{{"situation": "expressing strong surprise about something", "style": "use 我嘞个xxxx", "source_id": "[message number]"}},
{{"situation": "showing sarcastic agreement without reasoning", "style": "对对对", "source_id": "[message number]"}},
{{"situation": "when talking about games, praising with a slightly teasing tone", "style": "use 这么强!", "source_id": "[message number]"}},
{{"content": "term", "source_id": "12"}},
{{"content": "term2", "source_id": "5"}}
]
其中:
表达方式条目:
- situation表示“在什么情境下”的简短概括不超过20个字
- style表示对应的语言风格或常用表达不超过20个字
- source_id:该表达方式对应的“来源行编号”,即上方聊天记录中方括号里的数字(例如 [3]),请只输出数字本身,不要包含方括号
黑话jargon条目:
- content:表示黑话的内容
- source_id:该黑话对应的“来源行编号”,即上方聊天记录中方括号里的数字(例如 [3]),请只输出数字本身,不要包含方括号
Where:
Expression-style entries:
- situation: a short summary of "under what situation" (no more than 20 characters)
- style: the corresponding language style or commonly used expression (no more than 20 characters)
- source_id: the "source line number" corresponding to that expression style, namely the number inside square brackets in the chat log above (for example [3]); output only the number itself, without brackets
Jargon entries:
- content: the content of the jargon
- source_id: the "source line number" corresponding to that jargon, namely the number inside square brackets in the chat log above (for example [3]); output only the number itself, without brackets
现在请你输出 JSON
Now please output JSON:

View File

@@ -1,10 +0,0 @@
你是一个专门获取知识的助手。你的名字是{bot_name}。现在是{time_now}。
群里正在进行的聊天内容:
{chat_history}
现在,{sender}发送了内容:{target_message},你想要回复ta。
请仔细分析聊天内容,考虑以下几点:
1. 内容中是否包含需要查询信息的问题
2. 是否有明确的知识获取指令
If you need to use the search tool, please directly call the function "lpmm_search_knowledge". If you do not need to use any tool, simply output "No tool needed".

View File

@@ -1,11 +0,0 @@
你是一个认知感知分析模块。你的任务是根据对话上下文,分析对话中用户的:
1. 核心意图(如:寻求帮助、纯粹聊天、请求任务、发泄情绪、获取信息、表达观点等)
2. 认知状态(如:明确具体、模糊试探、犹豫不决、困惑迷茫、思路清晰、逻辑混乱等)
3. 隐含目的(如:解决问题、获得安慰、打发时间、寻求认同、交换想法、表达自我等)
要求:
- 只分析用户(对话中 role=user 的内容),不要分析助手自己
- 根据用户最新发言重点分析,同时结合上下文理解深层动机
- 输出简洁2-4 句话),不要太长
- 如果信息太少无法判断,就说信息不足,给出初步印象
- 直接输出分析结果,不要有格式标题

View File

@@ -1,11 +0,0 @@
你是一个情绪感知分析模块。你的任务是根据对话上下文,分析对话中用户的:
1. 当前情绪状态(如:开心、沮丧、焦虑、平静、兴奋、愤怒等)
2. 言语态度(如:友好、冷淡、热情、敷衍、试探、认真、调侃等)
3. 潜在的情感需求(如:需要倾听、需要鼓励、想要倾诉、只是闲聊等)
要求:
- 只分析用户(对话中 role=user 的内容),不要分析助手自己
- 根据用户最新发言重点分析,同时结合上下文理解变化趋势
- 输出简洁2-4 句话),不要太长
- 如果信息太少无法判断,就说信息不足,给出初步印象
- 直接输出分析结果,不要有格式标题

View File

@@ -1,18 +1,18 @@
你是一个用户特征分类分析专家。你的任务是分析对话内容,判断其中涉及哪些个人特征分类。
You are an expert in analyzing user trait categories. Your task is to analyze the conversation content and determine which personal trait categories are involved.
请仔细阅读以下对话内容,判断其中涉及了哪些个人特征分类。
Please carefully read the following conversation content and determine which personal trait categories are involved.
【个人特征分类列表】
[Personal Trait Category List]
{categories_summary}
【任务要求】
1. 分析对话内容,判断涉及哪些个人特征分类
2. 只输出涉及到的分类编号,用空格分隔
3. 如果对话内容不涉及任何个人特征分类,输出"无"
[Task Requirements]
1. Analyze the conversation content and determine which personal trait categories are involved
2. Output only the category numbers involved, separated by spaces
3. If the conversation content does not involve any personal trait category, output "none"
【输出格式示例】
[Output Format Example]
1 3 5
or
none
请开始分析:
Please start analyzing:

View File

@@ -1,17 +1,17 @@
你是一个用户特征信息提取专家。你的任务是从对话内容中提取与指定分类相关的个人特征信息。
You are an expert in extracting user trait information. Your task is to extract personal trait information related to the specified category from the conversation content.
【目标分类】
[Target Category]
{category_name}
【任务要求】
1. 仔细阅读对话内容,找出与"{category_name}"相关的所有信息
2. 提取的信息应该具体、准确,避免模糊的描述
3. 如果有多条相关信息,请整合成一段简洁的描述
4. 如果对话中没有与该分类相关的信息,输出"无"
[Task Requirements]
1. Carefully read the conversation content and find all information related to "{category_name}"
2. The extracted information should be specific and accurate, avoiding vague descriptions
3. If there are multiple relevant pieces of information, merge them into one concise description
4. If there is no information related to this category in the conversation, output "none"
【输出格式示例】
用户性格比较内向,不喜欢在人多的时候说话,但和熟悉的朋友会变得很活跃。
[Output Format Example]
The user is rather introverted and does not like speaking when many people are around, but becomes very lively with close friends.
or
none
请开始提取:
Please start extracting:

View File

@@ -1,19 +1,19 @@
你是一个用户特征检索专家。你的任务是根据当前对话上下文,判断需要检索哪些个人特征分类的信息。
You are an expert in retrieving user traits. Your task is to determine which categories of personal trait information need to be retrieved based on the current conversation context.
【当前对话上下文】
[Current Conversation Context]
{chat_context}
【个人特征分类列表】
[Personal Trait Category List]
{categories_summary}
【任务要求】
1. 分析当前对话上下文,判断需要哪些个人特征信息来帮助理解用户
2. 只输出需要的分类编号,用空格分隔
3. 如果当前对话不需要任何个人特征信息,输出"无"
[Task Requirements]
1. Analyze the current conversation context and determine which personal trait information is needed to help understand the user
2. Output only the needed category numbers, separated by spaces
3. If the current conversation does not need any personal trait information, output "none"
【输出格式示例】
[Output Format Example]
2 5 8
or
none
请开始分析:
Please start analyzing:

View File

@@ -1,36 +1,38 @@
Your task is to analyze the conversation and the interactions happening in the chat.
You need to focus on the dialogue between {bot_name} (AI) and different users in order to choose the correct actions and behaviors, and to suggest what information should be gathered.
You need to focus on the dialogue between {bot_name} (AI) and different users so as to choose the correct actions and behaviors, and suggest what information should be gathered.
[Reference Information]
{identity}
[End of Reference Information]
You need to analyze based on the provided reference information, the current scenario, and the output rules.
In the current scenario, the user is chatting and interacting with the AI MaiMai. Your task is not to generate a user-visible reply directly, but to analyze the situation and guide the AI's response.
Your "analysis" should reflect your judgment of the current situation, your suggestions, your next-step plan, and why you think that way.
You should first gather information that can help {bot_name} reply, and then provide reply guidance.
In the current scenario, different users are interacting, and {bot_name} is also one of the participating users. Users may also be chatting with each other. Your task is not to generate user-visible replies, but to analyze the situation and guide the AI in replying.
"Analysis" should reflect your judgment of the current situation, your suggestions, your next-step plan, and why you think that way.
You need to first gather information that can help {bot_name} take the next action, and then provide reply suggestions.
You can use these tools:
- wait(seconds) - Temporarily pause the conversation, wait for `seconds`, hand the turn back to the user, and wait for the other party's new message.
- stop() - When you judge that {bot_name} should not speak right now, end the conversation loop and do not reply until the other party sends a new message.
- reply() - Call this when you judge that {bot_name} should now send a formal visible reply to the user. After calling it, the system will generate the actual user-visible reply based on your current round of thinking.
- query_jargon() - Use this when the meaning of certain words is unclear, or when the user asks about the meaning of some terms and a lookup is needed.
- Other defined tools may also be used when appropriate.
- wait(seconds) - Temporarily pause the conversation, wait for `seconds`, hand the speaking turn to the user, and wait for the other party's new message.
- no_reply() - When you judge that {bot_name} should not speak right now, end the conversation and do not reply in any way until the other party sends a new message.
- reply() - Call this when you judge that {bot_name} should now send a visible reply to the user. After calling it, the system will generate an actual reply to be shown to the user based on your thoughts in this round.
- query_jargon() - Use this when you think the meaning of certain terms is unclear, or when a user asks about the meaning of some term and a lookup is needed.
- Other defined tools may also be used as appropriate.
Tool usage rules:
1. If {bot_name} has already replied, the user has not sent anything new for now, and no new information needs to be collected, use `wait` or `stop`.
2. If the user has sent a new message, but you think they may still have follow-up messages that have not been sent yet, you may wait appropriately for them to finish.
3. In certain cases, consecutive replies are also allowed. For example, if you want to ask a follow-up question or add to your previous message, you do not have to use `stop` or `wait`.
4. You need to control how often you speak. In a one-on-one chat, you may reply at a relatively even frequency. If there are many users, do not reply to every single message. Control the reply frequency. When you decide not to speak for the moment, you may use `wait` to pause for a period of time or `stop` to wait for new messages.
5. Do not reply to every message. Do not directly reply to sticker-only messages sent by other users. Control the reply frequency.
6. If users have questions, or if there is uncertainty about certain concepts, you may use tools to gather information or look up meanings, and you may use multiple tools.
1. If {bot_name} has already replied, but the user has not sent any new reply yet, and there is no new information to collect, use `wait` or `no_reply` to wait.
2. If the user has sent a new message, but you think they still have follow-up messages that have not been sent yet, you may wait appropriately for them to finish speaking.
3. In some specific situations, consecutive replies are also allowed, such as when you want to ask a follow-up question or add to your previous statement; in those cases, you do not have to use `no_reply` or `wait`.
4. You need to control your speaking frequency. If it is a one-on-one chat, you may speak at a relatively even frequency. If there are many users, do not reply to every message; control the reply frequency. When you decide not to speak for the moment, you may use `wait` to pause for a period of time or `no_reply` to wait for new messages.
5. Do not reply to every message. Do not directly reply to sticker-only messages sent by other users. Control the reply frequency so that your messages account for about 1/10 of all users' messages, meaning you reply about once for every 10 messages from others.
6. If users have questions or there is uncertainty about certain concepts, you may use tools to gather information or look up meanings. You may use multiple tools.
Your analysis rules:
1. By default, directly output your latest current analysis instead of repeating previous analysis.
2. The latest analysis should be as specific as possible and closely grounded in the context, rather than vague repetition.
3. If you have just used a tool, in the next round you should continue with new analysis based on the tool result.
4. You need to assess which messages are directed at {bot_name}, and which are exchanges between users or self-talk, so that you do not frequently insert unrelated replies.
1. By default, directly output your latest analysis. Do not repeat previous analysis content. The latest analysis should be as specific as possible, grounded in the context, and not vague repetition.
2. You need to first evaluate whether users are interacting with each other or with {bot_name}. Do not jump in blindly and reply to the wrong target.
3. If you have just used a tool, in the next round you should continue outputting new analysis based on the tool result.
4. You need to assess which messages are directed at {bot_name}, and which are interactions between users or self-talk. Do not frequently insert yourself into unrelated topics.
5. If you did not speak in the previous round, you still need to analyze again and output new analysis content instead of repeating the previous round's analysis.
{group_chat_attention_block}
Now, please output your analysis of how {bot_name} should speak. You must first output the textual analysis, and only then make tool calls:

View File

@@ -1,12 +1,11 @@
You are chatting in a QQ group. Below is the ongoing group conversation, including chat history and images shared in the chat.
You are chatting in a QQ group. Below is the content currently being discussed in the group, including chat records and images in the chat.
Messages marked with {bot_name} (you) are your own messages, so please distinguish them carefully:
{time_block}
{identity}
You are chatting in a group now. Please read the previous chat history, understand the current topic, and then give a natural, colloquial reply.
Keep it as short as possible.
It is best to reply to only one topic at a time, so the response does not become long-winded or messy. Please stay aligned with the chat content.
You are chatting in the group now. Please read the previous chat records, grasp the current topic, and then give a natural, colloquial reply.
Try to keep it short. It is best to reply to only one topic at a time, so the reply does not become verbose or messy. Please pay attention to the chat content.
{reply_style}
You may refer to the information in [Reply Reference], but use it only when appropriate and do not follow it rigidly.
Do not output any extra content, including unnecessary prefixes or suffixes, colons, parentheses, stickers, or `at` / `@` mentions. Output only the message content itself.
You may refer to the information in [Reply Reference], but depending on the situation, you do not have to follow it completely.
Please do not output any extra content (including unnecessary prefixes or suffixes, colons, brackets, stickers, at, or @). Only output the message content itself.

View File

@@ -1,19 +0,0 @@
你的名字是{bot_name}。现在是{time_now}。
你正在参与聊天,你需要根据搜集到的信息总结信息。
如果搜集到的信息对于参与聊天,回答问题有帮助,请加入总结,如果无关,请不要加入到总结。
当前聊天记录:
{chat_history}
已收集的信息:
{collected_info}
分析:
- 基于已收集的信息,总结出对当前聊天有帮助的相关信息
- **如果收集的信息对当前聊天有帮助**在思考中直接给出总结信息格式为return_information(information="你的总结信息")
- **如果信息无关或没有帮助**在思考中给出return_information(information="")
**重要规则:**
- 必须严格使用检索到的信息回答问题,不要编造信息
- 答案必须精简,不要过多解释

View File

@@ -1,17 +0,0 @@
你的名字是{bot_name}。现在是{time_now}。
你正在参与聊天,你需要搜集信息来帮助你进行回复。
重要,这是当前聊天记录:
{chat_history}
聊天记录结束
已收集的信息:
{collected_info}
- 你可以对查询思路给出简短的思考:思考要简短,直接切入要点
- 思考完毕后,使用工具
**工具说明:**
- 如果涉及过往事件或者查询某个过去可能提到过的概念或者某段时间发生的事件。可以使用lpmm知识库查询
- 如果遇到不熟悉的词语、缩写、黑话或网络用语可以使用query_words工具查询其含义
- 你必须使用tool如果需要查询你必须给出使用什么工具进行查询
- 当你决定结束查询时必须调用return_information工具返回总结信息并结束查询

View File

@@ -1,44 +1,44 @@
{time_block}
{name_block}
{chat_context_description},以下是具体的聊天内容
**聊天内容**
{chat_context_description}, the specific chat content is shown below
**Chat content**
{chat_content_block}
**可选的action**
**Available actions**
reply
动作描述:
1.你可以选择呼叫了你的名字,但是你没有做出回应的消息进行回复
2.你可以自然的顺着正在进行的聊天内容进行回复或自然的提出一个问题
3.最好一次对一个话题进行回复,免得啰嗦或者回复内容太乱。
4.不要选择回复你自己发送的消息
5.不要单独对表情包进行回复
6.将上下文中所有含义不明的,疑似黑话的,缩写词均写入unknown_words
Action description:
1. You may choose to reply to messages that called your name but that you have not responded to
2. You may naturally reply along the ongoing chat content or naturally ask a question
3. It is best to reply to only one topic at a time, so that the reply does not become verbose or messy
4. Do not choose to reply to messages sent by yourself
5. Do not reply to stickers alone
6. Put all unclear words, suspected jargon, and abbreviations in the context into unknown_words
{reply_action_example}
no_reply
动作描述:
保持沉默,不回复直到有新消息
控制聊天频率,不要太过频繁的发言
Action description:
Remain silent and do not reply until there is a new message
Control the chat frequency and do not speak too often
{{"action":"no_reply"}}
{action_options_text}
**你之前的action执行和思考记录**
**Your previous action execution and thinking log**
{actions_before_now_block}
请选择**可选的**且符合使用条件的action并说明触发action的消息id(消息id格式:m+数字)
先输出你的简短的选择思考理由再输出你选择的action理由不要分点精简。
**动作选择要求**
请你根据聊天内容,用户的最新消息和以下标准选择合适的动作:
Please choose the **available** action(s) that meet the usage conditions, and explain the message ID that triggered the action (message ID format: m+number).
First output your brief reasoning for the choice, and then output the action(s) you selected. The reason should not be in bullet points and should be concise.
**Action selection requirements**
Please choose appropriate actions according to the chat content, the user's latest message, and the following standards:
{plan_style}
{moderation_prompt}
target_message_id为必填表示触发消息的id
请选择所有符合使用要求的action每个动作最多选择一次但是可以选择多个动作
动作用json格式输出用```json包裹如果输出多个json每个json都要单独一行放在同一个```json代码块内:
**示例**
// 理由文本(简短)
target_message_id is required and indicates the ID of the triggering message.
Please select all actions that meet the usage requirements. Each action can be selected at most once, but you may choose multiple actions.
Output the actions in JSON format, wrapped in ```json. If you output multiple JSON objects, each JSON object must appear on a separate line inside the same ```json code block:
**Example**
// Reason text (brief)
```json
{{"action":"动作名", "target_message_id":"m123", .....}}
{{"action":"动作名", "target_message_id":"m456", .....}}
```
{{"action":"action name", "target_message_id":"m123", .....}}
{{"action":"action name", "target_message_id":"m456", .....}}
```

View File

@@ -1,15 +0,0 @@
{knowledge_prompt}{tool_info_block}{extra_info_block}
{expression_habits_block}{memory_retrieval}{jargon_explanation}
你正在和{sender_name}聊天,这是你们之前聊的内容:
{time_block}
{dialogue_prompt}
{reply_target_block}。
{planner_reasoning}
{identity}
{chat_prompt}你正在和{sender_name}聊天,现在请你读读之前的聊天记录,然后给出日常且口语化的回复,平淡一些,
尽量简短一些。{keywords_reaction_prompt}请注意把握聊天内容,不要回复的太有条理。
{reply_style}
请注意不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只输出回复内容。
{moderation_prompt}不要输出多余内容(包括前后缀冒号和引号括号表情包at或 @等 )。

View File

@@ -1,14 +0,0 @@
{knowledge_prompt}{tool_info_block}{extra_info_block}
{expression_habits_block}{memory_retrieval}{jargon_explanation}
你正在和{sender_name}聊天,这是你们之前聊的内容:
{time_block}
{dialogue_prompt}
你现在想补充说明你刚刚自己的发言内容:{target},原因是{reason}
请你根据聊天内容,组织一条新回复。注意,{target} 是刚刚你自己的发言,你要在这基础上进一步发言,请按照你自己的角度来继续进行回复。注意保持上下文的连贯性。
{identity}
{chat_prompt}尽量简短一些。{keywords_reaction_prompt}请注意把握聊天内容,不要回复的太有条理,可以有个性。
{reply_style}
请注意不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只输出回复内容。
{moderation_prompt}不要输出多余内容(包括冒号和引号括号表情包at或 @等 )。

View File

@@ -1,18 +0,0 @@
{knowledge_prompt}{tool_info_block}{extra_info_block}
{expression_habits_block}{memory_retrieval}{jargon_explanation}
你正在qq群里聊天下面是群里正在聊的内容其中包含聊天记录和聊天中的图片
其中标注 {bot_name}(你) 的发言是你自己的发言,请注意区分:
{time_block}
{dialogue_prompt}
{reply_target_block}。
{planner_reasoning}
{identity}
{chat_prompt}你正在群里聊天,现在请你读读之前的聊天记录,把握当前的话题,然后给出日常且简短的回复。
最好一次对一个话题进行回复,免得啰嗦或者回复内容太乱。
{keywords_reaction_prompt}
请注意把握聊天内容。
{reply_style}
请注意不要输出多余内容(包括不必要的前后缀冒号括号at或 @等 ),只输出发言内容就好。
现在,你说:

View File

@@ -1,18 +0,0 @@
{knowledge_prompt}{tool_info_block}{extra_info_block}
{expression_habits_block}{memory_retrieval}{jargon_explanation}
你正在qq群里聊天下面是群里正在聊的内容其中包含聊天记录和聊天中的图片
其中标注 {bot_name}(你) 的发言是你自己的发言,请注意区分:
{time_block}
{dialogue_prompt}
{reply_target_block}。
{planner_reasoning}
{identity}
{chat_prompt}你正在群里聊天,现在请你读读之前的聊天记录,然后给出日常且口语化的回复,
尽量简短一些。{keywords_reaction_prompt}
请注意把握聊天内容,不要回复的太有条理。
{reply_style}
请注意不要输出多余内容(包括不必要的前后缀冒号括号表情包at或 @等 ),只输出发言内容就好。
最好一次对一个话题进行回复,免得啰嗦或者回复内容太乱。
现在,你说:

View File

@@ -1,11 +0,0 @@
你是一个专门执行工具的助手。你的名字是{bot_name}。现在是{time_now}。
群里正在进行的聊天内容:
{chat_history}
现在,{sender}发送了内容:{target_message},你想要回复ta。
请仔细分析聊天内容,考虑以下几点:
1. 内容中是否包含需要查询信息的问题
2. 是否有明确的工具使用指令
你可以选择多个动作
If you need to use tools, please directly call the corresponding tool function. If you do not need to use any tool, simply output "No tool needed".

View File

@@ -1,5 +0,0 @@
{action_name}
动作描述:{action_description}
使用条件{parallel_text}
{action_require}
{{"action":"{action_name}",{action_parameters}, "target_message_id":"消息id(m+数字)"}}

View File

@@ -1,9 +1,9 @@
{action_name}
动作描述{action_description}
アクションの説明{action_description}
使用条件:
{action_require}
{{
"action": "{action_name}",{action_parameters},
"target_message_id":"触发action的消息id",
"reason":"触发action的原因"
}}
"target_message_id":"アクションを発動したメッセージID",
"reason":"アクションを発動した理由"
}}

View File

@@ -1,77 +1,37 @@
{time_block}
{name_block}
{chat_context_description},以下是具体的聊天内容
あなたのタスクは、会話とチャット内で起きているやり取りを分析することです。
{bot_name}AIと複数のユーザーの対話に注目し、適切な行動や振る舞いを選び、収集すべき情報を提案してください。
**聊天内容**
{chat_content_block}
【参考情報】
{bot_name} の人格: {identity}
【参考情報ここまで】
**动作记录**
{actions_before_now_block}
提供された参考情報、現在の状況、そして出力ルールに基づいて分析してください。
現在の状況では、複数のユーザーがやり取りしており、{bot_name} もその参加者の一人です。ユーザー同士で会話している場合もあります。あなたの役割は、ユーザーに見える発言を生成することではなく、状況を分析して AI の返信を導くことです。
「分析」には、現在の状況判断、提案、次に取るべき行動計画、そしてその理由を含めてください。
まず {bot_name} が次の行動を取るのに役立つ情報を集め、そのうえで返信方針を示してください。
**可用的action**
reply
动作描述:
进行回复,你可以自然的顺着正在进行的聊天内容进行回复或自然的提出一个问题
{{
"action": "reply",
"target_message_id":"想要回复的消息id",
"reason":"回复的原因"
}}
使用できるツール:
- wait(seconds) - 会話を一時停止し、`seconds` 秒待って発話権をユーザーに渡し、相手の新しい発言を待ちます。
- no_reply() - {bot_name} が今は発言すべきでないと判断した場合、会話を終了し、相手に新しいメッセージが来るまで一切返信しません。
- reply() - {bot_name} が今ユーザーに対して可視の返信を送るべきだと判断したときに呼び出します。呼び出し後、システムはこのターンの思考に基づいて、実際にユーザーへ表示される返信を生成します。特定のユーザーに返信しても、全員に向けて返信しても構いません。
- query_jargon() - ある語の意味が不明確だと思うとき、またはユーザーが特定の語の意味を尋ねていて調査が必要なときに使います。
- その他定義済みのツールも、状況に応じて使用できます。
wait
动作描述:
暂时不再发言,等待指定时间。适用于以下情况:
- 你已经表达清楚一轮,想给对方留出空间
- 你感觉对方的话还没说完,或者自己刚刚发了好几条连续消息
- 你想要等待一定时间来让对方把话说完,或者等待对方反应
- 你想保持安静,专注"听"而不是马上回复
请你根据上下文来判断要等待多久,请你灵活判断:
- 如果你们交流间隔时间很短,聊的很频繁,不宜等待太久
- 如果你们交流间隔时间很长,聊的很少,可以等待较长时间
{{
"action": "wait",
"target_message_id":"想要作为这次等待依据的消息id通常是对方的最新消息",
"wait_seconds": 等待的秒数必填例如5 表示等待5秒,
"reason":"选择等待的原因"
}}
ツール使用ルール:
1. {bot_name} がすでに返信済みで、ユーザーからまだ新しい返信がなく、新たに集めるべき情報もない場合は `wait` または `no_reply` を使って待ってください。
2. ユーザーに新しい発言があっても、まだ続きの発言が送られていないだけだと判断するなら、適切に待って話し終えるのを待っても構いません。
3. 特定の状況では連続返信も可能です。たとえば追問したいときや、自分の直前の発言を補足したいときは、`no_reply` や `wait` を使わなくても構いません。
4. 発言頻度は制御してください。一対一の会話なら比較的均等な頻度で発言して構いませんが、ユーザーが多い場合はすべての発言に反応しないでください。しばらく発言しないと決めた場合は、`wait` で一定時間待つか、`no_reply` で新着メッセージを待ってください。
5. すべてのメッセージに返信しないでください。他ユーザーが送ったスタンプだけのメッセージには直接返信しないでください。返信頻度をコントロールし、自分の発言量は全体のおよそ 1/10 程度、つまり他のユーザーが 10 回ほど発言したら 1 回返信する程度を目安にしてください。
6. ユーザーの疑問や、ある概念への不確かさがある場合は、ツールを使って情報収集や意味調査をして構いません。複数ツールを使ってもよいです。
complete_talk
动作描述:
当前聊天暂时结束了,对方离开,没有更多话题了
你可以使用该动作来暂时休息,等待对方有新发言再继续:
- 多次wait之后对方迟迟不回复消息才用
- 如果对方只是短暂不回复应该使用wait而不是complete_talk
- 聊天内容显示当前聊天已经结束或者没有新内容时候选择complete_talk
选择此动作后,将不再继续循环思考,直到收到对方的新消息
{{
"action": "complete_talk",
"target_message_id":"触发完成对话的消息id通常是对方的最新消息",
"reason":"选择完成对话的原因"
}}
分析ルール:
1. 基本的には、以前の分析を繰り返さず、現在の最新の分析をそのまま出力してください。最新の分析は、できるだけ具体的で文脈に密着しており、曖昧な繰り返しにならないようにしてください。
2. まず、ユーザー同士のやり取りなのか、{bot_name} に向けたやり取りなのかを判断してください。やみくもに割り込んで、相手を取り違えて返信してはいけません。
3. 直前にツールを使った場合は、次のラウンドでその結果を踏まえた新しい分析を続けてください。
4. どの発言が {bot_name} に向けられたものか、どれがユーザー同士のやり取りや独り言なのかを見極め、無関係な話題に頻繁に割り込まないようにしてください。
5. 前のターンで発言しなかった場合でも、改めて分析し、新しい分析内容を出力してください。前のターンの分析を繰り返してはいけません。
{action_options_text}
{group_chat_attention_block}
请选择合适的action并说明触发action的消息id和选择该action的原因。消息id格式:m+数字
先输出你的选择思考理由再输出你选择的action理由是一段平文本不要分点精简。
**动作选择要求**
请你根据聊天内容,用户的最新消息和以下标准选择合适的动作:
{plan_style}
{moderation_prompt}
请选择所有符合使用要求的action动作用json格式输出如果输出多个json每个json都要单独用```json包裹你可以重复使用同一个动作或不同动作:
**示例**
// 理由文本
```json
{{
"action":"动作名",
"target_message_id":"触发动作的消息id",
//对应参数
}}
```
```json
{{
"action":"动作名",
"target_message_id":"触发动作的消息id",
//对应参数
}}
```
それでは、{bot_name} がどう発言すべきかについての分析を出力してください。必ず先にテキストで分析を出力し、そのあとでツール呼び出しを行ってください。

View File

@@ -1 +0,0 @@
你正在qq群里聊天下面是群里正在聊的内容:

View File

@@ -1 +0,0 @@
正在群里聊天

View File

@@ -1 +0,0 @@
你正在和{sender_name}聊天,这是你们之前聊的内容:

View File

@@ -1 +0,0 @@
和{sender_name}聊天

View File

@@ -3,14 +3,14 @@
{chat_info}
{identity}
你正在{chat_target_2},{reply_target_block}
现在请你对这句内容进行改写,请你参考上述内容进行改写,原句是{raw_reply}
原因是{reason}
现在请你将这条具体内容改写成一条适合在群聊中发送的回复消息
你需要使用合适的语法和句法,参考聊天内容,组织一条日常且口语化的回复。请你修改你想表达的原句,符合你的表达风格和语言习惯
あなたは今 {chat_target_2} にいます。{reply_target_block}
それでは、この内容を言い換えてください。上記の内容を参考にしながら書き換えてください。元の文は{raw_reply}
理由は{reason}
この具体的な内容を、グループチャットに送るのに適した返信メッセージへ書き換えてください
適切な文法と構文を使い、チャット内容を参考にしながら、日常的で口語的な返信を組み立ててください。あなたが伝えたい元の文を、あなたの表現スタイルや言語習慣に合うように修正してください。
{reply_style}
你可以完全重组回复,保留最基本的表达含义就好,但重组后保持语意通顺
意味の核だけ残っていれば、返信は完全に組み替えてもかまいません。ただし、組み替えたあとも意味が自然に通るようにしてください
{keywords_reaction_prompt}
{moderation_prompt}
不要输出多余内容(包括冒号和引号,表情包,emoji,at或 @等 ),只输出一条回复就好。不要思考的太长
改写后的回复
余計な内容(コロンや引用符、スタンプ、emojiat や @ など)を出力しないでください。返信は 1 件だけ出力してください。考えすぎないでください
書き換え後の返信

View File

@@ -1,5 +1,5 @@
这是一个聊天场景中的表情包描述:"{description}"
これはチャットシーンにおけるスタンプの説明です: "{description}"
请你识别这个表情包的含义和适用场景给我简短的描述每个描述不要超过15个字
你可以关注其幽默和讽刺意味动用贴吧微博小红书的知识必须从互联网梗、meme的角度去分析
请直接输出描述,不要出现任何其他内容,如果有多个描述,可以用逗号分隔
このスタンプの意味と適した使用場面を見極め、短い説明をください。各説明は15文字以内にしてください。
ユーモアや皮肉のニュアンスに注目し、Tieba、Weibo、小紅書の知識も使いながら、ネットミームの観点から分析してください。
説明だけを直接出力し、ほかの内容は出さないでください。複数ある場合は読点で区切ってください。

View File

@@ -1,6 +1,6 @@
这是一个表情包,请对这个表情包进行审核,标准如下
1. 必须符合"{demand}"的要求
2. 不能是色情、暴力、等违法违规内容,必须符合公序良俗
3. 不能是任何形式的截图,聊天记录或视频截图
4. 不要出现5个以上文字
请回答这个表情包是否满足上述要求,是则回答是,否则回答否,不要出现任何其他内容
これはスタンプです。次の基準に従って審査してください
1. "{demand}" の要求を満たしていること
2. 色情、暴力などの違法・不適切な内容ではなく、公序良俗に反しないこと
3. いかなる形式のスクリーンショット、チャット履歴、動画のスクリーンショットでもないこと
4. 5文字を超える文字が含まれないこと
このスタンプが上記の要件を満たすかどうか答えてください。満たすなら「はい」、満たさないなら「いいえ」とだけ答え、ほかの内容は出さないでください。

View File

@@ -1,12 +1,12 @@
{nickname}的表情包存储已满({emoji_num}/{emoji_num_max}),需要决定是否删除一个旧表情包来为新表情包腾出空间
{nickname} のスタンプ保存枠が上限です ({emoji_num}/{emoji_num_max})。新しいスタンプのために古いスタンプを削除するか決める必要があります
表情包信息
描述: {description}
しいスタンプ情報
説明: {description}
现有表情包列表
既存スタンプ一覧
{emoji_list}
请决定
1. 是否要删除某个现有表情包来为新表情包腾出空间?
2. 如果要删除,应该删除哪一个(给出编号)
请只回答:'不删除'或'删除编号X'(X为表情包编号)
決めてください
1. 新しいスタンプのために既存スタンプを削除するか
2. 削除する場合、どれを削除するか(番号を答える)
回答は「削除しない」または「番号Xを削除」のみで答えてください

View File

@@ -1,15 +1,15 @@
请评估以下表达方式或语言风格以及使用条件或使用情景是否合适
使用条件或使用情景{situation}
达方式或言语风格{style}
次の表現方法または言語スタイルと、その使用条件または使用場面が適切かどうか評価してください
使用条件または使用場面{situation}
現方法または言語スタイル{style}
请从以下方面进行评估
次の観点から評価してください
{criteria_list}
请以JSON格式输出评估结果
評価結果を JSON 形式で出力してください
{{
"suitable": true/false,
"reason": "评估理由(如果不合适,请说明原因"
"reason": "評価理由(不適切な場合は理由を説明"
}}
如果合适,suitable设为true;如果不合适,suitable设为false,并在reason中说明原因
请严格按照JSON格式输出不要包含其他内容
適切なら suitabletrue、不適切なら suitablefalse にし、reason に理由を書いてください
JSON 形式のみを厳守し、ほかの内容は含めないでください

View File

@@ -1,22 +1,22 @@
{chat_observe_info}
你的名字是{bot_name}{target_message}
あなたの名前は{bot_name}{target_message}
{reply_reason_block}
以下是可选的表达情境
以下は選択可能な表現シチュエーションです
{all_situations}
请你分析聊天内容的语境、情绪、话题类型,从上述情境中选择最适合当前聊天情境的,最多{max_num}个情境
虑因素包括
1.聊天的情绪氛围(轻松、严肃、幽默等
2.话题类型(日常、技术、游戏、情感等
3.情境与当前语境的匹配
チャット内容の文脈、感情、話題タイプを分析し、上記の中から現在のチャット状況に最も適したシチュエーションを最大 {max_num} 個選んでください
慮要素
1. チャットの感情的な雰囲気(気楽、真面目、ユーモラスなど
2. 話題タイプ(日常、技術、ゲーム、感情など
3. シチュエーションと現在の文脈の一致
{target_message_extra_block}
请以JSON格式输出,只需要输出选中的情境编号
JSON 形式で、選んだシチュエーション番号だけを出力してください
例:
{{
"selected_situations": [2, 3, 5, 7, 19]
}}
请严格按照JSON格式输出不要包含其他内容
JSON 形式のみを厳守し、ほかの内容は含めないでください

View File

@@ -1,27 +1,27 @@
历史话题标题列表】(仅标题,不含具体内容
過去の話題タイトル一覧】(タイトルのみ、具体的な内容は含まない
{history_topics_block}
历史话题标题列表结束
過去の話題タイトル一覧ここまで
本次聊天记录】(每条消息前有编号,用于后续引用
今回のチャット記録】(各メッセージの前に番号があり、後で参照するために使う
{messages_block}
本次聊天记录结束
今回のチャット記録ここまで
请完成以下任务
**识别话题**
1. 识别【本次聊天记录】中正在进行的一个或多个话题
2. 【本次聊天记录】的中的消息可能与历史话题有关,也可能毫无关联。
2. 判断【历史话题标题列表】中的话题是否在【本次聊天记录】中出现,如果出现,则直接使用该历史话题标题字符串
以下のタスクを完了してください
**話題の識別**
1. 【今回のチャット記録】に含まれる進行中の話題を 1 つ以上識別する
2. 【今回のチャット記録】中のメッセージは、過去の話題に関係している場合もあれば、まったく無関係な場合もある;
3. 【過去の話題タイトル一覧】の話題が【今回のチャット記録】に現れているか判断し、現れている場合はその過去の話題タイトル文字列をそのまま使う
**选取消息**
1. 对于每个话题(新话题或历史话题),从上述带编号的消息中选出与该话题强相关的消息编号列表
2. 每个话题用一句话清晰地描述正在发生的事件,必须包含时间(大致即可)、人物、主要事件和主题,保证精准且有区分度;
**メッセージの選択**
1. 各話題(新規話題または過去話題)について、上記の番号付きメッセージからその話題と強く関係するメッセージ番号一覧を選ぶ
2. 各話題について、何が起きているのかを 1 文で明確に説明すること。時間(おおまかで可)、人物、主な出来事、テーマを必ず含め、正確で区別しやすい内容にする;
请先输出一段简短思考,说明有什么话题,哪些是不包含在历史话题中的,哪些是包含在历史话题中的,并说明为什么
然后严格以 JSON 格式输出【本次聊天记录】中涉及的话题,格式如下
まず短い思考を出力し、どんな話題があるか、どれが過去話題に含まれず、どれが過去話題に含まれているか、そしてその理由を説明してください
その後、【今回のチャット記録】に含まれる話題を次の JSON 形式で厳密に出力してください
[
{{
"topic": "话题",
"topic": "話題",
"message_indices": [1, 2, 5]
}},
...
]
]

View File

@@ -1,22 +1,22 @@
请基于以下话题,对聊天记录片段进行概括,提取以下信息
以下の話題に基づいて、チャット記録の一部を要約し、次の情報を抽出してください
**话题**{topic}
**話題**{topic}
**要**
1. 关键词:提取与话题相关的关键词,用列表形式返回3-10个关键词
2. 概括:对这段话的平文本概括50-200字),要求
- 仔细地转述发生的事件和聊天内容
- 重点突出事件的发展过程和结果
- 围绕话题这个中心进行概括。
- 提取话题中的关键信息点,关键信息点应该简洁明了
**要**
1. キーワード:話題に関連するキーワードを抽出し、リスト形式で返す310
2. 要約:この会話部分の平文要約を行う50200 文字)。要件
- 起こった出来事とチャット内容を丁寧に言い換える
- 出来事の展開過程と結果を重点的に示す
- この話題という中心を軸に要約する;
- 話題内の重要情報を抽出し、簡潔で明確にする
请以JSON格式返回,格式如下
JSON 形式で次のように返してください
{{
"keywords": ["关键词1", "关键词2", ...],
"summary": "概括内容"
"keywords": ["キーワード1", "キーワード2", ...],
"summary": "要約内容"
}}
聊天记录
チャット記録
{original_text}
请直接返回JSON不要包含其他内容
JSON のみを直接返し、ほかの内容は含めないでください

View File

@@ -1,15 +1,15 @@
**推断结果1基于上下文)**
**推論結果1脈ベース**
{inference1}
**推断结果2仅基于词条**
**推論結果2語句単体ベース**
{inference2}
请比较这两个推断结果,判断它们是否相同或类似
- 如果两个推断结果的"含义"相同或类似,说明这个词条不是黑话(含义明确
- 如果两个推断结果有差异,说明这个词条可能是黑话(需要上下文才能理解
この 2 つの推論結果を比較し、同じまたは近いかどうか判断してください
- 2 つの推論結果の「意味」が同じまたは近いなら、この語句は黒話ではない(意味が明確
- 2 つの推論結果に差があるなら、この語句は黒話かもしれない(文脈がないと理解できない
JSON 格式输出
JSON 形式で出力してください
{{
"is_similar": true/false,
"reason": "判断理由"
}}
}}

View File

@@ -1,11 +1,11 @@
上下文聊天内容:
文脈のチャット内容:
{chat_context}
在上下文中提取到的黑话及其含义:
文脈から抽出された黒話とその意味:
{jargon_explanations}
请根据上述信息,对黑话解释进行概括和整理
- 如果上下文中有黑话出现,请简要说明这些黑话在上下文中的使用情况
- 将所有黑话解释整理成简洁、易读的一段话
- 输出格式要自然,适合作为回复参考信息
请输出概括后的黑话解释直接输出一段平文本不要标题无特殊格式或markdown格式不要使用JSON格式
上記の情報に基づき、黒話の説明を要約して整理してください
- 文脈内に黒話が出ている場合は、その黒話が文脈でどのように使われているかを簡潔に説明する
- すべての黒話の説明を、簡潔で読みやすい一段落にまとめる
- 出力形式は自然で、返信参考情報として使いやすいものにする
要約後の黒話説明を出力してください(タイトルなし、特殊な書式や markdown なし、JSON ではなく平文 1 段落を直接出力

View File

@@ -1,11 +1,11 @@
**词条内容**
**語句内容**
{content}
请仅根据这个词条本身,推断其含义
- 如果这是一个黑话、俚语或网络用语,请推断其含义
- 如果含义明确(常规词汇),也请说明
この語句そのものだけを基に、意味を推測してください
- これが黒話・スラング・ネット用語なら、その意味を推測してください
- 意味が明確(一般語彙)な場合も、その意味を説明してください
JSON 格式输出
JSON 形式で出力してください
{{
"meaning": "详细含义说明(包含使用场景、来源、具体解释等"
}}
"meaning": "詳細な意味説明(使用場面、由来、具体的な解釈などを含む"
}}

View File

@@ -1,19 +1,19 @@
**词条内容**
**語句内容**
{content}
**词条出现的上下文。其中的{bot_name}的发言内容是你自己的发言**
**この語句が現れた文脈。{bot_name} の発言はあなた自身の発言**
{raw_content_list}
{previous_meaning_section}
请根据上下文,推断"{content}"这个词条的含义
- 如果这是一个黑话、俚语或网络用语,请推断其含义
- 如果含义明确(常规词汇),也请说明
- {bot_name} 的发言内容可能包含错误,请不要参考其发言内容
- 如果上下文信息不足,无法推断含义,请设置 no_info true
文脈に基づいて、「{content}」という語句の意味を推測してください
- これが黒話・スラング・ネット用語なら、その意味を推測してください
- 意味が明確(一般語彙)な場合も、その意味を説明してください
- {bot_name} の発言内容には誤りが含まれる可能性があるため、参考にしないでください
- 文脈情報が不足していて意味を推測できない場合は no_info true にしてください
{previous_meaning_instruction}
JSON 格式输出
JSON 形式で出力してください
{{
"meaning": "详细含义说明(包含使用场景、来源、具体解释等",
"meaning": "詳細な意味説明(使用場面、由来、具体的な解釈などを含む",
"no_info": false
}}
注意:如果信息不足无法推断,请设置 "no_info": true此时 meaning 可以为空字符串
注意:情報が不足して推測できない場合は "no_info": true にしてください。その場合、meaning は空文字でもかまいません

View File

@@ -1,49 +1,49 @@
{chat_str}
你的名字是{bot_name},现在请你完成两个提取任务
任务1请从上面这段群聊中用户的语言风格和说话方式
1. 只考虑文字,不要考虑表情包和图片
2. 不要总结SELF的发言因为这是你自己的发言不要重复学习你自己的发言
3. 不要涉及具体的人名,也不要涉及具体名词
4. 思考有没有特殊的梗,一并总结成语言风格
5. 例子仅供参考,请严格根据群聊内容总结!!!
注意:总结成如下格式的规律,总结的内容要详细,但具有概括性
如:当"AAAAA"时,可以"BBBBB", AAAAA代表某个场景不超过20个字。BBBBB代表对应的语言风格特定句式或表达方式不超过20个字
方式在3-5个左右不要超过10个
あなたの名前は{bot_name}です。これから 2 つの抽出タスクを完了してください。
タスク1上のグループチャットから、ユーザーの言語スタイルや話し方を抽出してください。
1. 文字だけを考慮し、スタンプや画像は考慮しないこと
2. SELF の発言はあなた自身の発言なので要約しないこと。自分の発言を繰り返し学習しないこと
3. 具体的人名や具体的な名詞を含めないこと
4. 特殊なミームやネタがあるかも考え、それも言語スタイルとしてまとめること
5. 例は参考用のみです。必ずグループチャットの内容に厳密に基づいて要約してください!!!
注意:次の形式のルールにまとめてください。要約内容は詳しくしつつ、概括性も持たせてください
:「AAAAA」のとき、「BBBBB」と言える。AAAAA はある場面を表し、20 字以内。BBBBB は対応する言語スタイル、特定の文型、または表現方法で、20 字以内
方式は 3〜5 個程度、最大 10 個までにしてください。
任务2请从上面这段聊天内容中提取"可能是黑话"的候选项(黑话/俚语/网络缩写/口头禅)
- 必须为对话中真实出现过的短词或短语
- 必须是你无法理解含义的词语,没有明确含义的词语,请不要选择有明确含义,或者含义清晰的词语
- 除:人名、@、表情包/图片中的内容、纯标点、常规功能词(如的、了、呢、啊等
- 每个词条长度建议 2-8 个字符(不强制),尽量短小
- 请你提取出可能的黑话最多30个黑话请尽量提取所有
タスク2上のチャット内容から、「黒話かもしれない」候補黒話/スラング/ネット略語/口癖)を抽出してください
- 必ず対話中に実際に出現した短い語句であること
- 意味を理解できない語句であること。意味が明確なものは選ばないこと
- 除:人名、@、スタンプ/画像内の内容、純粋な記号、一般的な機能語(例:の、ね、よ、啊 など
- 各語句の長さは 28 文字程度を推奨(必須ではない)し、できるだけ短くすること
- 可能な限りすべての黒話候補を抽出し、最大 30 個まで
黑话必须为以下几种类型
- 由字母构成的,汉语拼音首字母的简写词,例如nb、yyds、xswl
- 英文词语的缩写,用英文字母概括一个词汇或含义,例如CPU、GPU、API
- 中文词语的缩写,用几个汉字概括一个词汇或含义,例如:社死、内卷
黒話は次のいずれかのタイプでなければなりません
- アルファベットで構成された、中国語ピンイン頭文字の略語。例nb、yyds、xswl
- 英単語の略語で、英字で単語や意味を要約したもの。例CPU、GPU、API
- 中国語の略語で、数文字の漢字で語句や意味を要約したもの。例:社死、内卷
输出要求
将表达方式,语言风格和黑话以 JSON 数组输出,每个元素为一个对象,结构如下(注意字段名
注意请不要输出重复内容,请对表达方式和黑话进行去重
出力要件
表現方法、言語スタイル、黒話を JSON 配列で出力してください。各要素は次の構造のオブジェクトです(フィールド名に注意
重複内容は出力しないでください。表現方法と黒話はどちらも重複を除去してください
[
{{"situation": "AAAAA", "style": "BBBBB", "source_id": "3"}},
{{"situation": "CCCC", "style": "DDDD", "source_id": "7"}}
{{"situation": "对某件事表示十分惊叹", "style": "使用 我嘞个xxxx", "source_id": "[消息编号]"}},
{{"situation": "表示讽刺的赞同,不讲道理", "style": "对对对", "source_id": "[消息编号]"}},
{{"situation": "当涉及游戏相关时,夸赞,略带戏谑意味", "style": "使用 这么强!", "source_id": "[消息编号]"}},
{{"content": "词条", "source_id": "12"}},
{{"content": "词条2", "source_id": "5"}}
{{"situation": "あることに非常に驚いたとき", "style": "我嘞个xxxx を使う", "source_id": "[メッセージ番号]"}},
{{"situation": "皮肉っぽく同意し、理屈は言わない", "style": "对对对", "source_id": "[メッセージ番号]"}},
{{"situation": "ゲーム関連の話題で、少しからかうように褒める", "style": "这么强!を使う", "source_id": "[メッセージ番号]"}},
{{"content": "語句", "source_id": "12"}},
{{"content": "語句2", "source_id": "5"}}
]
其中
方式目:
- situation表示“在什么情境下”的简短概括不超过20个字
- style表示对应的语言风格或常用表达不超过20个字
- source_id:该表达方式对应的“来源行编号”,即上方聊天记录中方括号里的数字(例 [3],请只输出数字本身,不要包含方括号
黑话jargon目:
- content:表示黑话的内容
- source_id:该黑话对应的“来源行编号”,即上方聊天记录中方括号里的数字(例 [3],请只输出数字本身,不要包含方括号
内訳
方式の項目:
- situation: 「どんな状況で」を表す短い要約20 字以内
- style: 対応する言語スタイルまたはよく使う表現20 字以内
- source_id: その表現方式に対応する「出典行番号」。上のチャット記録の角括弧内の数字(例 [3]で、数字だけを出力し角括弧は含めない
黒話 jargon目:
- content: 黒話の内容
- source_id: その黒話に対応する「出典行番号」。上のチャット記録の角括弧内の数字(例 [3]で、数字だけを出力し角括弧は含めない
现在请你输出 JSON
それでは JSON を出力してください

View File

@@ -1,10 +0,0 @@
你是一个专门获取知识的助手。你的名字是{bot_name}。现在是{time_now}。
群里正在进行的聊天内容:
{chat_history}
现在,{sender}发送了内容:{target_message},你想要回复ta。
请仔细分析聊天内容,考虑以下几点:
1. 内容中是否包含需要查询信息的问题
2. 是否有明确的知识获取指令
If you need to use the search tool, please directly call the function "lpmm_search_knowledge". If you do not need to use any tool, simply output "No tool needed".

View File

@@ -1,11 +0,0 @@
你是一个认知感知分析模块。你的任务是根据对话上下文,分析对话中用户的:
1. 核心意图(如:寻求帮助、纯粹聊天、请求任务、发泄情绪、获取信息、表达观点等)
2. 认知状态(如:明确具体、模糊试探、犹豫不决、困惑迷茫、思路清晰、逻辑混乱等)
3. 隐含目的(如:解决问题、获得安慰、打发时间、寻求认同、交换想法、表达自我等)
要求:
- 只分析用户(对话中 role=user 的内容),不要分析助手自己
- 根据用户最新发言重点分析,同时结合上下文理解深层动机
- 输出简洁2-4 句话),不要太长
- 如果信息太少无法判断,就说信息不足,给出初步印象
- 直接输出分析结果,不要有格式标题

View File

@@ -1,11 +0,0 @@
你是一个情绪感知分析模块。你的任务是根据对话上下文,分析对话中用户的:
1. 当前情绪状态(如:开心、沮丧、焦虑、平静、兴奋、愤怒等)
2. 言语态度(如:友好、冷淡、热情、敷衍、试探、认真、调侃等)
3. 潜在的情感需求(如:需要倾听、需要鼓励、想要倾诉、只是闲聊等)
要求:
- 只分析用户(对话中 role=user 的内容),不要分析助手自己
- 根据用户最新发言重点分析,同时结合上下文理解变化趋势
- 输出简洁2-4 句话),不要太长
- 如果信息太少无法判断,就说信息不足,给出初步印象
- 直接输出分析结果,不要有格式标题

View File

@@ -1,18 +1,18 @@
你是一个用户特征分类分析专家。你的任务是分析对话内容,判断其中涉及哪些个人特征分类
あなたはユーザー特性カテゴリ分析の専門家です。あなたのタスクは、会話内容を分析し、どの個人特性カテゴリが関係しているかを判断することです
请仔细阅读以下对话内容,判断其中涉及了哪些个人特征分类
以下の会話内容を注意深く読み、どの個人特性カテゴリが関係しているかを判断してください
人特征分类列表
人特性カテゴリ一覧
{categories_summary}
任务要求
1. 分析对话内容,判断涉及哪些个人特征分类
2. 只输出涉及到的分类编号,用空格分隔
3. 如果对话内容不涉及任何个人特征分类,输出"无"
タスク要件
1. 会話内容を分析し、どの個人特性カテゴリが関係しているかを判断する
2. 関係しているカテゴリ番号だけを、スペース区切りで出力する
3. 会話内容がどの個人特性カテゴリにも関係しない場合は「無」と出力する
输出格式示例】
出力形式の例】
1 3 5
または
请开始分析:
分析を開始してください

View File

@@ -1,17 +1,17 @@
你是一个用户特征信息提取专家。你的任务是从对话内容中提取与指定分类相关的个人特征信息
あなたはユーザー特性情報抽出の専門家です。あなたのタスクは、会話内容から指定カテゴリに関係する個人特性情報を抽出することです
目标分类
対象カテゴリ
{category_name}
任务要求
1. 仔细阅读对话内容,找出与"{category_name}"相关的所有信息
2. 提取的信息应该具体、准确,避免模糊的描述
3. 如果有多条相关信息,请整合成一段简洁的描述
4. 如果对话中没有与该分类相关的信息,输出"无"
タスク要件
1. 会話内容を注意深く読み、「{category_name}」に関係するすべての情報を見つける
2. 抽出する情報は具体的かつ正確で、曖昧な表現を避ける
3. 関連情報が複数ある場合は、簡潔な 1 段落にまとめる
4. 会話内にこのカテゴリに関係する情報がない場合は「無」と出力する
输出格式示例】
用户性格比较内向,不喜欢在人多的时候说话,但和熟悉的朋友会变得很活跃
出力形式の例】
ユーザーはやや内向的で、人が多い場面では話すのが苦手だが、親しい友人とはとても活発になる
または
请开始提取
抽出を開始してください

View File

@@ -1,19 +1,19 @@
你是一个用户特征检索专家。你的任务是根据当前对话上下文,判断需要检索哪些个人特征分类的信息
あなたはユーザー特性検索の専門家です。あなたのタスクは、現在の会話文脈に基づいて、どの個人特性カテゴリ情報を検索する必要があるかを判断することです
当前对话上下文
現在の会話文脈
{chat_context}
人特征分类列表
人特性カテゴリ一覧
{categories_summary}
任务要求
1. 分析当前对话上下文,判断需要哪些个人特征信息来帮助理解用户
2. 只输出需要的分类编号,用空格分隔
3. 如果当前对话不需要任何个人特征信息,输出"无"
タスク要件
1. 現在の会話文脈を分析し、ユーザー理解の助けになる個人特性情報が何かを判断する
2. 必要なカテゴリ番号だけを、スペース区切りで出力する
3. 現在の会話で個人特性情報がまったく不要な場合は「無」と出力する
输出格式示例】
出力形式の例】
2 5 8
または
请开始分析:
分析を開始してください

View File

@@ -1,36 +1,37 @@
あなたの任務は、会話と会話中のやり取りの状況を分析することです。
{bot_name}AIと複数ユーザーの対話に注目し、適切な行動や振る舞いの選択、および収集すべき情報提案を行ってください。
あなたのタスクは、会話とチャット内で起きているやり取りを分析することです。
{bot_name}AIと複数ユーザーの対話に注目し、適切な行動や振る舞いを選び、収集すべき情報提案てください。
【参考情報】
{identity}
【参考情報ここまで】
提供された参考情報、現在の場面、そして出力ルールに基づいて分析してください。
現在の場面では、ユーザーは AI の MaiMai と会話・やり取りをしています。あなたの役割は、ユーザーに見える発言を直接生成することではなく、状況を分析して AI の返を導くことです。
提供された参考情報、現在の状況、そして出力ルールに基づいて分析してください。
現在の状況では、複数のユーザーがやり取りしており、{bot_name} もその参加者の一人です。ユーザー同士で会話している場合もあります。あなたの役割は、ユーザーに見える発言を生成することではなく、状況を分析して AI の返を導くことです。
「分析」には、現在の状況判断、提案、次に取るべき行動計画、そしてその理由を含めてください。
まず {bot_name} の返答に役立つ情報を集め、そのあとで返方針を示してください。
まず {bot_name} が次の行動を取るのに役立つ情報を集め、そのうえで返方針を示してください。
使用できるツール:
- wait(seconds) - 会話を一時停止し、`seconds` 秒待って発話権をユーザーにし、相手の新しい発言を待ちます。
- stop() - {bot_name} が今は発言すべきでないと判断した場合、会話ループを終了し、相手に新しいメッセージが来るまで返信しません。
- reply() - {bot_name} が今ユーザーに対して正式な可視返信を送るべきだと判断したときに呼び出します。呼び出し後、システムはこのラウンドの考えに基づいて、実際にユーザーへ表示される返信を生成します。
- query_jargon() - ある語の意味が不明確とき、またはユーザーが特定の語の意味を尋ねており、調査が必要なときに使います。
- そのほか定義済みのツールも、状況に応じて使用できます。
- wait(seconds) - 会話を一時停止し、`seconds` 秒待って発話権をユーザーにし、相手の新しい発言を待ちます。
- no_reply() - {bot_name} が今は発言すべきでないと判断した場合、会話を終了し、相手に新しいメッセージが来るまで一切返信しません。
- reply() - {bot_name} が今ユーザーに対して可視返信を送るべきだと判断したときに呼び出します。呼び出し後、システムはこのターンの思考に基づいて、実際にユーザーへ表示される返信を生成します。
- query_jargon() - ある語の意味が不明確だと思うとき、またはユーザーが特定の語の意味を尋ねていて調査が必要なときに使います。
- その定義済みのツールも、状況に応じて使用できます。
ツール使用ルール:
1. {bot_name} がすでに返信済みで、ユーザーからまだ新しい返信がなく、新たに集めるべき情報もない場合は `wait` または `stop` を使ってください。
2. ユーザーに新しい発言があっても、まだ続きの発言が来そうだと判断するなら、適切に待って話し終えるのを待って構いません。
3. 特定の状況では連続返信も可能です。たとえば追問したいときや、自分の直前の発言を補足したいときは、`stop` や `wait` を使わなくても構いません。
4. 発言頻度は制御してください。一対一の会話なら比較的均等な頻度で発言して構いませんが、ユーザーが多い場合はすべての発言に反応しないでください。しばらく発言しないと決めた場合は、`wait` で一定時間待つか、`stop` で新着メッセージを待ってください。
5. すべてのメッセージに返信しないでください。他ユーザーが送ったスタンプだけのメッセージには直接返信しないでください。返信頻度をコントロールしてください。
6. ユーザーの疑問や、ある概念への不確さがある場合は、ツールを使って情報収集や意味調査をして構いません。複数ツールを使ってもよいです。
1. {bot_name} がすでに返信済みで、ユーザーからまだ新しい返信がなく、新たに集めるべき情報もない場合は `wait` または `no_reply` を使って待ってください。
2. ユーザーに新しい発言があっても、まだ続きの発言が送られていないだけだと判断するなら、適切に待って話し終えるのを待って構いません。
3. 特定の状況では連続返信も可能です。たとえば追問したいときや、自分の直前の発言を補足したいときは、`no_reply` や `wait` を使わなくても構いません。
4. 発言頻度は制御してください。一対一の会話なら比較的均等な頻度で発言して構いませんが、ユーザーが多い場合はすべての発言に反応しないでください。しばらく発言しないと決めた場合は、`wait` で一定時間待つか、`no_reply` で新着メッセージを待ってください。
5. すべてのメッセージに返信しないでください。他ユーザーが送ったスタンプだけのメッセージには直接返信しないでください。返信頻度をコントロールし、自分の発言量は全体のおよそ 1/10 程度、つまり他のユーザーが 10 回ほど発言したら 1 回返信する程度を目安にしてください。
6. ユーザーの疑問や、ある概念への不確さがある場合は、ツールを使って情報収集や意味調査をして構いません。複数ツールを使ってもよいです。
分析ルール:
1. 基本的には、以前の分析を繰り返さず、現在の最新の分析をそのまま出力してください。
2. 最新の分析は、できるだけ具体的で文脈に密着しており、抽象的な繰り返しにならないようにしてください
1. 基本的には、以前の分析を繰り返さず、現在の最新の分析をそのまま出力してください。最新の分析は、できるだけ具体的で文脈に密着しており、曖昧な繰り返しにならないようにしてください。
2. まず、ユーザー同士のやり取りなのか、{bot_name} に向けたやり取りなのかを判断してください。やみくもに割り込んで、相手を取り違えて返信してはいけません
3. 直前にツールを使った場合は、次のラウンドでその結果を踏まえた新しい分析を続けてください。
4. どの発言が {bot_name} に向けられたものか、どれがユーザー同士のやり取りや独り言なのかを評価し、無関係な話題に頻繁に割り込まないようにしてください。
5. 前のラウンドで発言しなかった場合でも、改めて分析し、新しい分析内容を出力してください。前ラウンドの分析を繰り返してはいけません。
4. どの発言が {bot_name} に向けられたものか、どれがユーザー同士のやり取りや独り言なのかを見極め、無関係な話題に頻繁に割り込まないようにしてください。
5. 前のターンで発言しなかった場合でも、改めて分析し、新しい分析内容を出力してください。前のターンの分析を繰り返してはいけません。
{group_chat_attention_block}
それでは、{bot_name} がどう発言すべきかについての分析を出力してください。必ず先にテキストで分析を出力し、そのあとでツール呼び出しを行ってください。

View File

@@ -1,12 +1,11 @@
あなたは QQ グループで会話しています。以下はグループ内で進行中の会話内容で、チャット履歴と会話中に共有された画像が含まれています。
そのうち、{bot_name}あなた)と示された発言はあなた自身の発言なので、区別に注意してください
あなたは QQ グループで会話しています。以下はグループ内で現在話されている内容で、チャット記録とチャット中の画像が含まれます。
{bot_name}(あなた) と記された発言はあなた自身の発言です。区別に注意してください:
{time_block}
{identity}
あなたはグループ内で会話しています。これまでのチャット履歴を読んで現在の話題を把握し、日常的で口語的な返をしてください。
できるだけ短めにしてください。
一度に一つの話題にだけ返すのが望ましいです。そうすることで、くどくなったり内容が散らかったりするのを防げます。会話の流れにしっかり沿ってください。
あなたはグループ内で会話しています。これまでのチャット記録を読み、現在の話題を把握したうえで、日常的で口語的な返をしてください。
できるだけ短くしてください。話題は一度に一つだけに返信したほうが、冗長になったり内容が散らかったりしません。チャット内容をしっかり踏まえてください。
{reply_style}
[返信情報参考] の内容は必要に応じて参考にしていませんが、必ずしも完全に従う必要はありません。
不要な内容は出力しないでください。たとえば不要な前置きや後置き、コロン、かっこ、スタンプ、`at``@` などは含めず、発言内容だけを出力してください。
返信情報参考】の情報は参考にしてかまいませんが、状況に応じて完全に従う必要はありません。
余計な内容不要な前置きや後置き、コロン、括弧、スタンプ、at や @ など)は出力せず、発言内容だけを出力してください。

View File

@@ -1,19 +0,0 @@
你的名字是{bot_name}。现在是{time_now}。
你正在参与聊天,你需要根据搜集到的信息总结信息。
如果搜集到的信息对于参与聊天,回答问题有帮助,请加入总结,如果无关,请不要加入到总结。
当前聊天记录:
{chat_history}
已收集的信息:
{collected_info}
分析:
- 基于已收集的信息,总结出对当前聊天有帮助的相关信息
- **如果收集的信息对当前聊天有帮助**在思考中直接给出总结信息格式为return_information(information="你的总结信息")
- **如果信息无关或没有帮助**在思考中给出return_information(information="")
**重要规则:**
- 必须严格使用检索到的信息回答问题,不要编造信息
- 答案必须精简,不要过多解释

View File

@@ -1,17 +0,0 @@
你的名字是{bot_name}。现在是{time_now}。
你正在参与聊天,你需要搜集信息来帮助你进行回复。
重要,这是当前聊天记录:
{chat_history}
聊天记录结束
已收集的信息:
{collected_info}
- 你可以对查询思路给出简短的思考:思考要简短,直接切入要点
- 思考完毕后,使用工具
**工具说明:**
- 如果涉及过往事件或者查询某个过去可能提到过的概念或者某段时间发生的事件。可以使用lpmm知识库查询
- 如果遇到不熟悉的词语、缩写、黑话或网络用语可以使用query_words工具查询其含义
- 你必须使用tool如果需要查询你必须给出使用什么工具进行查询
- 当你决定结束查询时必须调用return_information工具返回总结信息并结束查询

View File

@@ -1,44 +1,44 @@
{time_block}
{name_block}
{chat_context_description}以下具体的聊天内容
**聊天内容**
{chat_context_description}以下具体的なチャット内容です
**チャット内容**
{chat_content_block}
**可选的action**
**選択可能な action**
reply
动作描述
1.你可以选择呼叫了你的名字,但是你没有做出回应的消息进行回复
2.你可以自然的顺着正在进行的聊天内容进行回复或自然的提出一个问题
3.最好一次对一个话题进行回复,免得啰嗦或者回复内容太乱。
4.不要选择回复你自己发送的消息
5.不要单独对表情包进行回复
6.将上下文中所有含义不明的,疑似黑话的,缩写词均写入unknown_words
アクションの説明
1. あなたの名前が呼ばれているのに、まだ反応していないメッセージに返信してよいです
2. 進行中の会話内容に自然に沿って返信したり、自然に質問を返したりしてよいです
3. 冗長になったり内容が散らかったりしないよう、できれば一度に一つの話題だけに返信してください
4. 自分が送信したメッセージに返信しないでください
5. スタンプだけに単独で返信しないでください
6. 文脈内で意味が不明なもの、黒話と思われるもの、略語はすべて unknown_words に書き入れてください
{reply_action_example}
no_reply
动作描述
保持沉默,不回复直到有新消息
控制聊天频率,不要太过频繁的发言
アクションの説明
黙ったままにし、新しいメッセージが来るまで返信しない
会話頻度を制御し、発言しすぎない
{{"action":"no_reply"}}
{action_options_text}
**你之前的action执行和思考记录**
**これまでの action 実行と考えの記録**
{actions_before_now_block}
请选择**可选的**且符合使用条件的action并说明触发action的消息id(消息id格式:m+数字)
先输出你的简短的选择思考理由再输出你选择的action理由不要分点精简
**动作选择要求**
请你根据聊天内容,用户的最新消息和以下标准选择合适的动作:
使用条件を満たす **選択可能な** action を選び、その action を発動したメッセージ ID を示してください(メッセージ ID の形式: m+数字)。
まず簡潔な選択理由を出力し、そのあとに選んだ action を出力してください。理由は箇条書きにせず、短くまとめてください
**アクション選択の要件**
チャット内容、ユーザーの最新メッセージ、そして次の基準に基づいて適切な action を選んでください:
{plan_style}
{moderation_prompt}
target_message_id为必填表示触发消息的id
请选择所有符合使用要求的action每个动作最多选择一次但是可以选择多个动作
动作用json格式输出用```json包裹如果输出多个json每个json都要单独一行放在同一个```json代码块内:
**例**
// 理由文本(简短
target_message_id は必須で、トリガーとなったメッセージの ID を表します。
使用要件を満たすすべての action を選んでください。各 action は最大 1 回までですが、複数の action を選ぶことはできます。
action は JSON 形式で出力し、```json で囲んでください。複数の JSON を出力する場合は、それぞれを同じ ```json コードブロック内の別々の行に置いてください:
**例**
// 理由テキスト(簡潔
```json
{{"action":"动作名", "target_message_id":"m123", .....}}
{{"action":"动作名", "target_message_id":"m456", .....}}
```
{{"action":"アクション名", "target_message_id":"m123", .....}}
{{"action":"アクション名", "target_message_id":"m456", .....}}
```

View File

@@ -1,15 +0,0 @@
{knowledge_prompt}{tool_info_block}{extra_info_block}
{expression_habits_block}{memory_retrieval}{jargon_explanation}
你正在和{sender_name}聊天,这是你们之前聊的内容:
{time_block}
{dialogue_prompt}
{reply_target_block}。
{planner_reasoning}
{identity}
{chat_prompt}你正在和{sender_name}聊天,现在请你读读之前的聊天记录,然后给出日常且口语化的回复,平淡一些,
尽量简短一些。{keywords_reaction_prompt}请注意把握聊天内容,不要回复的太有条理。
{reply_style}
请注意不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只输出回复内容。
{moderation_prompt}不要输出多余内容(包括前后缀冒号和引号括号表情包at或 @等 )。

View File

@@ -1,14 +0,0 @@
{knowledge_prompt}{tool_info_block}{extra_info_block}
{expression_habits_block}{memory_retrieval}{jargon_explanation}
你正在和{sender_name}聊天,这是你们之前聊的内容:
{time_block}
{dialogue_prompt}
你现在想补充说明你刚刚自己的发言内容:{target},原因是{reason}
请你根据聊天内容,组织一条新回复。注意,{target} 是刚刚你自己的发言,你要在这基础上进一步发言,请按照你自己的角度来继续进行回复。注意保持上下文的连贯性。
{identity}
{chat_prompt}尽量简短一些。{keywords_reaction_prompt}请注意把握聊天内容,不要回复的太有条理,可以有个性。
{reply_style}
请注意不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只输出回复内容。
{moderation_prompt}不要输出多余内容(包括冒号和引号括号表情包at或 @等 )。

View File

@@ -1,18 +0,0 @@
{knowledge_prompt}{tool_info_block}{extra_info_block}
{expression_habits_block}{memory_retrieval}{jargon_explanation}
你正在qq群里聊天下面是群里正在聊的内容其中包含聊天记录和聊天中的图片
其中标注 {bot_name}(你) 的发言是你自己的发言,请注意区分:
{time_block}
{dialogue_prompt}
{reply_target_block}。
{planner_reasoning}
{identity}
{chat_prompt}你正在群里聊天,现在请你读读之前的聊天记录,把握当前的话题,然后给出日常且简短的回复。
最好一次对一个话题进行回复,免得啰嗦或者回复内容太乱。
{keywords_reaction_prompt}
请注意把握聊天内容。
{reply_style}
请注意不要输出多余内容(包括不必要的前后缀冒号括号at或 @等 ),只输出发言内容就好。
现在,你说:

View File

@@ -1,18 +0,0 @@
{knowledge_prompt}{tool_info_block}{extra_info_block}
{expression_habits_block}{memory_retrieval}{jargon_explanation}
你正在qq群里聊天下面是群里正在聊的内容其中包含聊天记录和聊天中的图片
其中标注 {bot_name}(你) 的发言是你自己的发言,请注意区分:
{time_block}
{dialogue_prompt}
{reply_target_block}。
{planner_reasoning}
{identity}
{chat_prompt}你正在群里聊天,现在请你读读之前的聊天记录,然后给出日常且口语化的回复,
尽量简短一些。{keywords_reaction_prompt}
请注意把握聊天内容,不要回复的太有条理。
{reply_style}
请注意不要输出多余内容(包括不必要的前后缀冒号括号表情包at或 @等 ),只输出发言内容就好。
最好一次对一个话题进行回复,免得啰嗦或者回复内容太乱。
现在,你说:

View File

@@ -1,11 +0,0 @@
你是一个专门执行工具的助手。你的名字是{bot_name}。现在是{time_now}。
群里正在进行的聊天内容:
{chat_history}
现在,{sender}发送了内容:{target_message},你想要回复ta。
请仔细分析聊天内容,考虑以下几点:
1. 内容中是否包含需要查询信息的问题
2. 是否有明确的工具使用指令
你可以选择多个动作
If you need to use tools, please directly call the corresponding tool function. If you do not need to use any tool, simply output "No tool needed".

View File

@@ -6,15 +6,15 @@
【参考信息结束】
你需要根据提供的参考信息,当前场景和输出规则来进行分析
在当前场景中,用户正在与AI麦麦进行聊天互动你的任务不是生成对用户可见的发言而是进行分析来指导AI进行回复。
在当前场景中,不同的用户正在互动({bot_name}也是一位参与的用户),用户也可能与进行聊天互动你的任务不是生成对用户可见的发言而是进行分析来指导AI进行回复。
“分析”应该体现你对当前局面的判断、你的建议、你的下一步计划,以及你为什么这样想。
你需要先搜集能够帮助{bot_name}回复的信息,然后再给出回复意见
你需要先搜集能够帮助{bot_name}进行下一步行动的信息,然后再给出回复意见
你可以使用这些工具:
- wait(seconds) - 暂时停止对话,等待(seconds)秒,把话语权交给用户,等待对方新的发言。
- no_reply() - 当你判断{bot_name}现在不应该发言,结束对话,不进行任何回复,直到对方有新消息。
- reply():当你判断{bot_name}现在应该正式对用户发出一条可见回复时调用。调用后系统会基于你当前这轮的想法生成一条真正展示给用户的回复。
- reply():当你判断{bot_name}现在应该正式对用户发出一条可见回复时调用。调用后系统会基于你当前这轮的想法生成一条真正展示给用户的回复。你可以针对某个用户回复,也可以对所有用户回复。
- query_jargon():当你认为某些词的含义不明确,或用户询问某些词的含义,需要进行查询
- 其他定义的工具,你可以视情况合适使用
@@ -23,14 +23,16 @@
2.如果用户有新发言,但是你评估用户还有后续发言尚未发送,可以适当等待让用户说完
3.在特定情况下也可以连续回复例如想要追问或者补充自己先前的发言可以不使用no_reply或者wait
4.你需要控制自己发言的频率如果用户一对一聊天可以以均匀地频率发言如果用户较多不要每句都回复控制回复频率。当你决定暂时不发言可以使用wait暂时等待一定时间或者no_reply等待新消息
5.不要每条消息都回复,不要直接回复别的用户发送的表情包消息,控制回复频率
5.不要每条消息都回复,不要直接回复别的用户发送的表情包消息,控制回复频率控制你的发言占所有用户的1/10,也就是其他用户10条发言左右你回复一条。
6.如果存在用户的疑问,或者对某些概念的不确定,你可以使用工具来搜集信息或者查询含义,你可以使用多个工具
你的分析规则:
1. 默认直接输出你当前的最新分析,不要重复之前的分析内容。
2. 最新分析应尽量具体,贴近上下文,不要空泛重复。
1. 默认直接输出你当前的最新分析,不要重复之前的分析内容。最新分析应尽量具体,贴近上下文,不要空泛重复。
2. 你需要先评估是用户之间在互动还是和{bot_name}在互动,不要盲目插话,弄错回复对象
3. 如果你刚刚做了工具调用,下一轮应结合工具结果继续输出新的分析。
4. 你需要评估哪些话是对{bot_name}的发言,哪些是用户之间的交流或者自言自语,不要频繁插入无关的话题。
5. 如果你上一轮没有发言,需要重新进行分析,输出新的分析内容,不要重复上一轮的分析内容
{group_chat_attention_block}
现在,请你输出你对{bot_name}发言的分析,你必须先输出文本内容的分析,然后再进行工具调用:

View File

@@ -19,13 +19,14 @@ dependencies = [
"jieba>=0.42.1",
"json-repair>=0.47.6",
"maim-message>=0.6.2",
"maibot-plugin-sdk>=2.1.0",
"maibot-plugin-sdk>=2.3.0",
"mcp",
"msgpack>=1.1.2",
"numpy>=2.2.6",
"openai>=1.95.0",
"pandas>=2.3.1",
"pillow>=11.3.0",
"playwright>=1.54.0",
"pyarrow>=20.0.0",
"pydantic>=2.11.7",
"pypinyin>=0.54.0",

View File

@@ -0,0 +1,194 @@
"""HTML 浏览器渲染服务测试。"""
from pathlib import Path
from typing import Any, Dict, List
import pytest
from src.config.official_configs import PluginRuntimeRenderConfig
from src.services import html_render_service as html_render_service_module
from src.services.html_render_service import HTMLRenderService, ManagedBrowserRecord
class _FakeChromium:
"""用于模拟 Playwright Chromium 启动器的测试桩。"""
def __init__(self, effects: List[Any]) -> None:
"""初始化 Chromium 启动测试桩。
Args:
effects: 每次调用 ``launch`` 时依次返回或抛出的结果。
"""
self._effects: List[Any] = list(effects)
self.calls: List[Dict[str, Any]] = []
async def launch(self, **kwargs: Any) -> Any:
"""模拟 Playwright Chromium 的启动过程。
Args:
**kwargs: 浏览器启动参数。
Returns:
Any: 预设的浏览器对象。
Raises:
Exception: 当预设结果为异常对象时抛出。
"""
self.calls.append(dict(kwargs))
effect = self._effects.pop(0)
if isinstance(effect, Exception):
raise effect
return effect
class _FakePlaywright:
"""用于模拟 Playwright 根对象的测试桩。"""
def __init__(self, chromium: _FakeChromium) -> None:
"""初始化 Playwright 测试桩。
Args:
chromium: Chromium 启动器测试桩。
"""
self.chromium = chromium
def _build_render_config(**kwargs: Any) -> PluginRuntimeRenderConfig:
"""构造用于测试的浏览器渲染配置。
Args:
**kwargs: 需要覆盖的配置字段。
Returns:
PluginRuntimeRenderConfig: 测试使用的配置对象。
"""
payload: Dict[str, Any] = {
"auto_download_chromium": True,
"browser_install_root": "data/test-playwright-browsers",
}
payload.update(kwargs)
return PluginRuntimeRenderConfig(**payload)
@pytest.mark.asyncio
async def test_launch_browser_auto_downloads_chromium_when_missing(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""未检测到可用浏览器时,应自动下载 Chromium 并记录状态。"""
monkeypatch.setattr(html_render_service_module, "PROJECT_ROOT", tmp_path)
service = HTMLRenderService()
config = _build_render_config()
fake_browser = object()
fake_chromium = _FakeChromium(
[
RuntimeError("browserType.launch: Executable doesn't exist at /tmp/chromium"),
fake_browser,
]
)
install_calls: List[str] = []
monkeypatch.setattr(service, "_resolve_executable_path", lambda _config: "")
async def fake_install(_config: PluginRuntimeRenderConfig) -> None:
"""模拟 Chromium 自动下载。
Args:
_config: 当前浏览器渲染配置。
"""
install_calls.append(_config.browser_install_root)
browsers_path = service._get_managed_browsers_path(_config)
(browsers_path / "chromium-1234").mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(service, "_install_chromium_browser", fake_install)
browser = await service._launch_browser(_FakePlaywright(fake_chromium), config)
assert browser is fake_browser
assert install_calls == ["data/test-playwright-browsers"]
assert len(fake_chromium.calls) == 2
browser_record = service._load_managed_browser_record()
assert browser_record is not None
assert browser_record.install_source == "auto_download"
assert browser_record.browser_name == "chromium"
@pytest.mark.asyncio
async def test_launch_browser_reuses_existing_managed_browser(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""已存在 Playwright 托管浏览器时,不应重复下载。"""
monkeypatch.setattr(html_render_service_module, "PROJECT_ROOT", tmp_path)
service = HTMLRenderService()
config = _build_render_config()
browsers_path = service._get_managed_browsers_path(config)
(browsers_path / "chrome-headless-shell-1234").mkdir(parents=True, exist_ok=True)
fake_browser = object()
fake_chromium = _FakeChromium([fake_browser])
monkeypatch.setattr(service, "_resolve_executable_path", lambda _config: "")
async def fail_install(_config: PluginRuntimeRenderConfig) -> None:
"""若被错误调用则立即失败。
Args:
_config: 当前浏览器渲染配置。
Raises:
AssertionError: 表示本测试不期望进入下载逻辑。
"""
raise AssertionError("不应触发自动下载")
monkeypatch.setattr(service, "_install_chromium_browser", fail_install)
browser = await service._launch_browser(_FakePlaywright(fake_chromium), config)
assert browser is fake_browser
assert len(fake_chromium.calls) == 1
browser_record = service._load_managed_browser_record()
assert browser_record is not None
assert browser_record.install_source == "existing_cache"
assert browser_record.browsers_path == str(browsers_path)
@pytest.mark.asyncio
async def test_launch_browser_prefers_local_executable(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""探测到本机浏览器时,应优先使用可执行文件路径启动。"""
monkeypatch.setattr(html_render_service_module, "PROJECT_ROOT", tmp_path)
service = HTMLRenderService()
config = _build_render_config()
fake_browser = object()
fake_chromium = _FakeChromium([fake_browser])
executable_path = "/usr/bin/google-chrome"
monkeypatch.setattr(service, "_resolve_executable_path", lambda _config: executable_path)
browser = await service._launch_browser(_FakePlaywright(fake_chromium), config)
assert browser is fake_browser
assert len(fake_chromium.calls) == 1
assert fake_chromium.calls[0]["executable_path"] == executable_path
assert service._load_managed_browser_record() is None
def test_managed_browser_record_roundtrip() -> None:
"""托管浏览器记录应支持序列化与反序列化。"""
record = ManagedBrowserRecord(
browser_name="chromium",
browsers_path="/tmp/playwright-browsers",
install_source="auto_download",
playwright_version="1.58.0",
recorded_at="2026-04-03T10:00:00+00:00",
last_verified_at="2026-04-03T10:00:01+00:00",
)
restored_record = ManagedBrowserRecord.from_dict(record.to_dict())
assert restored_record == record

View File

@@ -1,9 +1,9 @@
"""NapCat 插件与新 SDK 对接测试。"""
from importlib import import_module, util
from pathlib import Path
from typing import Any, Dict, List
from typing import Any, Dict, List, Tuple
import importlib
import logging
import sys
@@ -12,8 +12,10 @@ import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
PLUGINS_ROOT = PROJECT_ROOT / "plugins"
SDK_ROOT = PROJECT_ROOT / "packages" / "maibot-plugin-sdk"
NAPCAT_PLUGIN_DIR = PLUGINS_ROOT / "MaiBot-Napcat-Adapter"
NAPCAT_TEST_MODULE = "_test_napcat_adapter"
for import_path in (str(PLUGINS_ROOT), str(SDK_ROOT)):
for import_path in (str(SDK_ROOT),):
if import_path not in sys.path:
sys.path.insert(0, import_path)
@@ -63,7 +65,41 @@ class _FakeGatewayCapability:
return True
def _load_napcat_sdk_symbols() -> tuple[Any, Any, Any, Any]:
def _load_napcat_sdk_modules() -> Tuple[Any, Any, Any, Any]:
"""动态加载 NapCat 插件测试所需的模块。
Returns:
tuple[Any, Any, Any, Any]:
依次返回常量模块、配置模块、插件模块和运行时状态模块。
"""
if NAPCAT_TEST_MODULE not in sys.modules:
plugin_path = NAPCAT_PLUGIN_DIR / "plugin.py"
spec = util.spec_from_file_location(
NAPCAT_TEST_MODULE,
plugin_path,
submodule_search_locations=[str(NAPCAT_PLUGIN_DIR)],
)
if spec is None or spec.loader is None:
raise ImportError(f"无法为 NapCat 插件创建模块规格: {plugin_path}")
module = util.module_from_spec(spec)
sys.modules[NAPCAT_TEST_MODULE] = module
try:
spec.loader.exec_module(module)
except Exception:
sys.modules.pop(NAPCAT_TEST_MODULE, None)
raise
return (
import_module(f"{NAPCAT_TEST_MODULE}.constants"),
import_module(f"{NAPCAT_TEST_MODULE}.config"),
import_module(f"{NAPCAT_TEST_MODULE}.plugin"),
import_module(f"{NAPCAT_TEST_MODULE}.runtime_state"),
)
def _load_napcat_sdk_symbols() -> Tuple[Any, Any, Any, Any]:
"""动态加载 NapCat 插件测试所需的符号。
Returns:
@@ -71,10 +107,7 @@ def _load_napcat_sdk_symbols() -> tuple[Any, Any, Any, Any]:
依次返回网关名常量、配置类、插件类和运行时状态管理器类。
"""
constants_module = importlib.import_module("napcat_adapter.constants")
config_module = importlib.import_module("napcat_adapter.config")
plugin_module = importlib.import_module("napcat_adapter.plugin")
runtime_state_module = importlib.import_module("napcat_adapter.runtime_state")
constants_module, config_module, plugin_module, runtime_state_module = _load_napcat_sdk_modules()
return (
constants_module.NAPCAT_GATEWAY_NAME,
config_module.NapCatServerConfig,
@@ -103,6 +136,63 @@ def test_napcat_plugin_collects_duplex_message_gateway() -> None:
assert gateway_component["metadata"]["protocol"] == "napcat"
def test_napcat_plugin_uses_sdk_config_model() -> None:
"""NapCat 插件应声明 SDK 配置模型并暴露默认配置与 Schema。"""
constants_module, _config_module, plugin_module, _runtime_state_module = _load_napcat_sdk_modules()
plugin = plugin_module.NapCatAdapterPlugin()
default_config = plugin.get_default_config()
schema = plugin.get_webui_config_schema(plugin_id="maibot-team.napcat-adapter")
assert default_config["plugin"]["config_version"] == constants_module.SUPPORTED_CONFIG_VERSION
assert default_config["chat"]["ban_qq_bot"] is False
assert default_config["filters"]["ignore_self_message"] is True
assert schema["plugin_id"] == "maibot-team.napcat-adapter"
assert schema["sections"]["chat"]["fields"]["group_list"]["type"] == "array"
assert schema["sections"]["chat"]["fields"]["group_list_type"]["choices"] == ["whitelist", "blacklist"]
def test_napcat_plugin_normalizes_legacy_config_values() -> None:
"""NapCat 插件应兼容旧配置字段并输出规范化结果。"""
constants_module, _config_module, plugin_module, _runtime_state_module = _load_napcat_sdk_modules()
plugin = plugin_module.NapCatAdapterPlugin()
plugin.set_plugin_config(
{
"plugin": {"enabled": True, "config_version": ""},
"connection": {
"access_token": "secret-token",
"heartbeat_sec": "45",
"ws_url": "ws://10.0.0.8:3012/onebot/v11/ws",
},
"chat": {
"ban_qq_bot": True,
"ban_user_id": ["42", 42, ""],
"group_list": [123, " 456 ", None, "123"],
"group_list_type": "whitelist",
"private_list": "invalid",
"private_list_type": "unexpected",
},
"filters": {"ignore_self_message": True},
}
)
config_data = plugin.get_plugin_config_data()
assert "connection" not in config_data
assert config_data["plugin"]["config_version"] == constants_module.SUPPORTED_CONFIG_VERSION
assert config_data["napcat_server"]["host"] == "10.0.0.8"
assert config_data["napcat_server"]["port"] == 3012
assert config_data["napcat_server"]["token"] == "secret-token"
assert config_data["napcat_server"]["heartbeat_interval"] == 45.0
assert config_data["chat"]["group_list"] == ["123", "456"]
assert config_data["chat"]["private_list"] == []
assert config_data["chat"]["private_list_type"] == constants_module.DEFAULT_CHAT_LIST_TYPE
assert plugin.config.napcat_server.build_ws_url() == "ws://10.0.0.8:3012"
@pytest.mark.asyncio
async def test_runtime_state_reports_via_gateway_capability() -> None:
"""NapCat 运行时状态应通过新的消息网关能力上报。"""

View File

@@ -0,0 +1,436 @@
"""插件配置运行时测试。"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Dict, Mapping, Optional, Tuple, cast
import tomllib
import pytest
from src.plugin_runtime.component_query import component_query_service
from src.plugin_runtime.protocol.envelope import (
Envelope,
InspectPluginConfigPayload,
MessageType,
RegisterPluginPayload,
ValidatePluginConfigPayload,
)
from src.plugin_runtime.runner.runner_main import PluginRunner
from src.webui.routers.plugin.config_routes import get_plugin_config, get_plugin_config_schema, update_plugin_config
from src.webui.routers.plugin.schemas import UpdatePluginConfigRequest
class _DemoConfigPlugin:
"""用于测试 Runner 配置归一化流程的伪插件。"""
def __init__(self) -> None:
"""初始化测试插件状态。"""
self.received_config: Dict[str, Any] = {}
def normalize_plugin_config(self, config_data: Optional[Mapping[str, Any]]) -> Tuple[Dict[str, Any], bool]:
"""补齐测试插件的默认配置。
Args:
config_data: 原始配置数据。
Returns:
Tuple[Dict[str, Any], bool]: 补齐后的配置,以及是否发生变更。
"""
current_config = dict(config_data or {})
plugin_section = dict(current_config.get("plugin", {}))
changed = "retry_count" not in plugin_section
plugin_section.setdefault("enabled", True)
plugin_section.setdefault("retry_count", 3)
return {"plugin": plugin_section}, changed
def set_plugin_config(self, config: Dict[str, Any]) -> None:
"""记录 Runner 注入的配置内容。
Args:
config: 当前最新配置。
"""
self.received_config = config
def get_default_config(self) -> Dict[str, Any]:
"""返回测试插件的默认配置。
Returns:
Dict[str, Any]: 默认配置字典。
"""
return {"plugin": {"enabled": True, "retry_count": 3}}
def get_webui_config_schema(
self,
*,
plugin_id: str = "",
plugin_name: str = "",
plugin_version: str = "",
plugin_description: str = "",
plugin_author: str = "",
) -> Dict[str, Any]:
"""返回测试插件的 WebUI 配置 Schema。
Args:
plugin_id: 插件 ID。
plugin_name: 插件名称。
plugin_version: 插件版本。
plugin_description: 插件描述。
plugin_author: 插件作者。
Returns:
Dict[str, Any]: 测试配置 Schema。
"""
del plugin_name, plugin_description, plugin_author
return {
"plugin_id": plugin_id,
"plugin_info": {
"name": "Demo",
"version": plugin_version,
"description": "",
"author": "",
},
"sections": {
"plugin": {
"fields": {
"enabled": {
"type": "boolean",
"label": "启用",
"default": True,
"ui_type": "switch",
}
}
}
},
"layout": {"type": "auto", "tabs": []},
}
class _StrictConfigPlugin:
"""用于测试配置校验错误的伪插件。"""
def normalize_plugin_config(self, config_data: Optional[Mapping[str, Any]]) -> Tuple[Dict[str, Any], bool]:
"""校验重试次数不能为负数。
Args:
config_data: 原始配置数据。
Returns:
Tuple[Dict[str, Any], bool]: 规范化配置结果。
Raises:
ValueError: 当重试次数为负数时抛出。
"""
current_config = dict(config_data or {})
plugin_section = dict(current_config.get("plugin", {}))
retry_count = int(plugin_section.get("retry_count", 0))
if retry_count < 0:
raise ValueError("重试次数不能小于 0")
plugin_section.setdefault("enabled", True)
return {"plugin": plugin_section}, False
def set_plugin_config(self, config: Dict[str, Any]) -> None:
"""兼容 Runner 配置注入接口。
Args:
config: 当前配置字典。
"""
del config
def test_runner_apply_plugin_config_generates_config_file(tmp_path: Path) -> None:
"""Runner 注入配置时应自动补齐并落盘 config.toml。"""
plugin = _DemoConfigPlugin()
runner = PluginRunner(
host_address="ipc://unused",
session_token="session-token",
plugin_dirs=[],
)
meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin)
runner._apply_plugin_config(cast(Any, meta), config_data={"plugin": {"enabled": False}})
config_path = tmp_path / "config.toml"
assert config_path.exists()
assert plugin.received_config == {"plugin": {"enabled": False, "retry_count": 3}}
with config_path.open("rb") as handle:
saved_config = tomllib.load(handle)
assert saved_config == {"plugin": {"enabled": False, "retry_count": 3}}
def test_component_query_service_returns_plugin_config_schema(monkeypatch: Any) -> None:
"""组件查询服务应支持按插件 ID 返回配置 Schema。"""
payload = RegisterPluginPayload(
plugin_id="demo.plugin",
plugin_version="1.0.0",
default_config={"plugin": {"enabled": True}},
config_schema={
"plugin_id": "demo.plugin",
"plugin_info": {
"name": "Demo",
"version": "1.0.0",
"description": "",
"author": "",
},
"sections": {"plugin": {"fields": {}}},
"layout": {"type": "auto", "tabs": []},
},
)
fake_supervisor = SimpleNamespace(_registered_plugins={"demo.plugin": payload})
fake_manager = SimpleNamespace(_get_supervisor_for_plugin=lambda plugin_id: fake_supervisor)
monkeypatch.setattr(
type(component_query_service),
"_get_runtime_manager",
staticmethod(lambda: fake_manager),
)
assert component_query_service.get_plugin_config_schema("demo.plugin") == payload.config_schema
assert component_query_service.get_plugin_default_config("demo.plugin") == payload.default_config
@pytest.mark.asyncio
async def test_runner_validate_plugin_config_handler_returns_normalized_config(monkeypatch: pytest.MonkeyPatch) -> None:
"""Runner 应返回插件模型归一化后的配置。"""
plugin = _DemoConfigPlugin()
runner = PluginRunner(
host_address="ipc://unused",
session_token="session-token",
plugin_dirs=[],
)
meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir="", instance=plugin)
monkeypatch.setattr(runner._loader, "get_plugin", lambda plugin_id: meta if plugin_id == "demo.plugin" else None)
envelope = Envelope(
request_id=1,
message_type=MessageType.REQUEST,
method="plugin.validate_config",
plugin_id="demo.plugin",
payload=ValidatePluginConfigPayload(config_data={"plugin": {"enabled": False}}).model_dump(),
)
response = await runner._handle_validate_plugin_config(envelope)
assert response.error is None
assert response.payload["success"] is True
assert response.payload["normalized_config"] == {"plugin": {"enabled": False, "retry_count": 3}}
@pytest.mark.asyncio
async def test_runner_inspect_plugin_config_handler_supports_unloaded_plugin(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Runner 应支持对未加载插件执行冷检查。"""
plugin = _DemoConfigPlugin()
runner = PluginRunner(
host_address="ipc://unused",
session_token="session-token",
plugin_dirs=[],
)
meta = SimpleNamespace(
plugin_id="demo.plugin",
plugin_dir="/tmp/demo-plugin",
instance=plugin,
manifest=SimpleNamespace(
name="Demo",
description="",
author=SimpleNamespace(name="tester"),
),
version="1.0.0",
)
purged_plugins: list[tuple[str, str]] = []
monkeypatch.setattr(
runner,
"_resolve_plugin_meta_for_config_request",
lambda plugin_id: (meta, True, None) if plugin_id == "demo.plugin" else (None, False, "not-found"),
)
monkeypatch.setattr(
runner._loader,
"purge_plugin_modules",
lambda plugin_id, plugin_dir: purged_plugins.append((plugin_id, plugin_dir)),
)
envelope = Envelope(
request_id=1,
message_type=MessageType.REQUEST,
method="plugin.inspect_config",
plugin_id="demo.plugin",
payload=InspectPluginConfigPayload(
config_data={"plugin": {"enabled": False}},
use_provided_config=True,
).model_dump(),
)
response = await runner._handle_inspect_plugin_config(envelope)
assert response.error is None
assert response.payload["success"] is True
assert response.payload["enabled"] is False
assert response.payload["normalized_config"] == {"plugin": {"enabled": False, "retry_count": 3}}
assert response.payload["default_config"] == {"plugin": {"enabled": True, "retry_count": 3}}
assert purged_plugins == [("demo.plugin", "/tmp/demo-plugin")]
@pytest.mark.asyncio
async def test_runner_validate_plugin_config_handler_returns_error_on_invalid_config(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Runner 应在插件拒绝配置时返回错误响应。"""
plugin = _StrictConfigPlugin()
runner = PluginRunner(
host_address="ipc://unused",
session_token="session-token",
plugin_dirs=[],
)
meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir="", instance=plugin)
monkeypatch.setattr(runner._loader, "get_plugin", lambda plugin_id: meta if plugin_id == "demo.plugin" else None)
envelope = Envelope(
request_id=1,
message_type=MessageType.REQUEST,
method="plugin.validate_config",
plugin_id="demo.plugin",
payload=ValidatePluginConfigPayload(config_data={"plugin": {"retry_count": -1}}).model_dump(),
)
response = await runner._handle_validate_plugin_config(envelope)
assert response.error is not None
assert response.error["message"] == "重试次数不能小于 0"
@pytest.mark.asyncio
async def test_update_plugin_config_prefers_runtime_validation(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""WebUI 保存插件配置时应优先使用运行时校验结果。"""
config_path = tmp_path / "config.toml"
async def _mock_validate_plugin_config(plugin_id: str, config_data: Dict[str, Any]) -> Dict[str, Any] | None:
"""返回运行时归一化后的配置。
Args:
plugin_id: 插件 ID。
config_data: 原始配置。
Returns:
Dict[str, Any] | None: 归一化后的配置。
"""
assert plugin_id == "demo.plugin"
assert config_data == {"plugin": {"enabled": False}}
return {"plugin": {"enabled": False, "retry_count": 3}}
fake_runtime_manager = SimpleNamespace(validate_plugin_config=_mock_validate_plugin_config)
monkeypatch.setattr(
"src.webui.routers.plugin.config_routes.require_plugin_token",
lambda session: session or "session-token",
)
monkeypatch.setattr(
"src.webui.routers.plugin.config_routes.find_plugin_path_by_id",
lambda plugin_id: tmp_path if plugin_id == "demo.plugin" else None,
)
monkeypatch.setattr(
"src.plugin_runtime.integration.get_plugin_runtime_manager",
lambda: fake_runtime_manager,
)
response = await update_plugin_config(
"demo.plugin",
UpdatePluginConfigRequest(config={"plugin.enabled": False}),
maibot_session="session-token",
)
assert response["success"] is True
with config_path.open("rb") as handle:
saved_config = tomllib.load(handle)
assert saved_config == {"plugin": {"enabled": False, "retry_count": 3}}
@pytest.mark.asyncio
async def test_webui_config_endpoints_use_runtime_inspection_for_unloaded_plugin(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""WebUI 在插件未加载时也应从代码定义返回配置与 Schema。"""
async def _mock_inspect_plugin_config(
plugin_id: str,
config_data: Optional[Dict[str, Any]] = None,
*,
use_provided_config: bool = False,
) -> SimpleNamespace | None:
"""返回运行时冷检查结果。
Args:
plugin_id: 插件 ID。
config_data: 可选配置。
use_provided_config: 是否使用传入配置。
Returns:
SimpleNamespace | None: 冷检查结果。
"""
del config_data, use_provided_config
if plugin_id != "demo.plugin":
return None
return SimpleNamespace(
config_schema={
"plugin_id": "demo.plugin",
"plugin_info": {
"name": "Demo",
"version": "1.0.0",
"description": "",
"author": "",
},
"sections": {"plugin": {"fields": {}}},
"layout": {"type": "auto", "tabs": []},
},
normalized_config={"plugin": {"enabled": True, "retry_count": 3}},
enabled=True,
)
fake_runtime_manager = SimpleNamespace(inspect_plugin_config=_mock_inspect_plugin_config)
monkeypatch.setattr(
"src.webui.routers.plugin.config_routes.require_plugin_token",
lambda session: session or "session-token",
)
monkeypatch.setattr(
"src.webui.routers.plugin.config_routes.find_plugin_path_by_id",
lambda plugin_id: tmp_path if plugin_id == "demo.plugin" else None,
)
monkeypatch.setattr(
"src.plugin_runtime.integration.get_plugin_runtime_manager",
lambda: fake_runtime_manager,
)
schema_response = await get_plugin_config_schema("demo.plugin", maibot_session="session-token")
config_response = await get_plugin_config("demo.plugin", maibot_session="session-token")
assert schema_response["success"] is True
assert schema_response["schema"]["plugin_id"] == "demo.plugin"
assert config_response == {
"success": True,
"config": {"plugin": {"enabled": True, "retry_count": 3}},
"message": "配置文件不存在,已返回默认配置",
}

View File

@@ -0,0 +1,225 @@
"""插件依赖流水线测试。"""
from pathlib import Path
import json
import pytest
from src.plugin_runtime.dependency_pipeline import PluginDependencyPipeline
def _build_manifest(
plugin_id: str,
*,
dependencies: list[dict[str, str]] | None = None,
) -> dict[str, object]:
"""构造测试用的 Manifest v2 数据。
Args:
plugin_id: 插件 ID。
dependencies: 依赖声明列表。
Returns:
dict[str, object]: 可直接写入 ``_manifest.json`` 的字典。
"""
return {
"manifest_version": 2,
"version": "1.0.0",
"name": plugin_id,
"description": "测试插件",
"author": {
"name": "tester",
"url": "https://example.com/tester",
},
"license": "MIT",
"urls": {
"repository": f"https://example.com/{plugin_id}",
},
"host_application": {
"min_version": "1.0.0",
"max_version": "1.0.0",
},
"sdk": {
"min_version": "2.0.0",
"max_version": "2.99.99",
},
"dependencies": dependencies or [],
"capabilities": [],
"i18n": {
"default_locale": "zh-CN",
"supported_locales": ["zh-CN"],
},
"id": plugin_id,
}
def _write_plugin(
plugin_root: Path,
plugin_name: str,
plugin_id: str,
*,
dependencies: list[dict[str, str]] | None = None,
) -> Path:
"""在临时目录中写入一个测试插件。
Args:
plugin_root: 插件根目录。
plugin_name: 插件目录名。
plugin_id: 插件 ID。
dependencies: Python 依赖声明列表。
Returns:
Path: 插件目录路径。
"""
plugin_dir = plugin_root / plugin_name
plugin_dir.mkdir(parents=True)
(plugin_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8")
(plugin_dir / "_manifest.json").write_text(
json.dumps(_build_manifest(plugin_id, dependencies=dependencies)),
encoding="utf-8",
)
return plugin_dir
def test_build_plan_blocks_plugin_conflicting_with_host_requirement(tmp_path: Path) -> None:
"""与主程序依赖冲突的插件应被阻止加载。"""
plugin_root = tmp_path / "plugins"
_write_plugin(
plugin_root,
"conflict_plugin",
"test.conflict-plugin",
dependencies=[
{
"type": "python_package",
"name": "numpy",
"version_spec": "<1.0.0",
}
],
)
pipeline = PluginDependencyPipeline(project_root=Path.cwd())
plan = pipeline.build_plan([plugin_root])
assert "test.conflict-plugin" in plan.blocked_plugin_reasons
assert "主程序" in plan.blocked_plugin_reasons["test.conflict-plugin"]
assert plan.install_requirements == ()
def test_build_plan_blocks_plugins_with_conflicting_python_dependencies(tmp_path: Path) -> None:
"""插件之间出现 Python 包版本冲突时应同时阻止双方加载。"""
plugin_root = tmp_path / "plugins"
_write_plugin(
plugin_root,
"plugin_a",
"test.plugin-a",
dependencies=[
{
"type": "python_package",
"name": "demo-package",
"version_spec": "<2.0.0",
}
],
)
_write_plugin(
plugin_root,
"plugin_b",
"test.plugin-b",
dependencies=[
{
"type": "python_package",
"name": "demo-package",
"version_spec": ">=3.0.0",
}
],
)
pipeline = PluginDependencyPipeline(project_root=Path.cwd())
plan = pipeline.build_plan([plugin_root])
assert "test.plugin-a" in plan.blocked_plugin_reasons
assert "test.plugin-b" in plan.blocked_plugin_reasons
assert "test.plugin-b" in plan.blocked_plugin_reasons["test.plugin-a"]
assert "test.plugin-a" in plan.blocked_plugin_reasons["test.plugin-b"]
def test_build_plan_collects_install_requirements_for_missing_packages(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""未安装但无冲突的依赖应进入自动安装计划。"""
plugin_root = tmp_path / "plugins"
_write_plugin(
plugin_root,
"plugin_a",
"test.plugin-a",
dependencies=[
{
"type": "python_package",
"name": "demo-package",
"version_spec": ">=1.0.0,<2.0.0",
}
],
)
pipeline = PluginDependencyPipeline(project_root=Path.cwd())
monkeypatch.setattr(
pipeline._manifest_validator,
"get_installed_package_version",
lambda package_name: None if package_name == "demo-package" else "1.0.0",
)
plan = pipeline.build_plan([plugin_root])
assert plan.blocked_plugin_reasons == {}
assert len(plan.install_requirements) == 1
assert plan.install_requirements[0].package_name == "demo-package"
assert plan.install_requirements[0].plugin_ids == ("test.plugin-a",)
assert plan.install_requirements[0].requirement_text == "demo-package>=1.0.0,<2.0.0"
@pytest.mark.asyncio
async def test_execute_blocks_plugins_when_auto_install_fails(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""自动安装失败时,相关插件应被阻止加载。"""
plugin_root = tmp_path / "plugins"
_write_plugin(
plugin_root,
"plugin_a",
"test.plugin-a",
dependencies=[
{
"type": "python_package",
"name": "demo-package",
"version_spec": ">=1.0.0,<2.0.0",
}
],
)
pipeline = PluginDependencyPipeline(project_root=Path.cwd())
monkeypatch.setattr(
pipeline._manifest_validator,
"get_installed_package_version",
lambda package_name: None if package_name == "demo-package" else "1.0.0",
)
async def fake_install(_requirements) -> tuple[bool, str]:
"""模拟依赖安装失败。"""
return False, "network error"
monkeypatch.setattr(pipeline, "_install_requirements", fake_install)
result = await pipeline.execute([plugin_root])
assert result.environment_changed is False
assert "test.plugin-a" in result.blocked_plugin_reasons
assert "自动安装 Python 依赖失败" in result.blocked_plugin_reasons["test.plugin-a"]

View File

@@ -3,9 +3,11 @@
验证协议层、传输层、RPC 通信链路的正确性。
"""
# pyright: reportArgumentType=false, reportAttributeAccessIssue=false, reportCallIssue=false, reportIndexIssue=false, reportMissingImports=false, reportOptionalMemberAccess=false
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Awaitable, Callable, Dict, List, Optional
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
import asyncio
import json
@@ -1405,6 +1407,57 @@ class TestComponentRegistry:
assert warnings
assert "plugin_a.broken" in warnings[0]
def test_register_hook_handler_rejects_unknown_hook(self):
from src.plugin_runtime.host.component_registry import ComponentRegistrationError, ComponentRegistry
from src.plugin_runtime.host.hook_spec_registry import HookSpecRegistry
reg = ComponentRegistry(hook_spec_registry=HookSpecRegistry())
with pytest.raises(ComponentRegistrationError, match="未注册的 Hook"):
reg.register_component(
"broken_hook",
"hook_handler",
"plugin_a",
{
"hook": "chat.receive.unknown",
"mode": "blocking",
},
)
def test_register_plugin_components_is_atomic_when_hook_invalid(self):
from src.plugin_runtime.host.component_registry import ComponentRegistrationError, ComponentRegistry
from src.plugin_runtime.host.hook_spec_registry import HookSpec, HookSpecRegistry
hook_spec_registry = HookSpecRegistry()
hook_spec_registry.register_hook_spec(HookSpec(name="chat.receive.before_process"))
reg = ComponentRegistry(hook_spec_registry=hook_spec_registry)
reg.register_plugin_components(
"plugin_a",
[
{"name": "cmd_old", "component_type": "command", "metadata": {"command_pattern": r"^/old"}},
],
)
with pytest.raises(ComponentRegistrationError, match="未注册的 Hook"):
reg.register_plugin_components(
"plugin_a",
[
{
"name": "hook_ok",
"component_type": "hook_handler",
"metadata": {"hook": "chat.receive.before_process", "mode": "blocking"},
},
{
"name": "hook_bad",
"component_type": "hook_handler",
"metadata": {"hook": "chat.receive.missing", "mode": "blocking"},
},
],
)
assert reg.get_component("plugin_a.cmd_old") is not None
assert reg.get_component("plugin_a.hook_ok") is None
def test_query_by_type(self):
from src.plugin_runtime.host.component_registry import ComponentRegistry
@@ -2142,6 +2195,18 @@ class TestPluginRuntimeHookEntry:
assert result.kwargs["session_id"] == "s-1"
assert ("b1", "builtin_guard") in call_log
def test_manager_lists_builtin_hook_specs(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""PluginRuntimeManager 应暴露内置 Hook 规格清单。"""
_ComponentRegistry, PluginRuntimeManager = self._import_manager_modules(monkeypatch)
manager = PluginRuntimeManager()
hook_names = {spec.name for spec in manager.list_hook_specs()}
assert "chat.receive.before_process" in hook_names
assert "send_service.before_send" in hook_names
assert "maisaka.planner.after_response" in hook_names
class TestRPCServer:
"""RPC Server 代际保护测试"""
@@ -2828,7 +2893,7 @@ class TestIntegration:
assert instances[0].stopped is True
@pytest.mark.asyncio
async def test_handle_plugin_source_changes_only_reload_matching_supervisor(self, monkeypatch, tmp_path):
async def test_handle_plugin_source_changes_restarts_supervisors_after_dependency_sync(self, monkeypatch, tmp_path):
from src.config.file_watcher import FileChange
from src.plugin_runtime import integration as integration_module
import json
@@ -2852,7 +2917,6 @@ class TestIntegration:
def __init__(self, plugin_dirs, registered_plugins):
self._plugin_dirs = plugin_dirs
self._registered_plugins = registered_plugins
self.reload_reasons = []
self.config_updates = []
def get_loaded_plugin_ids(self):
@@ -2861,9 +2925,6 @@ class TestIntegration:
def get_loaded_plugin_versions(self):
return {plugin_id: "1.0.0" for plugin_id in self._registered_plugins}
async def reload_plugins(self, plugin_ids=None, reason="manual", external_available_plugins=None):
self.reload_reasons.append((plugin_ids, reason, external_available_plugins or {}))
async def notify_plugin_config_updated(self, plugin_id, config_data, config_version=""):
self.config_updates.append((plugin_id, config_data, config_version))
return True
@@ -2872,27 +2933,37 @@ class TestIntegration:
manager._started = True
manager._builtin_supervisor = FakeSupervisor([builtin_root], {"test.alpha": object()})
manager._third_party_supervisor = FakeSupervisor([thirdparty_root], {"test.beta": object()})
dependency_sync_calls = []
restart_calls = []
async def fake_sync(plugin_dirs: Sequence[Path]) -> Any:
"""记录依赖同步调用。"""
dependency_sync_calls.append(list(plugin_dirs))
return integration_module.DependencySyncState(
blocked_changed_plugin_ids={"test.beta"},
environment_changed=False,
)
async def fake_restart(reason: str) -> bool:
"""记录 Supervisor 重启调用。"""
restart_calls.append(reason)
return True
monkeypatch.setattr(manager, "_sync_plugin_dependencies", fake_sync)
monkeypatch.setattr(manager, "_restart_supervisors", fake_restart)
changes = [
FileChange(change_type=1, path=beta_dir / "plugin.py"),
]
refresh_calls = []
def fake_refresh() -> None:
refresh_calls.append(True)
manager._refresh_plugin_config_watch_subscriptions = fake_refresh
await manager._handle_plugin_source_changes(changes)
assert manager._builtin_supervisor.reload_reasons == []
assert manager._third_party_supervisor.reload_reasons == [
(["test.beta"], "file_watcher", {"test.alpha": "1.0.0"})
]
assert dependency_sync_calls == [[builtin_root, thirdparty_root]]
assert restart_calls == ["file_watcher_blocklist_changed"]
assert manager._builtin_supervisor.config_updates == []
assert manager._third_party_supervisor.config_updates == []
assert refresh_calls == [True]
@pytest.mark.asyncio
async def test_reload_plugins_globally_warns_and_skips_cross_supervisor_dependents(self, monkeypatch):
@@ -2974,6 +3045,16 @@ class TestIntegration:
self._registered_plugins = {plugin_id: object() for plugin_id in plugins}
self.config_updates = []
async def inspect_plugin_config(
self,
plugin_id: str,
config_data: Optional[Dict[str, Any]] = None,
use_provided_config: bool = False,
) -> SimpleNamespace:
"""返回测试用的配置解析结果。"""
del config_data, use_provided_config
return SimpleNamespace(enabled=True, normalized_config={"enabled": True}, plugin_id=plugin_id)
async def notify_plugin_config_updated(
self,
plugin_id,
@@ -2997,6 +3078,110 @@ class TestIntegration:
assert manager._builtin_supervisor.config_updates == [("test.alpha", {"enabled": True}, "", "self")]
assert manager._third_party_supervisor.config_updates == []
@pytest.mark.asyncio
async def test_handle_plugin_config_changes_loads_unloaded_enabled_plugin(self, monkeypatch, tmp_path):
from src.plugin_runtime import integration as integration_module
from src.config.file_watcher import FileChange
import json
thirdparty_root = tmp_path / "plugins"
alpha_dir = thirdparty_root / "alpha"
alpha_dir.mkdir(parents=True)
(alpha_dir / "config.toml").write_text("[plugin]\nenabled = true\n", encoding="utf-8")
(alpha_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8")
(alpha_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.alpha")), encoding="utf-8")
monkeypatch.chdir(tmp_path)
class FakeSupervisor:
def __init__(self, plugin_dirs):
self._plugin_dirs = plugin_dirs
self._registered_plugins = {}
async def inspect_plugin_config(
self,
plugin_id: str,
config_data: Optional[Dict[str, Any]] = None,
use_provided_config: bool = False,
) -> SimpleNamespace:
"""返回测试用的启用配置快照。"""
del config_data, use_provided_config
return SimpleNamespace(enabled=True, normalized_config={"plugin": {"enabled": True}}, plugin_id=plugin_id)
manager = integration_module.PluginRuntimeManager()
manager._started = True
manager._third_party_supervisor = FakeSupervisor([thirdparty_root])
load_calls = []
async def fake_load_plugin_globally(plugin_id: str, reason: str = "manual") -> bool:
"""记录自动加载调用。"""
load_calls.append((plugin_id, reason))
return True
monkeypatch.setattr(manager, "load_plugin_globally", fake_load_plugin_globally)
await manager._handle_plugin_config_changes(
"test.alpha",
[FileChange(change_type=1, path=alpha_dir / "config.toml")],
)
assert load_calls == [("test.alpha", "config_enabled")]
@pytest.mark.asyncio
async def test_handle_plugin_config_changes_unloads_loaded_disabled_plugin(self, monkeypatch, tmp_path):
from src.plugin_runtime import integration as integration_module
from src.config.file_watcher import FileChange
import json
builtin_root = tmp_path / "src" / "plugins" / "built_in"
alpha_dir = builtin_root / "alpha"
alpha_dir.mkdir(parents=True)
(alpha_dir / "config.toml").write_text("[plugin]\nenabled = false\n", encoding="utf-8")
(alpha_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8")
(alpha_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.alpha")), encoding="utf-8")
monkeypatch.chdir(tmp_path)
class FakeSupervisor:
def __init__(self, plugin_dirs, plugins):
self._plugin_dirs = plugin_dirs
self._registered_plugins = {plugin_id: object() for plugin_id in plugins}
async def inspect_plugin_config(
self,
plugin_id: str,
config_data: Optional[Dict[str, Any]] = None,
use_provided_config: bool = False,
) -> SimpleNamespace:
"""返回测试用的禁用配置快照。"""
del config_data, use_provided_config
return SimpleNamespace(
enabled=False,
normalized_config={"plugin": {"enabled": False}},
plugin_id=plugin_id,
)
manager = integration_module.PluginRuntimeManager()
manager._started = True
manager._builtin_supervisor = FakeSupervisor([builtin_root], ["test.alpha"])
reload_calls = []
async def fake_reload_plugins_globally(plugin_ids: Sequence[str], reason: str = "manual") -> bool:
"""记录自动卸载调用。"""
reload_calls.append((list(plugin_ids), reason))
return True
monkeypatch.setattr(manager, "reload_plugins_globally", fake_reload_plugins_globally)
await manager._handle_plugin_config_changes(
"test.alpha",
[FileChange(change_type=1, path=alpha_dir / "config.toml")],
)
assert reload_calls == [(["test.alpha"], "config_disabled")]
@pytest.mark.asyncio
async def test_handle_main_config_reload_only_notifies_subscribers(self, monkeypatch):
from src.plugin_runtime import integration as integration_module
@@ -3108,6 +3293,55 @@ class TestIntegration:
subscription["paths"][0] for subscription in manager._plugin_file_watcher.subscriptions
} == {alpha_dir / "config.toml", beta_dir / "config.toml"}
def test_refresh_plugin_config_watch_subscriptions_includes_unloaded_plugins(self, tmp_path):
from src.plugin_runtime import integration as integration_module
import json
thirdparty_root = tmp_path / "plugins"
alpha_dir = thirdparty_root / "alpha"
beta_dir = thirdparty_root / "beta"
alpha_dir.mkdir(parents=True)
beta_dir.mkdir(parents=True)
(alpha_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8")
(beta_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8")
(alpha_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.alpha")), encoding="utf-8")
(beta_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.beta")), encoding="utf-8")
class FakeWatcher:
def __init__(self):
self.subscriptions = []
def subscribe(
self,
callback: Any,
*,
paths: Optional[Sequence[Path]] = None,
change_types: Any = None,
) -> str:
"""记录新的监听订阅。"""
del callback, change_types
subscription_id = f"sub-{len(self.subscriptions) + 1}"
self.subscriptions.append({"id": subscription_id, "paths": tuple(paths or ())})
return subscription_id
def unsubscribe(self, subscription_id: str) -> bool:
"""兼容 watcher 取消订阅接口。"""
del subscription_id
return True
class FakeSupervisor:
def __init__(self, plugin_dirs, plugins):
self._plugin_dirs = plugin_dirs
self._registered_plugins = {plugin_id: object() for plugin_id in plugins}
manager = integration_module.PluginRuntimeManager()
manager._plugin_file_watcher = FakeWatcher()
manager._third_party_supervisor = FakeSupervisor([thirdparty_root], ["test.alpha"])
manager._refresh_plugin_config_watch_subscriptions()
assert set(manager._plugin_config_watcher_subscriptions.keys()) == {"test.alpha", "test.beta"}
@pytest.mark.asyncio
async def test_component_reload_plugin_returns_failure_when_reload_rolls_back(self, monkeypatch):
from src.plugin_runtime import integration as integration_module

Some files were not shown because too many files have changed in this diff Show More