fix:修复qq号为int的问题,修复部分多行配置问题,修复多语言prompt问题

This commit is contained in:
SengokuCola
2026-05-05 00:32:49 +08:00
parent 94a0cb3a62
commit 4641fa1a15
17 changed files with 164 additions and 55 deletions

View File

@@ -32,8 +32,7 @@
# 运行/调试/构建/测试/依赖 # 运行/调试/构建/测试/依赖
优先使用uv 优先使用uv
依赖项以 pyproject.toml 为准 依赖项以 pyproject.toml 为准要同步更新requirements.txt
# 语言规范 # 语言规范
项目的首选语言为简体中文,无论是注释语言,日志展示语言,还是 WebUI 展示语言都首要以简体中文为首要实现目标 项目的首选语言为简体中文,无论是注释语言,日志展示语言,还是 WebUI 展示语言都首要以简体中文为首要实现目标
@@ -52,3 +51,6 @@
# maibot插件开发文档 # maibot插件开发文档
https://github.com/Mai-with-u/maibot-plugin-sdk/blob/main/docs/guide.md https://github.com/Mai-with-u/maibot-plugin-sdk/blob/main/docs/guide.md
# 如何提交maibot插件
https://github.com/Mai-with-u/plugin-repo/blob/main/CONTRIBUTING.md

View File

@@ -215,12 +215,50 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
? [...normalFields, ...advancedFields] ? [...normalFields, ...advancedFields]
: normalFields : normalFields
const groupFieldsByRow = (fields: FieldSchema[]) => {
const rows: FieldSchema[][] = []
let currentRow: FieldSchema[] = []
let currentRowKey: string | undefined
for (const field of fields) {
const rowKey = field['x-row']
if (rowKey && rowKey === currentRowKey) {
currentRow.push(field)
continue
}
if (currentRow.length > 0) {
rows.push(currentRow)
}
currentRow = [field]
currentRowKey = rowKey
}
if (currentRow.length > 0) {
rows.push(currentRow)
}
return rows
}
const renderFieldList = (fields: FieldSchema[]) => ( const renderFieldList = (fields: FieldSchema[]) => (
<> <>
{fields.map((field, index) => ( {groupFieldsByRow(fields).map((row, index) => (
<React.Fragment key={field.name}> <React.Fragment key={row.map((field) => field.name).join('|')}>
{index > 0 && <Separator className="my-2 bg-border/50" />} {index > 0 && <Separator className="my-2 bg-border/50" />}
<div className="py-1">{renderField(field)}</div> {row.length > 1 ? (
<div
className="grid gap-4 py-1 md:grid-cols-[repeat(var(--field-row-count),minmax(0,1fr))]"
style={{ '--field-row-count': row.length } as React.CSSProperties}
>
{row.map((field) => (
<div key={field.name}>{renderField(field)}</div>
))}
</div>
) : (
<div className="py-1">{renderField(row[0])}</div>
)}
</React.Fragment> </React.Fragment>
))} ))}
</> </>

View File

@@ -8,6 +8,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Slider } from "@/components/ui/slider" import { Slider } from "@/components/ui/slider"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import type { FieldSchema } from "@/types/config-schema" import type { FieldSchema } from "@/types/config-schema"
@@ -115,11 +121,9 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
return <IconComponent className="h-4 w-4" /> return <IconComponent className="h-4 w-4" />
} }
const isRuleTypeSelect = const optionDescriptions = schema['x-option-descriptions'] ?? {}
schema.name === 'rule_type' && const hasOptionDescriptions = Object.keys(optionDescriptions).length > 0
(schema.type === 'select' || schema['x-widget'] === 'select') const inlineDescription = hasOptionDescriptions ? '' : schema.description
const inlineDescription = isRuleTypeSelect ? '' : schema.description
const selectHoverDescription = isRuleTypeSelect ? schema.description : undefined
const renderFieldHeader = () => ( const renderFieldHeader = () => (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
@@ -352,15 +356,43 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
return ( return (
<Select value={strValue} onValueChange={(val) => onChange(val)}> <Select value={strValue} onValueChange={(val) => onChange(val)}>
<SelectTrigger title={selectHoverDescription}> <SelectTrigger>
<SelectValue placeholder={`Select ${schema.label}`} /> <SelectValue placeholder={`Select ${schema.label}`} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{options.map((option) => ( {hasOptionDescriptions ? (
<SelectItem key={option} value={option}> <TooltipProvider delayDuration={150}>
{option} {options.map((option) => {
</SelectItem> const description = optionDescriptions[option]
))} return description ? (
<Tooltip key={option}>
<TooltipTrigger asChild>
<SelectItem value={option} title={description}>
{option}
</SelectItem>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
className="max-w-72 bg-background text-foreground border shadow-lg"
>
{description}
</TooltipContent>
</Tooltip>
) : (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
)
})}
</TooltipProvider>
) : (
options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))
)}
</SelectContent> </SelectContent>
</Select> </Select>
) )

View File

@@ -490,10 +490,20 @@ function BotConfigPageContent() {
const saveSourceCode = async () => { const saveSourceCode = async () => {
try { try {
setSaving(true) setSaving(true)
// 编辑器展示时会把 basic string 内的 \n 展开成真实换行;保存前先转回 TOML 转义序列。
const escapedSourceCode = sourceCode.replace(/"([^"]*)"/g, (_match, content) => {
const encoded = content
.replace(/\\/g, '\\\\') // 反斜杠必须先转义,避免 \s 等序列被 TOML 当作非法转义
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\t/g, '\\t')
.replace(/\r/g, '\\r')
return `"${encoded}"`
})
// 前端验证 TOML 格式 // 前端验证 TOML 格式
try { try {
parseToml(sourceCode) parseToml(escapedSourceCode)
} catch (error) { } catch (error) {
const errorMsg = error instanceof Error ? error.message : 'TOML 格式错误' const errorMsg = error instanceof Error ? error.message : 'TOML 格式错误'
const translatedMsg = translateTomlError(errorMsg) const translatedMsg = translateTomlError(errorMsg)
@@ -508,18 +518,7 @@ function BotConfigPageContent() {
return return
} }
// 将双引号字符串中的实际字符转换回 TOML 转义序列 const result = await updateBotConfigRaw(escapedSourceCode)
// 使用正则表达式只处理双引号字符串内的内容,不影响单引号字符串
const escaped = sourceCode.replace(/"([^"]*)"/g, (_match, content) => {
const encoded = content
.replace(/\\/g, '\\\\') // 反斜杠(必须放在最前)
.replace(/"/g, '\\"') // 双引号
.replace(/\n/g, '\\n') // 换行符
.replace(/\t/g, '\\t') // 制表符
.replace(/\r/g, '\\r') // 回车符
return `"${encoded}"`
})
const result = await updateBotConfigRaw(escaped)
if (!result.success) { if (!result.success) {
setHasTomlError(true) setHasTomlError(true)
const errorMsg = result.error const errorMsg = result.error

View File

@@ -889,7 +889,7 @@ function ModelConfigPageContent() {
</Button> </Button>
<Button type="button" variant="ghost" size="sm" onClick={dismissTourEntry}> <Button type="button" variant="ghost" size="sm" onClick={dismissTourEntry}>
</Button> </Button>
</div> </div>
</AlertDescription> </AlertDescription>

View File

@@ -63,7 +63,7 @@ export function EmojiManagementPage() {
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [pageSize, setPageSize] = useState(20) const [pageSize, setPageSize] = useState(20)
const [registeredFilter, setRegisteredFilter] = useState<string>('all') const [registeredFilter, setRegisteredFilter] = useState<string>('registered')
const [bannedFilter, setBannedFilter] = useState<string>('all') const [bannedFilter, setBannedFilter] = useState<string>('all')
const [formatFilter, setFormatFilter] = useState<string>('all') const [formatFilter, setFormatFilter] = useState<string>('all')
const [sortBy, setSortBy] = useState<string>('usage_count') const [sortBy, setSortBy] = useState<string>('usage_count')

View File

@@ -44,7 +44,7 @@ function normalizePlatform(raw: string): string {
function deriveSelectedPlatform(config: BotBasicConfig): { selected: string; customName: string } { function deriveSelectedPlatform(config: BotBasicConfig): { selected: string; customName: string } {
const platform = config.platform const platform = config.platform
// Legacy: no platform set but has QQ account // Legacy: no platform set but has QQ account
if (!platform && config.qq_account > 0) { if (!platform && config.qq_account.trim()) {
return { selected: 'qq', customName: '' } return { selected: 'qq', customName: '' }
} }
if (!platform) { if (!platform) {
@@ -96,9 +96,7 @@ export function BotBasicForm({ config, onChange }: BotBasicFormProps) {
const customPlatformName = customPlatformNameOverride ?? derived.customName const customPlatformName = customPlatformNameOverride ?? derived.customName
const primaryAccount = const primaryAccount =
selectedPlatform === 'qq' selectedPlatform === 'qq'
? config.qq_account > 0 ? config.qq_account.trim()
? String(config.qq_account)
: ''
: config.platform : config.platform
? getPrimaryAccount(config.platforms, config.platform) ? getPrimaryAccount(config.platforms, config.platform)
: '' : ''
@@ -141,7 +139,7 @@ export function BotBasicForm({ config, onChange }: BotBasicFormProps) {
if (normalized === 'qq') { if (normalized === 'qq') {
onChange({ onChange({
...config, ...config,
qq_account: Number(accountId) || 0, qq_account: accountId.trim(),
platform: 'qq', platform: 'qq',
}) })
} else { } else {

View File

@@ -61,10 +61,11 @@ export async function loadBotBasicConfig(): Promise<BotBasicConfig> {
) )
const data = throwIfError(result) const data = throwIfError(result)
const botConfig = (data.config.bot || {}) as Partial<BotBasicConfig> const botConfig = (data.config.bot || {}) as Partial<BotBasicConfig>
const qqAccount = String(botConfig.qq_account ?? '').trim()
return { return {
platform: botConfig.platform || (botConfig.qq_account ? 'qq' : ''), platform: botConfig.platform || (qqAccount ? 'qq' : ''),
qq_account: botConfig.qq_account || 0, qq_account: qqAccount,
platforms: botConfig.platforms || [], platforms: botConfig.platforms || [],
nickname: botConfig.nickname || '', nickname: botConfig.nickname || '',
alias_names: botConfig.alias_names || [], alias_names: botConfig.alias_names || [],

View File

@@ -106,7 +106,7 @@ function SetupPageContent() {
// 步骤1Bot基础信息 // 步骤1Bot基础信息
const [botBasic, setBotBasic] = useState<BotBasicConfig>({ const [botBasic, setBotBasic] = useState<BotBasicConfig>({
platform: '', platform: '',
qq_account: 0, qq_account: '',
platforms: [], platforms: [],
nickname: '', nickname: '',
alias_names: [], alias_names: [],
@@ -239,7 +239,7 @@ function SetupPageContent() {
if (!config.platform) return t('setupPage.validation.selectPlatform') if (!config.platform) return t('setupPage.validation.selectPlatform')
if (!config.nickname.trim()) return t('setupPage.validation.enterNickname') if (!config.nickname.trim()) return t('setupPage.validation.enterNickname')
if (config.platform === 'qq') { if (config.platform === 'qq') {
if (!config.qq_account || config.qq_account <= 0) { if (!config.qq_account.trim()) {
return t('setupPage.validation.enterQqAccount') return t('setupPage.validation.enterQqAccount')
} }
} else { } else {

View File

@@ -10,7 +10,7 @@ export interface SetupStep {
// 步骤1Bot基础信息 // 步骤1Bot基础信息
export interface BotBasicConfig { export interface BotBasicConfig {
platform: string // Primary platform name (normalized, lowercase) platform: string // Primary platform name (normalized, lowercase)
qq_account: number // QQ account (preserved always for webui compat) qq_account: string // QQ account (preserved always for webui compat)
platforms: string[] // Other platform accounts "platform:account" platforms: string[] // Other platform accounts "platform:account"
nickname: string nickname: string
alias_names: string[] alias_names: string[]

View File

@@ -40,6 +40,8 @@ export interface FieldSchema {
'x-icon'?: string 'x-icon'?: string
'x-layout'?: 'inline-right' 'x-layout'?: 'inline-right'
'x-input-width'?: string 'x-input-width'?: string
'x-option-descriptions'?: Record<string, string>
'x-row'?: string
'x-textarea-min-height'?: number 'x-textarea-min-height'?: number
'x-textarea-rows'?: number 'x-textarea-rows'?: number
advanced?: boolean advanced?: boolean

View File

@@ -1,7 +1,5 @@
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. 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: Messages marked as your own messages should be distinguished carefully:
{time_block}
{identity} {identity}
You are chatting in the group now. Please read the previous chat records, grasp the current topic, and then give a natural, colloquial reply. You are chatting in the group now. Please read the previous chat records, grasp the current topic, and then give a natural, colloquial reply.

View File

@@ -1,7 +1,5 @@
あなたは QQ グループで会話しています。以下はグループ内で現在話されている内容で、チャット記録とチャット中の画像が含まれます。 あなたは QQ グループで会話しています。以下はグループ内で現在話されている内容で、チャット記録とチャット中の画像が含まれます。
{bot_name}(あなた) と記された発言はあなた自身の発言です。区別に注意してください: あなた自身の発言として記された発言は、区別に注意してください:
{time_block}
{identity} {identity}
今あなたはグループ内で会話しています。これまでのチャット記録を読み、現在の話題を把握したうえで、日常的で口語的な返信をしてください。 今あなたはグループ内で会話しています。これまでのチャット記録を読み、現在の話題を把握したうえで、日常的で口語的な返信をしてください。

View File

@@ -56,7 +56,7 @@ BOT_CONFIG_PATH: Path = (CONFIG_DIR / "bot_config.toml").resolve().absolute()
MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute() MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute()
LEGACY_ENV_PATH: Path = (PROJECT_ROOT / ".env").resolve().absolute() LEGACY_ENV_PATH: Path = (PROJECT_ROOT / ".env").resolve().absolute()
A_MEMORIX_LEGACY_CONFIG_PATH: Path = (CONFIG_DIR / "a_memorix.toml").resolve().absolute() A_MEMORIX_LEGACY_CONFIG_PATH: Path = (CONFIG_DIR / "a_memorix.toml").resolve().absolute()
MMC_VERSION: str = "1.0.0-pre.10" MMC_VERSION: str = "1.0.0-pre.11"
CONFIG_VERSION: str = "8.10.6" CONFIG_VERSION: str = "8.10.6"
MODEL_CONFIG_VERSION: str = "1.15.3" MODEL_CONFIG_VERSION: str = "1.15.3"

View File

@@ -343,10 +343,10 @@ def try_migrate_legacy_bot_config_dict(data: dict[str, Any]) -> MigrationResult:
reasons: list[str] = [] reasons: list[str] = []
bot = _as_dict(data.get("bot")) bot = _as_dict(data.get("bot"))
if bot is not None and isinstance(bot.get("qq_account"), str) and not bot["qq_account"].strip(): if bot is not None and isinstance(bot.get("qq_account"), int):
bot["qq_account"] = 0 bot["qq_account"] = str(bot["qq_account"]) if bot["qq_account"] > 0 else ""
migrated_any = True migrated_any = True
reasons.append("bot.qq_account_empty") reasons.append("bot.qq_account_int_to_string")
chat = _as_dict(data.get("chat")) chat = _as_dict(data.get("chat"))
if chat is not None and _migrate_chat_talk_value_rules(chat): if chat is not None and _migrate_chat_talk_value_rules(chat):

View File

@@ -4,6 +4,17 @@ import re
from .config_base import ConfigBase, Field from .config_base import ConfigBase, Field
RULE_TYPE_OPTION_DESCRIPTIONS = {
"group": "群聊聊天流item_id 填群号或群聊 ID",
"private": "私聊聊天流item_id 填用户 ID",
}
VISUAL_MODE_OPTION_DESCRIPTIONS = {
"auto": "根据模型信息自动选择文本或多模态模式",
"text": "纯文本模式,不向模型发送视觉输入",
"multimodal": "多模态模式,会向模型发送视觉输入",
}
""" """
须知: 须知:
1. 本文件中记录了所有的配置项 1. 本文件中记录了所有的配置项
@@ -33,8 +44,8 @@ class BotConfig(ConfigBase):
) )
"""平台""" """平台"""
qq_account: int = Field( qq_account: str = Field(
default=0, default="",
json_schema_extra={ json_schema_extra={
"x-widget": "input", "x-widget": "input",
"x-icon": "user", "x-icon": "user",
@@ -141,6 +152,8 @@ class VisualConfig(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "select", "x-widget": "select",
"x-icon": "git-branch", "x-icon": "git-branch",
"x-option-descriptions": VISUAL_MODE_OPTION_DESCRIPTIONS,
"x-row": "visual-modes",
}, },
) )
"""规划器模式auto根据模型信息自动选择text为纯文本模式multimodal为多模态模式""" """规划器模式auto根据模型信息自动选择text为纯文本模式multimodal为多模态模式"""
@@ -150,6 +163,8 @@ class VisualConfig(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "select", "x-widget": "select",
"x-icon": "git-branch", "x-icon": "git-branch",
"x-option-descriptions": VISUAL_MODE_OPTION_DESCRIPTIONS,
"x-row": "visual-modes",
}, },
) )
"""回复器模式auto根据模型信息自动选择text为纯文本模式multimodal为多模态模式""" """回复器模式auto根据模型信息自动选择text为纯文本模式multimodal为多模态模式"""
@@ -162,7 +177,13 @@ class TalkRulesItem(ConfigBase):
item_id: str = "" item_id: str = ""
"""用户ID与平台一起留空表示全局""" """用户ID与平台一起留空表示全局"""
rule_type: Literal["group", "private"] = "group" rule_type: Literal["group", "private"] = Field(
default="group",
json_schema_extra={
"x-widget": "select",
"x-option-descriptions": RULE_TYPE_OPTION_DESCRIPTIONS,
},
)
"""聊天流类型group群聊或private私聊""" """聊天流类型group群聊或private私聊"""
time: str = "" time: str = ""
@@ -185,6 +206,7 @@ class ChatConfig(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "slider", "x-widget": "slider",
"x-icon": "message-circle", "x-icon": "message-circle",
"x-row": "talk-values",
"step": 0.1, "step": 0.1,
}, },
) )
@@ -197,6 +219,7 @@ class ChatConfig(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "slider", "x-widget": "slider",
"x-icon": "message-circle", "x-icon": "message-circle",
"x-row": "talk-values",
"step": 0.1, "step": 0.1,
}, },
) )
@@ -207,11 +230,19 @@ class ChatConfig(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "switch", "x-widget": "switch",
"x-icon": "at-sign", "x-icon": "at-sign",
"x-row": "reply-switches",
}, },
) )
"""是否启用提及必回复""" """是否启用提及必回复"""
inevitable_at_reply: bool = Field(default=True) inevitable_at_reply: bool = Field(
default=True,
json_schema_extra={
"x-widget": "switch",
"x-icon": "at-sign",
"x-row": "reply-switches",
},
)
"""是否启用at必回复""" """是否启用at必回复"""
enable_at: bool = Field( enable_at: bool = Field(
@@ -241,6 +272,7 @@ class ChatConfig(ConfigBase):
"x-icon": "layers", "x-icon": "layers",
"x-layout": "inline-right", "x-layout": "inline-right",
"x-input-width": "12rem", "x-input-width": "12rem",
"x-row": "context-sizes",
}, },
) )
"""上下文长度""" """上下文长度"""
@@ -252,6 +284,7 @@ class ChatConfig(ConfigBase):
"x-icon": "layers", "x-icon": "layers",
"x-layout": "inline-right", "x-layout": "inline-right",
"x-input-width": "12rem", "x-input-width": "12rem",
"x-row": "context-sizes",
}, },
) )
"""私聊上下文长度""" """私聊上下文长度"""
@@ -395,6 +428,7 @@ class TargetItem(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "select", "x-widget": "select",
"x-icon": "users", "x-icon": "users",
"x-option-descriptions": RULE_TYPE_OPTION_DESCRIPTIONS,
}, },
) )
"""聊天流类型group群聊或private私聊""" """聊天流类型group群聊或private私聊"""
@@ -1049,6 +1083,7 @@ class LearningItem(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "select", "x-widget": "select",
"x-icon": "users", "x-icon": "users",
"x-option-descriptions": RULE_TYPE_OPTION_DESCRIPTIONS,
}, },
) )
"""聊天流类型group群聊或private私聊""" """聊天流类型group群聊或private私聊"""
@@ -1751,6 +1786,7 @@ class ExtraPromptItem(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "select", "x-widget": "select",
"x-icon": "users", "x-icon": "users",
"x-option-descriptions": RULE_TYPE_OPTION_DESCRIPTIONS,
}, },
) )
"""聊天流类型group群聊或private私聊""" """聊天流类型group群聊或private私聊"""

View File

@@ -132,6 +132,11 @@ def _toml_to_plain_dict(obj: Any) -> Any:
def _coerce_numeric_value(value: Any, target_type: Any) -> Any: def _coerce_numeric_value(value: Any, target_type: Any) -> Any:
"""根据配置字段类型,把旧 WebUI 可能写入的数字字符串还原为数字。""" """根据配置字段类型,把旧 WebUI 可能写入的数字字符串还原为数字。"""
if target_type is str:
if isinstance(value, (int, float)):
return str(value)
return value
if target_type is int: if target_type is int:
if isinstance(value, str): if isinstance(value, str):
try: try: