🎨 优化角色卡编辑

Signed-off-by: Echo <1711788888@qq.com>
This commit is contained in:
2026-02-28 15:09:53 +08:00
parent 81b552b689
commit a6234e7bb0
8 changed files with 530 additions and 9 deletions

View File

@@ -8,4 +8,5 @@ type ApiGroup struct {
PresetApi
UploadApi
WorldbookApi
RegexScriptApi
}

View File

@@ -88,6 +88,7 @@ func RegisterTables() {
app.AIPreset{},
app.Worldbook{},
app.WorldbookEntry{},
app.RegexScript{},
)
if err != nil {
global.GVA_LOG.Error("register table failed", zap.Error(err))

View File

@@ -154,6 +154,7 @@ func Routers() *gin.Engine {
appRouter.InitPresetRouter(appGroup) // 预设路由:/app/preset/*
appRouter.InitUploadRouter(appGroup) // 上传路由:/app/upload/*
appRouter.InitWorldbookRouter(appGroup) // 世界书路由:/app/worldbook/*
appRouter.InitRegexScriptRouter(appGroup) // 正则脚本路由:/app/regex/*
}
//插件路由安装

View File

@@ -8,4 +8,5 @@ type RouterGroup struct {
PresetRouter
UploadRouter
WorldbookRouter
RegexScriptRouter
}

View File

@@ -267,7 +267,18 @@ func (s *CharacterService) ImportCharacterFromPNG(userID uint, file *multipart.F
IsPublic: false,
}
return s.CreateCharacter(userID, req)
// 创建角色卡
resp, err := s.CreateCharacter(userID, req)
if err != nil {
return nil, err
}
// 处理扩展数据中的正则脚本
if card.Data.Extensions != nil {
s.processRegexScriptsFromExtensions(userID, resp.ID, card.Data.Extensions)
}
return resp, nil
}
// ImportCharacterFromJSON 从 JSON 文件导入角色卡
@@ -312,7 +323,18 @@ func (s *CharacterService) ImportCharacterFromJSON(userID uint, file *multipart.
IsPublic: false,
}
return s.CreateCharacter(userID, req)
// 创建角色卡
resp, err := s.CreateCharacter(userID, req)
if err != nil {
return nil, err
}
// 处理扩展数据中的正则脚本
if card.Data.Extensions != nil {
s.processRegexScriptsFromExtensions(userID, resp.ID, card.Data.Extensions)
}
return resp, nil
}
// ExportCharacterToJSON 导出角色卡为 JSON
@@ -339,6 +361,16 @@ func (s *CharacterService) ExportCharacterToJSON(userID, characterID uint) (*uti
json.Unmarshal(character.CharacterBook, &characterBook)
json.Unmarshal(character.Extensions, &extensions)
// 查询角色关联的正则脚本并添加到 extensions
var regexScripts []app.RegexScript
global.GVA_DB.Where("owner_char_id = ? AND scope = 1", characterID).Find(&regexScripts)
if len(regexScripts) > 0 {
if extensions == nil {
extensions = make(map[string]interface{})
}
extensions["regex_scripts"] = regexScripts
}
// 构建 V2 格式
card := &utils.CharacterCardV2{
Spec: character.Spec,
@@ -364,3 +396,94 @@ func (s *CharacterService) ExportCharacterToJSON(userID, characterID uint) (*uti
return card, nil
}
// processRegexScriptsFromExtensions 从扩展数据中提取并创建正则脚本
func (s *CharacterService) processRegexScriptsFromExtensions(userID, characterID uint, extensions map[string]interface{}) {
// 检查是否包含正则脚本
regexScriptsData, ok := extensions["regex_scripts"]
if !ok {
return
}
// 转换为 JSON 以便解析
scriptsJSON, err := json.Marshal(regexScriptsData)
if err != nil {
global.GVA_LOG.Error("序列化正则脚本失败: " + err.Error())
return
}
// 解析正则脚本数组
var scripts []map[string]interface{}
if err := json.Unmarshal(scriptsJSON, &scripts); err != nil {
global.GVA_LOG.Error("解析正则脚本失败: " + err.Error())
return
}
// 创建正则脚本记录
for _, scriptData := range scripts {
script := app.RegexScript{
UserID: userID,
Scope: 1, // 角色作用域
OwnerCharID: &characterID,
}
// 提取字段
if name, ok := scriptData["name"].(string); ok {
script.Name = name
}
if findRegex, ok := scriptData["findRegex"].(string); ok {
script.FindRegex = findRegex
}
if replaceWith, ok := scriptData["replaceWith"].(string); ok {
script.ReplaceWith = replaceWith
}
if placement, ok := scriptData["placement"].(float64); ok {
script.Placement = int(placement)
}
if disabled, ok := scriptData["disabled"].(bool); ok {
script.Disabled = disabled
}
if markdownOnly, ok := scriptData["markdownOnly"].(bool); ok {
script.MarkdownOnly = markdownOnly
}
if runOnEdit, ok := scriptData["runOnEdit"].(bool); ok {
script.RunOnEdit = runOnEdit
}
if promptOnly, ok := scriptData["promptOnly"].(bool); ok {
script.PromptOnly = promptOnly
}
if substituteRegex, ok := scriptData["substituteRegex"].(bool); ok {
script.SubstituteRegex = substituteRegex
}
if order, ok := scriptData["order"].(float64); ok {
script.Order = int(order)
}
// 处理可选的整数字段
if minDepth, ok := scriptData["minDepth"].(float64); ok {
depth := int(minDepth)
script.MinDepth = &depth
}
if maxDepth, ok := scriptData["maxDepth"].(float64); ok {
depth := int(maxDepth)
script.MaxDepth = &depth
}
// 处理 trimStrings 数组
if trimStrings, ok := scriptData["trimStrings"].([]interface{}); ok {
trimStringsJSON, _ := json.Marshal(trimStrings)
script.TrimStrings = datatypes.JSON(trimStringsJSON)
}
// 处理扩展字段
if scriptExtensions, ok := scriptData["extensions"].(map[string]interface{}); ok {
extensionsJSON, _ := json.Marshal(scriptExtensions)
script.Extensions = datatypes.JSON(extensionsJSON)
}
// 创建记录
if err := global.GVA_DB.Create(&script).Error; err != nil {
global.GVA_LOG.Error("创建正则脚本失败: " + err.Error())
}
}
}

View File

@@ -8,4 +8,5 @@ type AppServiceGroup struct {
PresetService
UploadService
WorldbookService
RegexScriptService
}

View File

@@ -1,6 +1,7 @@
import {Image, Palette, RotateCcw, Save, Upload, X} from 'lucide-react'
import {Image, Palette, RotateCcw, Save, Upload, X, BookOpen} from 'lucide-react'
import {useEffect, useRef, useState} from 'react'
import {type Conversation, conversationApi} from '../api/conversation'
import {type Worldbook, getWorldbookList} from '../api/worldbook'
interface SettingsPanelProps {
conversation: Conversation
@@ -11,6 +12,8 @@ interface SettingsPanelProps {
interface ConversationSettings {
themeColor: string
backgroundImage?: string
worldbookId?: number
worldbookEnabled: boolean
}
const THEME_COLORS = [
@@ -25,13 +28,16 @@ const THEME_COLORS = [
export default function SettingsPanel({ conversation, onClose, onUpdate }: SettingsPanelProps) {
const [settings, setSettings] = useState<ConversationSettings>({
themeColor: '#7C3AED',
worldbookEnabled: false,
})
const [hasChanges, setHasChanges] = useState(false)
const [backgroundPreview, setBackgroundPreview] = useState<string>()
const [worldbooks, setWorldbooks] = useState<Worldbook[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
loadSettings()
loadWorldbooks()
}, [])
const loadSettings = () => {
@@ -44,6 +50,8 @@ export default function SettingsPanel({ conversation, onClose, onUpdate }: Setti
// 合并默认设置和保存的设置
const mergedSettings = {
themeColor: '#7C3AED',
worldbookEnabled: conversation.worldbookEnabled || false,
worldbookId: conversation.worldbookId,
...parsed
}
@@ -60,6 +68,22 @@ export default function SettingsPanel({ conversation, onClose, onUpdate }: Setti
} catch (e) {
console.error('解析设置失败:', e)
}
} else {
// 如果没有 settings从 conversation 直接读取世界书配置
setSettings(prev => ({
...prev,
worldbookEnabled: conversation.worldbookEnabled || false,
worldbookId: conversation.worldbookId,
}))
}
}
const loadWorldbooks = async () => {
try {
const response = await getWorldbookList({ page: 1, pageSize: 100 })
setWorldbooks(response.data.list || [])
} catch (error) {
console.error('加载世界书列表失败:', error)
}
}
@@ -99,7 +123,11 @@ export default function SettingsPanel({ conversation, onClose, onUpdate }: Setti
const handleSave = async () => {
try {
// 保存到后端
await conversationApi.updateConversationSettings(conversation.id, settings)
await conversationApi.updateConversationSettings(conversation.id, {
settings,
worldbookId: settings.worldbookId,
worldbookEnabled: settings.worldbookEnabled,
})
// 应用主题色到根元素
if (settings.themeColor) {
@@ -119,6 +147,7 @@ export default function SettingsPanel({ conversation, onClose, onUpdate }: Setti
const handleReset = () => {
setSettings({
themeColor: '#7C3AED',
worldbookEnabled: false,
})
setBackgroundPreview(undefined)
setHasChanges(false)
@@ -214,10 +243,64 @@ export default function SettingsPanel({ conversation, onClose, onUpdate }: Setti
</div>
</div>
<div className="glass-hover rounded-2xl p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-primary/20 rounded-lg">
<BookOpen className="w-5 h-5 text-primary" />
</div>
<h3 className="font-semibold"></h3>
</div>
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium"></label>
<p className="text-xs text-white/60 mt-1"></p>
</div>
<button
onClick={() => handleChange('worldbookEnabled', !settings.worldbookEnabled)}
className={`relative w-12 h-6 rounded-full transition-colors cursor-pointer ${
settings.worldbookEnabled ? 'bg-primary' : 'bg-white/20'
}`}
>
<div
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
settings.worldbookEnabled ? 'translate-x-6' : ''
}`}
/>
</button>
</div>
{settings.worldbookEnabled && (
<div>
<label className="text-sm text-white/80 mb-3 block"></label>
<select
value={settings.worldbookId || ''}
onChange={(e) => handleChange('worldbookId', e.target.value ? Number(e.target.value) : undefined)}
className="w-full px-4 py-3 glass rounded-xl text-sm bg-white/5 border border-white/10 focus:border-primary/50 focus:outline-none cursor-pointer"
>
<option value=""></option>
{worldbooks.map((wb) => (
<option key={wb.id} value={wb.id}>
{wb.name} ({wb.entryCount} )
</option>
))}
</select>
{worldbooks.length === 0 && (
<p className="text-xs text-white/40 mt-2">
</p>
)}
</div>
)}
</div>
</div>
<div className="glass-hover rounded-2xl p-4">
<div className="text-xs text-white/60 space-y-2">
<p>💡 </p>
<p>💡 </p>
<p>💡 </p>
<p>💡 AI </p>
</div>
</div>

View File

@@ -1,8 +1,9 @@
import {useEffect, useState} from 'react'
import {useNavigate, useSearchParams} from 'react-router-dom'
import Navbar from '../components/Navbar'
import {Book, Download, Edit, FileJson, Image as ImageIcon, Plus, Search, Trash2, X} from 'lucide-react'
import {Book, Code2, Download, Edit, FileJson, Image as ImageIcon, Plus, Search, Trash2, X} from 'lucide-react'
import {type Character, characterApi} from '../api/character'
import {type RegexScript, regexScriptApi} from '../api/regex'
interface WorldBookEntry {
keys: string[]
@@ -32,6 +33,9 @@ export default function CharacterManagePage() {
const [total, setTotal] = useState(0)
const [showWorldBookEditor, setShowWorldBookEditor] = useState(false)
const [worldBookEntries, setWorldBookEntries] = useState<WorldBookEntry[]>([])
const [showRegexScriptEditor, setShowRegexScriptEditor] = useState(false)
const [regexScripts, setRegexScripts] = useState<RegexScript[]>([])
const [editingTab, setEditingTab] = useState<'basic' | 'worldbook' | 'regex'>('basic')
const navigate = useNavigate()
const [searchParams] = useSearchParams()
@@ -47,6 +51,7 @@ export default function CharacterManagePage() {
setSelectedCharacter(char)
setShowEditModal(true)
loadWorldBook(char)
loadRegexScripts(char.id)
}
}
}, [searchParams, characters])
@@ -66,6 +71,35 @@ export default function CharacterManagePage() {
}
}
const loadRegexScripts = async (characterId: number) => {
try {
const response = await regexScriptApi.getRegexScriptList({
scope: 1,
pageSize: 100,
})
console.log('正则脚本 API 响应:', response)
// apiClient 拦截器返回 response.data所以这里的 response 就是后端的完整响应
// 后端返回: {code: 0, data: {list: [...], total: 10, ...}, msg: '获取成功'}
let list = []
if (response.data && response.data.list && Array.isArray(response.data.list)) {
// response.data.list 是正确的路径
list = response.data.list
} else {
console.warn('正则脚本列表数据格式不正确:', response)
setRegexScripts([])
return
}
const charScripts = list.filter((s: any) => s.ownerCharId === characterId)
console.log(`找到 ${charScripts.length} 个角色关联的正则脚本`)
setRegexScripts(charScripts)
} catch (err) {
console.error('加载正则脚本失败:', err)
setRegexScripts([])
}
}
const loadCharacters = async () => {
try {
setLoading(true)
@@ -173,6 +207,7 @@ export default function CharacterManagePage() {
setWorldBookEntries([...worldBookEntries, {
keys: [],
content: '',
comment: '',
enabled: true,
insertion_order: worldBookEntries.length,
position: 'after_char'
@@ -308,6 +343,8 @@ export default function CharacterManagePage() {
onClick={() => {
setSelectedCharacter(char)
setShowEditModal(true)
loadWorldBook(char)
loadRegexScripts(char.id)
}}
className="flex-1 px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer flex items-center justify-center gap-2"
>
@@ -513,14 +550,22 @@ export default function CharacterManagePage() {
<p className="text-xs text-white/40 mt-1">广使</p>
</div>
<div>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setShowWorldBookEditor(true)}
className="w-full px-4 py-3 glass-hover rounded-xl text-sm cursor-pointer flex items-center justify-center gap-2"
className="px-4 py-3 glass-hover rounded-xl text-sm cursor-pointer flex items-center justify-center gap-2"
>
<Book className="w-4 h-4" />
({getWorldBookEntryCount(selectedCharacter)} )
({getWorldBookEntryCount(selectedCharacter)})
</button>
<button
type="button"
onClick={() => setShowRegexScriptEditor(true)}
className="px-4 py-3 glass-hover rounded-xl text-sm cursor-pointer flex items-center justify-center gap-2"
>
<Code2 className="w-4 h-4" />
({regexScripts.length})
</button>
</div>
</form>
@@ -572,7 +617,14 @@ export default function CharacterManagePage() {
worldBookEntries.map((entry, index) => (
<div key={index} className="glass rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium"> #{index + 1}</span>
<div className="flex-1">
<span className="text-sm font-medium">
{entry.comment ? entry.comment : `条目 #${index + 1}`}
</span>
{entry.comment && (
<span className="text-xs text-white/40 ml-2">#{index + 1}</span>
)}
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
@@ -593,6 +645,17 @@ export default function CharacterManagePage() {
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-white/60 mb-1 block"></label>
<input
type="text"
value={entry.comment || ''}
onChange={(e) => updateWorldBookEntry(index, 'comment', e.target.value)}
placeholder="例如: 魔法系统设定"
className="w-full px-3 py-2 glass rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div>
<label className="text-xs text-white/60 mb-1 block"></label>
<input
@@ -672,6 +735,253 @@ export default function CharacterManagePage() {
</div>
</div>
)}
{showRegexScriptEditor && selectedCharacter && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass rounded-3xl p-8 max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold"></h2>
<button
onClick={() => setShowRegexScriptEditor(false)}
className="p-2 glass-hover rounded-lg cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto pr-2 space-y-4">
{regexScripts.length === 0 ? (
<div className="glass rounded-xl p-8 text-center">
<Code2 className="w-12 h-12 mx-auto mb-3 text-white/40" />
<p className="text-white/60 mb-4"></p>
<button
onClick={async () => {
try {
await regexScriptApi.createRegexScript({
name: '新脚本',
findRegex: '',
scope: 1,
ownerCharId: selectedCharacter.id,
})
loadRegexScripts(selectedCharacter.id)
} catch (err: any) {
alert(err.response?.data?.msg || '创建失败')
}
}}
className="px-4 py-2 bg-gradient-to-r from-primary to-secondary rounded-lg text-sm cursor-pointer"
>
</button>
</div>
) : (
regexScripts.map((script) => (
<div key={script.id} className="glass rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<input
type="text"
value={script.name}
onChange={(e) => {
const newScripts = regexScripts.map(s =>
s.id === script.id ? { ...s, name: e.target.value } : s
)
setRegexScripts(newScripts)
}}
className="flex-1 px-3 py-2 glass rounded-lg text-sm font-medium focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
<div className="flex items-center gap-2 ml-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={!script.disabled}
onChange={(e) => {
const newScripts = regexScripts.map(s =>
s.id === script.id ? { ...s, disabled: !e.target.checked } : s
)
setRegexScripts(newScripts)
}}
className="cursor-pointer"
/>
<span className="text-xs text-white/60"></span>
</label>
<button
onClick={async () => {
if (!confirm('确定要删除这个脚本吗?')) return
try {
await regexScriptApi.deleteRegexScript(script.id)
loadRegexScripts(selectedCharacter.id)
} catch (err: any) {
alert(err.response?.data?.msg || '删除失败')
}
}}
className="p-1 glass-hover rounded text-red-400 cursor-pointer"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-white/60 mb-1 block"></label>
<input
type="text"
value={script.findRegex}
onChange={(e) => {
const newScripts = regexScripts.map(s =>
s.id === script.id ? { ...s, findRegex: e.target.value } : s
)
setRegexScripts(newScripts)
}}
placeholder="例如: /\*\*(.+?)\*\*/"
className="w-full px-3 py-2 glass rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div>
<label className="text-xs text-white/60 mb-1 block"></label>
<input
type="text"
value={script.replaceWith}
onChange={(e) => {
const newScripts = regexScripts.map(s =>
s.id === script.id ? { ...s, replaceWith: e.target.value } : s
)
setRegexScripts(newScripts)
}}
placeholder="例如: <strong>$1</strong>"
className="w-full px-3 py-2 glass rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs text-white/60 mb-1 block"></label>
<select
value={script.placement}
onChange={(e) => {
const newScripts = regexScripts.map(s =>
s.id === script.id ? { ...s, placement: parseInt(e.target.value) } : s
)
setRegexScripts(newScripts)
}}
className="w-full px-3 py-2 glass rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="0"></option>
<option value="1"></option>
<option value="2"></option>
<option value="3"></option>
</select>
</div>
<div>
<label className="text-xs text-white/60 mb-1 block"></label>
<input
type="number"
value={script.order}
onChange={(e) => {
const newScripts = regexScripts.map(s =>
s.id === script.id ? { ...s, order: parseInt(e.target.value) } : s
)
setRegexScripts(newScripts)
}}
className="w-full px-3 py-2 glass rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div className="flex flex-col gap-1 pt-5">
<label className="flex items-center gap-2 cursor-pointer text-xs">
<input
type="checkbox"
checked={script.substituteRegex}
onChange={(e) => {
const newScripts = regexScripts.map(s =>
s.id === script.id ? { ...s, substituteRegex: e.target.checked } : s
)
setRegexScripts(newScripts)
}}
className="cursor-pointer"
/>
<span className="text-white/60"></span>
</label>
</div>
</div>
<div className="flex gap-2">
<button
onClick={async () => {
const testInput = prompt('输入测试文本:', '这是一段**测试**文本')
if (!testInput) return
try {
const result = await regexScriptApi.testRegexScript(script.id, testInput)
alert(`原文:${result.data.original}\n\n结果${result.data.result}`)
} catch (err: any) {
alert(err.response?.data?.msg || '测试失败')
}
}}
className="px-3 py-1.5 glass-hover rounded-lg text-xs cursor-pointer"
>
</button>
<button
onClick={async () => {
try {
await regexScriptApi.updateRegexScript(script.id, {
name: script.name,
findRegex: script.findRegex,
replaceWith: script.replaceWith,
placement: script.placement,
disabled: script.disabled,
order: script.order,
substituteRegex: script.substituteRegex,
})
alert('保存成功')
} catch (err: any) {
alert(err.response?.data?.msg || '保存失败')
}
}}
className="px-3 py-1.5 bg-gradient-to-r from-primary to-secondary rounded-lg text-xs cursor-pointer"
>
</button>
</div>
</div>
</div>
))
)}
</div>
<div className="flex gap-3 mt-6 pt-6 border-t border-white/10">
<button
onClick={async () => {
try {
await regexScriptApi.createRegexScript({
name: '新脚本',
findRegex: '',
scope: 1,
ownerCharId: selectedCharacter.id,
})
loadRegexScripts(selectedCharacter.id)
} catch (err: any) {
alert(err.response?.data?.msg || '创建失败')
}
}}
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer flex items-center gap-2"
>
<Plus className="w-4 h-4" />
</button>
<div className="flex-1" />
<button
onClick={() => setShowRegexScriptEditor(false)}
className="px-4 py-2 bg-gradient-to-r from-primary to-secondary rounded-lg text-sm cursor-pointer"
>
</button>
</div>
</div>
</div>
)}
</div>
)
}