新增正则和扩展模块

This commit is contained in:
2026-02-11 23:44:09 +08:00
parent 2bca8e2788
commit 4e611d3a5e
47 changed files with 10058 additions and 49 deletions

View File

@@ -8,10 +8,12 @@
"name": "web-app-vue",
"version": "0.0.0",
"dependencies": {
"@types/uuid": "^10.0.0",
"@vueuse/core": "^14.2.1",
"axios": "^1.13.5",
"element-plus": "^2.13.2",
"pinia": "^3.0.4",
"uuid": "^13.0.0",
"vue": "^3.5.25",
"vue-router": "^4.6.4"
},
@@ -1317,6 +1319,12 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"license": "MIT"
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
@@ -2846,6 +2854,19 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",

View File

@@ -9,10 +9,12 @@
"preview": "vite preview"
},
"dependencies": {
"@types/uuid": "^10.0.0",
"@vueuse/core": "^14.2.1",
"axios": "^1.13.5",
"element-plus": "^2.13.2",
"pinia": "^3.0.4",
"uuid": "^13.0.0",
"vue": "^3.5.25",
"vue-router": "^4.6.4"
},

View File

@@ -0,0 +1,103 @@
import request from '@/utils/request'
import type {
Extension,
ExtensionManifest,
CreateExtensionRequest,
UpdateExtensionRequest,
ExtensionListParams,
ExtensionListResponse,
ToggleExtensionRequest,
UpdateExtensionSettingsRequest,
ExtensionStatsRequest,
} from '@/types/extension'
// 创建/安装扩展
export function createExtension(data: CreateExtensionRequest) {
return request.post<Extension>('/app/extension', data)
}
// 更新扩展
export function updateExtension(id: number, data: UpdateExtensionRequest) {
return request.put(`/app/extension/${id}`, data)
}
// 删除/卸载扩展
export function deleteExtension(id: number, deleteFiles = false) {
return request.delete(`/app/extension/${id}`, { params: { deleteFiles } })
}
// 获取扩展详情
export function getExtension(id: number) {
return request.get<Extension>(`/app/extension/${id}`)
}
// 获取扩展列表
export function getExtensionList(params?: ExtensionListParams) {
return request.get<ExtensionListResponse>('/app/extension/list', { params })
}
// 获取启用的扩展列表
export function getEnabledExtensions() {
return request.get<Extension[]>('/app/extension/enabled')
}
// 启用/禁用扩展
export function toggleExtension(id: number, data: ToggleExtensionRequest) {
return request.post(`/app/extension/${id}/toggle`, data)
}
// 获取扩展配置
export function getExtensionSettings(id: number) {
return request.get<Record<string, any>>(`/app/extension/${id}/settings`)
}
// 更新扩展配置
export function updateExtensionSettings(id: number, data: UpdateExtensionSettingsRequest) {
return request.put(`/app/extension/${id}/settings`, data)
}
// 获取扩展 manifest
export function getExtensionManifest(id: number) {
return request.get<ExtensionManifest>(`/app/extension/${id}/manifest`)
}
// 导入扩展
export function importExtension(file: File) {
const formData = new FormData()
formData.append('file', file)
return request.post<Extension>('/app/extension/import', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
}
// 导出扩展
export function exportExtension(id: number) {
return request.get(`/app/extension/${id}/export`, {
responseType: 'blob',
})
}
// 更新扩展统计
export function updateExtensionStats(data: ExtensionStatsRequest) {
return request.post('/app/extension/stats', data)
}
// 智能安装扩展(自动识别 Git URL 或 Manifest URL兼容 SillyTavern
export function installExtensionFromUrl(url: string, branch = 'main') {
return request.post<Extension>('/app/extension/install/url', { url, branch })
}
// 从 Git URL 安装扩展
export function installExtensionFromGit(gitUrl: string, branch = 'main') {
return request.post<Extension>('/app/extension/install/git', {
gitUrl,
branch,
})
}
// 升级扩展版本(从源重新安装)
export function upgradeExtension(id: number, force = false) {
return request.post<Extension>(`/app/extension/${id}/update`, { force })
}

View File

@@ -0,0 +1,141 @@
import request from '@/utils/request'
import type {
RegexScript,
RegexScriptListParams,
RegexScriptListResponse,
CreateRegexScriptRequest,
UpdateRegexScriptRequest,
TestRegexScriptRequest,
TestRegexScriptResponse,
ApplyRegexScriptsRequest,
ApplyRegexScriptsResponse,
RegexScriptExportData,
} from '@/types/regexScript'
// 创建正则脚本
export function createRegexScript(data: CreateRegexScriptRequest) {
return request<RegexScript>({
url: '/app/regex',
method: 'post',
data,
})
}
// 更新正则脚本
export function updateRegexScript(id: number, data: UpdateRegexScriptRequest) {
return request({
url: `/app/regex/${id}`,
method: 'put',
data,
})
}
// 删除正则脚本
export function deleteRegexScript(id: number) {
return request({
url: `/app/regex/${id}`,
method: 'delete',
})
}
// 获取正则脚本详情
export function getRegexScript(id: number) {
return request<RegexScript>({
url: `/app/regex/${id}`,
method: 'get',
})
}
// 获取正则脚本列表
export function getRegexScriptList(params?: RegexScriptListParams) {
return request<RegexScriptListResponse>({
url: '/app/regex',
method: 'get',
params,
})
}
// 复制正则脚本
export function duplicateRegexScript(id: number) {
return request<RegexScript>({
url: `/app/regex/${id}/duplicate`,
method: 'post',
})
}
// 关联角色到正则脚本
export function linkCharactersToRegex(id: number, characterIds: number[]) {
return request({
url: `/app/regex/${id}/link`,
method: 'post',
data: { characterIds },
})
}
// 获取角色的正则脚本
export function getCharacterRegexScripts(characterId: number) {
return request<RegexScript[]>({
url: `/app/regex/character/${characterId}`,
method: 'get',
})
}
// 测试正则脚本
export function testRegexScript(data: TestRegexScriptRequest) {
return request<TestRegexScriptResponse>({
url: '/app/regex/test',
method: 'post',
data,
})
}
// 应用正则脚本
export function applyRegexScripts(data: ApplyRegexScriptsRequest) {
return request<ApplyRegexScriptsResponse>({
url: '/app/regex/apply',
method: 'post',
data,
})
}
// 导入正则脚本
export function importRegexScripts(file: File, overwriteMode: string = 'skip') {
const formData = new FormData()
formData.append('file', file)
formData.append('overwriteMode', overwriteMode)
return request({
url: '/app/regex/import',
method: 'post',
data: formData,
headers: {
'Content-Type': 'multipart/form-data',
},
})
}
// 导出正则脚本
export function exportRegexScripts(scriptIds?: number[]) {
const params = scriptIds && scriptIds.length > 0 ? { scriptIds: scriptIds.join(',') } : {}
return request<RegexScriptExportData>({
url: '/app/regex/export',
method: 'get',
params,
})
}
// 下载正则脚本JSON 文件)
export function downloadRegexScriptsJSON(scriptIds?: number[]) {
return exportRegexScripts(scriptIds).then((data) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `regex_scripts_${Date.now()}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
})
}

View File

@@ -11,13 +11,19 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
@@ -27,19 +33,25 @@ declare module 'vue' {
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElRow: typeof import('element-plus/es')['ElRow']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElStatistic: typeof import('element-plus/es')['ElStatistic']
ElSlider: typeof import('element-plus/es')['ElSlider']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElUpload: typeof import('element-plus/es')['ElUpload']
ExtensionDrawer: typeof import('./components/ExtensionDrawer.vue')['default']
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']

View File

@@ -0,0 +1,740 @@
<template>
<el-drawer
v-model="drawerVisible"
title="扩展管理"
:size="600"
direction="rtl"
destroy-on-close
>
<div class="extension-drawer-content">
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索扩展..."
clearable
size="small"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<!-- 操作按钮 -->
<div class="action-bar">
<el-button size="small" @click="showInstallDialog = true">
<el-icon><Plus /></el-icon>
安装扩展
</el-button>
<el-button
size="small"
@click="handleUpdateAll"
:disabled="!hasUpdatableExtensions"
>
<el-icon><Refresh /></el-icon>
全部更新
</el-button>
<el-button size="small" @click="handleRefresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
<!-- 扩展折叠面板 -->
<div class="extension-list" v-loading="extensionStore.loading">
<el-empty v-if="filteredExtensions.length === 0" description="暂无扩展" />
<el-collapse v-else v-model="activeNames">
<el-collapse-item
v-for="ext in filteredExtensions"
:key="ext.id"
:name="ext.id"
class="extension-item"
>
<!-- 扩展头部 -->
<template #title>
<div class="extension-header" @click.stop>
<el-switch
v-model="ext.isEnabled"
size="default"
@change="handleToggle(ext)"
style="margin-right: 12px"
/>
<div class="extension-title">
<span class="name">{{ ext.displayName || ext.name }}</span>
<el-tag v-if="ext.isSystemExt" size="small" type="info" style="margin-left: 8px">
系统
</el-tag>
<el-tag :type="getRunningStatus(ext).type" size="small" style="margin-left: 8px">
{{ getRunningStatus(ext).text }}
</el-tag>
</div>
</div>
</template>
<!-- 扩展内容 -->
<div class="extension-content">
<div class="extension-description" v-if="ext.description">
<p>{{ ext.description }}</p>
</div>
<!-- 扩展配置 -->
<el-divider content-position="left">配置</el-divider>
<!-- 自定义配置容器扩展可以在运行时注入 UI -->
<div
:id="`extension-settings-${ext.name}`"
class="extension-custom-settings"
:data-extension-id="ext.id"
:data-extension-name="ext.name"
>
<!-- 扩展的自定义配置 UI 将动态注入到这里 -->
</div>
<!-- 基于 manifest schema 的配置表单兜底方案 -->
<el-form label-position="top" size="small">
<template v-for="(setting, key) in getSettingsSchema(ext)" :key="key">
<!-- Text 输入框 -->
<el-form-item
v-if="setting.type === 'text' || !setting.type"
:label="setting.label || key"
>
<el-input
v-model="extensionSettings[ext.id][key]"
:placeholder="setting.placeholder"
@change="handleSettingChange(ext.id, key)"
/>
<div v-if="setting.description" class="setting-hint">
{{ setting.description }}
</div>
</el-form-item>
<!-- Number 数字输入 -->
<el-form-item
v-else-if="setting.type === 'number'"
:label="setting.label || key"
>
<el-input-number
v-model="extensionSettings[ext.id][key]"
:min="setting.min"
:max="setting.max"
:step="setting.step || 1"
@change="handleSettingChange(ext.id, key)"
style="width: 100%"
/>
<div v-if="setting.description" class="setting-hint">
{{ setting.description }}
</div>
</el-form-item>
<!-- Boolean 开关 -->
<el-form-item
v-else-if="setting.type === 'boolean'"
:label="setting.label || key"
>
<el-switch
v-model="extensionSettings[ext.id][key]"
@change="handleSettingChange(ext.id, key)"
/>
<div v-if="setting.description" class="setting-hint">
{{ setting.description }}
</div>
</el-form-item>
<!-- Select 下拉选择 -->
<el-form-item
v-else-if="setting.type === 'select'"
:label="setting.label || key"
>
<el-select
v-model="extensionSettings[ext.id][key]"
@change="handleSettingChange(ext.id, key)"
style="width: 100%"
>
<el-option
v-for="option in setting.options"
:key="option.value || option"
:label="option.label || option"
:value="option.value || option"
/>
</el-select>
<div v-if="setting.description" class="setting-hint">
{{ setting.description }}
</div>
</el-form-item>
<!-- Textarea 文本域 -->
<el-form-item
v-else-if="setting.type === 'textarea'"
:label="setting.label || key"
>
<el-input
v-model="extensionSettings[ext.id][key]"
type="textarea"
:rows="setting.rows || 3"
:placeholder="setting.placeholder"
@change="handleSettingChange(ext.id, key)"
/>
<div v-if="setting.description" class="setting-hint">
{{ setting.description }}
</div>
</el-form-item>
<!-- Slider 滑块 -->
<el-form-item
v-else-if="setting.type === 'slider'"
:label="setting.label || key"
>
<el-slider
v-model="extensionSettings[ext.id][key]"
:min="setting.min || 0"
:max="setting.max || 100"
:step="setting.step || 1"
:show-input="true"
@change="handleSettingChange(ext.id, key)"
/>
<div v-if="setting.description" class="setting-hint">
{{ setting.description }}
</div>
</el-form-item>
</template>
<!-- 没有配置项 -->
<el-empty
v-if="Object.keys(getSettingsSchema(ext)).length === 0"
description="此扩展没有可配置项"
:image-size="60"
/>
</el-form>
<!-- 操作按钮 -->
<div class="extension-actions">
<el-button
v-if="ext.isEnabled && getRunningStatus(ext).isRunning"
size="small"
type="warning"
@click="handleReload(ext)"
>
<el-icon><RefreshRight /></el-icon>
重新加载
</el-button>
<el-button
v-if="ext.sourceUrl && (ext.installSource === 'git' || ext.installSource === 'url')"
size="small"
type="primary"
@click="handleUpdate(ext)"
>
<el-icon><Refresh /></el-icon>
更新
</el-button>
<el-button
v-if="!ext.isSystemExt"
size="small"
type="danger"
@click="handleDelete(ext)"
>
<el-icon><Delete /></el-icon>
卸载
</el-button>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
</div>
<!-- 安装扩展对话框 -->
<el-dialog
v-model="showInstallDialog"
title="安装扩展"
width="500px"
append-to-body
>
<el-tabs v-model="installTab">
<el-tab-pane label="上传文件" name="file">
<el-upload
drag
:auto-upload="false"
:on-change="handleFileChange"
:show-file-list="true"
:limit="1"
accept=".json"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">
manifest.json 拖到此处<em>点击上传</em>
</div>
</el-upload>
</el-tab-pane>
<el-tab-pane label="URL 安装" name="url">
<el-form :model="installForm" label-width="120px">
<el-form-item label="URL">
<el-input
v-model="installForm.url"
placeholder="Git 仓库 URL 或 manifest.json URL"
>
<template #append>
<el-tooltip content="支持 Git 仓库 URL如 GitHub、GitLab或直接的 manifest.json URL" placement="top">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</template>
</el-input>
</el-form-item>
<el-form-item label="分支(可选)">
<el-input
v-model="installForm.branch"
placeholder="main"
>
<template #append>
<el-tooltip content="Git 仓库的分支名称,默认为 main" placement="top">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</template>
</el-input>
</el-form-item>
</el-form>
<el-alert type="info" :closable="false" show-icon>
<template #title>
<div style="font-size: 12px;">
示例<br />
https://github.com/user/extension.git<br />
https://gitlab.com/user/extension<br />
https://example.com/manifest.json
</div>
</template>
</el-alert>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button @click="showInstallDialog = false">取消</el-button>
<el-button type="primary" @click="handleInstall" :loading="installing">
安装
</el-button>
</template>
</el-dialog>
</el-drawer>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { useExtensionStore } from '@/stores/extension'
import { extensionRuntime } from '@/utils/extensionRuntime'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Search,
Plus,
Refresh,
RefreshRight,
Delete,
UploadFilled,
QuestionFilled,
} from '@element-plus/icons-vue'
import type { Extension } from '@/types/extension'
// Props
interface Props {
visible?: boolean
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
})
// Emits
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
}>()
// Store
const extensionStore = useExtensionStore()
// State
const drawerVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
})
const searchKeyword = ref('')
const activeNames = ref<number[]>([])
const extensionSettings = reactive<Record<number, Record<string, any>>>({})
const showInstallDialog = ref(false)
const installTab = ref('file')
const installing = ref(false)
const selectedFile = ref<File | null>(null)
const installForm = reactive({
url: '',
branch: 'main',
})
// Computed
const filteredExtensions = computed(() => {
let extensions = extensionStore.extensions
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
extensions = extensions.filter(ext =>
(ext.name?.toLowerCase().includes(keyword)) ||
(ext.displayName?.toLowerCase().includes(keyword)) ||
(ext.description?.toLowerCase().includes(keyword))
)
}
return extensions
})
// 是否有可更新的扩展
const hasUpdatableExtensions = computed(() => {
return extensionStore.extensions.some(ext =>
ext.sourceUrl && (ext.installSource === 'git' || ext.installSource === 'url')
)
})
// Methods
const getSettingsSchema = (ext: Extension) => {
try {
const manifest = typeof ext.manifestData === 'string'
? JSON.parse(ext.manifestData)
: ext.manifestData
return manifest?.settings || {}
} catch {
return {}
}
}
const getRunningStatus = (ext: Extension) => {
const instance = extensionRuntime.getInstance(ext.id)
if (!ext.isEnabled) {
return { text: '已禁用', type: 'info', isRunning: false }
}
if (instance?.isRunning) {
return { text: '运行中', type: 'success', isRunning: true }
}
if (instance?.isLoaded) {
return { text: '已加载', type: 'warning', isRunning: false }
}
return { text: '未加载', type: 'danger', isRunning: false }
}
const handleToggle = async (ext: Extension) => {
try {
// 传递当前的启用状态
await extensionStore.toggleExtension(ext.id, ext.isEnabled)
// 根据状态加载/卸载扩展
if (ext.isEnabled) {
const success = await extensionRuntime.enableExtension(ext)
if (!success) {
ext.isEnabled = false
ElMessage.error('启用扩展失败')
} else {
ElMessage.success('扩展已启用并运行')
}
} else {
await extensionRuntime.disableExtension(ext.id)
ElMessage.success('扩展已禁用')
}
} catch (error) {
console.error('切换扩展状态失败:', error)
// 恢复状态
ext.isEnabled = !ext.isEnabled
}
}
const handleSettingChange = async (extId: number, key: string) => {
try {
await extensionStore.updateExtensionSettings(extId, {
[key]: extensionSettings[extId][key],
})
// 通知扩展配置已更改
extensionRuntime.emitEvent('settingsChanged', {
extensionId: extId,
key,
value: extensionSettings[extId][key],
})
} catch (error) {
console.error('保存设置失败:', error)
ElMessage.error('保存设置失败')
}
}
const handleReload = async (ext: Extension) => {
try {
await extensionRuntime.reloadExtension(ext)
ElMessage.success('扩展已重新加载')
} catch (error) {
console.error('重新加载失败:', error)
ElMessage.error('重新加载失败')
}
}
const handleUpdate = async (ext: Extension) => {
try {
await ElMessageBox.confirm(
`确定要更新扩展 "${ext.displayName || ext.name}" 吗?这将从原始安装源重新下载扩展。`,
'确认更新',
{
type: 'info',
confirmButtonText: '更新',
cancelButtonText: '取消',
}
)
// 更新扩展
await extensionStore.upgradeExtension(ext.id)
// 如果扩展正在运行,重新加载
if (ext.isEnabled) {
await extensionRuntime.reloadExtension(ext)
}
ElMessage.success('扩展已更新')
} catch (error) {
if (error !== 'cancel') {
console.error('更新失败:', error)
}
}
}
const handleDelete = async (ext: Extension) => {
try {
await ElMessageBox.confirm(
`确定要卸载扩展 "${ext.displayName || ext.name}" 吗?`,
'确认卸载',
{ type: 'warning' }
)
// 先卸载运行时
await extensionRuntime.unloadExtension(ext.id)
// 再删除数据
await extensionStore.deleteExtension(ext.id)
ElMessage.success('扩展已卸载')
} catch (error) {
if (error !== 'cancel') {
console.error('卸载失败:', error)
}
}
}
const handleRefresh = () => {
extensionStore.fetchExtensionList()
}
// 批量更新所有扩展
const handleUpdateAll = async () => {
try {
await ElMessageBox.confirm(
'确定要更新所有可更新的扩展吗?',
'批量更新',
{
type: 'info',
confirmButtonText: '更新',
cancelButtonText: '取消',
}
)
const updatableExtensions = extensionStore.extensions.filter(ext =>
ext.sourceUrl && (ext.installSource === 'git' || ext.installSource === 'url')
)
if (updatableExtensions.length === 0) {
ElMessage.info('没有可更新的扩展')
return
}
ElMessage.info(`开始更新 ${updatableExtensions.length} 个扩展...`)
let successCount = 0
let failCount = 0
for (const ext of updatableExtensions) {
try {
await extensionStore.upgradeExtension(ext.id)
successCount++
} catch (error) {
console.error(`更新扩展 ${ext.name} 失败:`, error)
failCount++
}
}
if (failCount === 0) {
ElMessage.success(`成功更新 ${successCount} 个扩展`)
} else {
ElMessage.warning(`更新完成:成功 ${successCount} 个,失败 ${failCount}`)
}
// 重新加载所有启用的扩展
await extensionStore.fetchExtensionList()
} catch (error) {
if (error !== 'cancel') {
console.error('批量更新失败:', error)
}
}
}
const handleFileChange = (file: any) => {
selectedFile.value = file.raw
}
const handleInstall = async () => {
try {
installing.value = true
if (installTab.value === 'file') {
if (!selectedFile.value) {
ElMessage.warning('请选择 manifest.json 文件')
return
}
await extensionStore.importExtension(selectedFile.value)
} else {
if (!installForm.url) {
ElMessage.warning('请输入 Manifest URL')
return
}
// 从 URL 安装扩展(支持分支参数)
await installFromURL(installForm.url, installForm.branch || 'main')
}
showInstallDialog.value = false
selectedFile.value = null
installForm.url = ''
installForm.branch = 'main'
} catch (error) {
console.error('安装失败:', error)
} finally {
installing.value = false
}
}
// 从 URL 安装扩展(智能识别 Git URL 或 Manifest URL
const installFromURL = async (url: string, branch = 'main') => {
try {
// 验证 URL 格式
if (!url.startsWith('http://') && !url.startsWith('https://')) {
ElMessage.error('请输入有效的 HTTP(S) URL')
return
}
ElMessage.info('正在安装扩展...')
// 调用后端智能安装 API自动识别 Git URL 或 Manifest URL
await extensionStore.installExtensionFromUrl(url, branch)
ElMessage.success('扩展安装成功')
} catch (error: any) {
console.error('安装失败:', error)
ElMessage.error(error.message || '安装失败')
throw error
}
}
// 监听扩展列表变化,初始化设置
watch(
() => extensionStore.extensions,
(extensions) => {
extensions.forEach(ext => {
if (!extensionSettings[ext.id]) {
extensionSettings[ext.id] = {}
}
// 加载已保存的设置
if (ext.settings) {
const settings = typeof ext.settings === 'string'
? JSON.parse(ext.settings)
: ext.settings
Object.assign(extensionSettings[ext.id], settings)
}
// 如果扩展已启用,自动加载
if (ext.isEnabled && !extensionRuntime.getInstance(ext.id)) {
extensionRuntime.enableExtension(ext)
}
})
},
{ immediate: true, deep: true }
)
// 当抽屉打开时刷新列表
watch(drawerVisible, (visible) => {
if (visible) {
handleRefresh()
}
})
</script>
<style scoped lang="scss">
.extension-drawer-content {
height: 100%;
display: flex;
flex-direction: column;
.search-bar {
margin-bottom: 12px;
}
.action-bar {
margin-bottom: 16px;
display: flex;
gap: 8px;
}
.extension-list {
flex: 1;
overflow-y: auto;
.extension-item {
margin-bottom: 8px;
.extension-header {
display: flex;
align-items: center;
width: 100%;
.extension-title {
flex: 1;
display: flex;
align-items: center;
.name {
font-weight: 500;
}
}
}
.extension-content {
.extension-description {
margin-bottom: 16px;
padding: 12px;
background-color: var(--el-fill-color-light);
border-radius: 4px;
p {
margin: 0;
color: var(--el-text-color-regular);
font-size: 14px;
}
}
.setting-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
.extension-actions {
margin-top: 16px;
display: flex;
gap: 8px;
}
}
}
}
}
</style>

View File

@@ -21,7 +21,26 @@
<el-icon><Files /></el-icon>
<span>我的角色卡</span>
</el-menu-item>
<el-menu-item v-if="authStore.isLoggedIn" index="/worldbook">
<el-icon><Reading /></el-icon>
<span>世界书</span>
</el-menu-item>
<el-menu-item v-if="authStore.isLoggedIn" index="/regex">
<el-icon><MagicStick /></el-icon>
<span>正则脚本</span>
</el-menu-item>
</el-menu>
<!-- 扩展快捷按钮 -->
<el-button
v-if="authStore.isLoggedIn"
text
@click="extensionDrawerVisible = true"
class="extension-menu-button"
>
<el-icon><Connection /></el-icon>
<span>扩展</span>
</el-button>
</div>
<div class="header-right">
@@ -58,25 +77,34 @@
<el-main class="layout-main">
<router-view />
</el-main>
<!-- 全局扩展抽屉 -->
<ExtensionDrawer v-model:visible="extensionDrawerVisible" />
</el-container>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { Grid, Files } from '@element-plus/icons-vue'
import { Grid, Files, Reading, Connection, MagicStick } from '@element-plus/icons-vue'
import ExtensionDrawer from '@/components/ExtensionDrawer.vue'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
// 扩展抽屉状态
const extensionDrawerVisible = ref(false)
// 初始化用户信息
authStore.initUserInfo()
// 当前激活的菜单
const activeMenu = computed(() => {
if (route.path.startsWith('/my-characters')) return '/my-characters'
if (route.path.startsWith('/worldbook')) return '/worldbook'
if (route.path.startsWith('/regex')) return '/regex'
return '/'
})
@@ -138,6 +166,15 @@ function handleCommand(command: string) {
border: none;
background: transparent;
}
.extension-menu-button {
margin-left: 12px;
font-size: 14px;
.el-icon {
margin-right: 4px;
}
}
}
.header-right {

View File

@@ -58,6 +58,54 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/character/Edit.vue'),
meta: { title: '编辑角色卡', requiresAuth: true },
},
{
path: 'worldbook',
name: 'WorldBookList',
component: () => import('@/views/worldbook/WorldBookList.vue'),
meta: { title: '世界书管理', requiresAuth: true },
},
{
path: 'worldbook/create',
name: 'CreateWorldBook',
component: () => import('@/views/worldbook/WorldBookEdit.vue'),
meta: { title: '创建世界书', requiresAuth: true },
},
{
path: 'worldbook/edit/:id',
name: 'EditWorldBook',
component: () => import('@/views/worldbook/WorldBookEdit.vue'),
meta: { title: '编辑世界书', requiresAuth: true },
},
{
path: 'extension',
name: 'ExtensionList',
component: () => import('@/views/extension/ExtensionListNew.vue'),
meta: { title: '扩展管理', requiresAuth: true },
},
{
path: 'extension/settings/:id',
name: 'ExtensionSettings',
component: () => import('@/views/extension/ExtensionSettings.vue'),
meta: { title: '扩展配置', requiresAuth: true },
},
{
path: 'regex',
name: 'RegexScriptList',
component: () => import('@/views/regex/RegexScriptList.vue'),
meta: { title: '正则脚本管理', requiresAuth: true },
},
{
path: 'regex/create',
name: 'CreateRegexScript',
component: () => import('@/views/regex/RegexScriptEdit.vue'),
meta: { title: '创建正则脚本', requiresAuth: true },
},
{
path: 'regex/edit/:id',
name: 'EditRegexScript',
component: () => import('@/views/regex/RegexScriptEdit.vue'),
meta: { title: '编辑正则脚本', requiresAuth: true },
},
],
},
]

View File

@@ -0,0 +1,368 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import * as extensionApi from '@/api/extension'
import type {
Extension,
ExtensionListParams,
CreateExtensionRequest,
UpdateExtensionRequest,
} from '@/types/extension'
export const useExtensionStore = defineStore('extension', () => {
// 状态
const extensions = ref<Extension[]>([])
const enabledExtensions = ref<Extension[]>([])
const currentExtension = ref<Extension | null>(null)
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
const loading = ref(false)
// 过滤条件
const filters = ref<ExtensionListParams>({
extensionType: '',
category: '',
isEnabled: undefined,
tag: '',
})
/**
* 获取扩展列表
*/
const fetchExtensionList = async (params?: ExtensionListParams) => {
try {
loading.value = true
const requestParams: ExtensionListParams = {
page: currentPage.value,
pageSize: pageSize.value,
...filters.value,
...params,
}
const response = await extensionApi.getExtensionList(requestParams)
extensions.value = response.data.list
total.value = response.data.total
currentPage.value = response.data.page
pageSize.value = response.data.pageSize
} catch (error: any) {
console.error('获取扩展列表失败:', error)
ElMessage.error(error.response?.data?.msg || '获取扩展列表失败')
throw error
} finally {
loading.value = false
}
}
/**
* 获取启用的扩展列表
*/
const fetchEnabledExtensions = async () => {
try {
const response = await extensionApi.getEnabledExtensions()
enabledExtensions.value = response.data
return response.data
} catch (error: any) {
console.error('获取启用扩展列表失败:', error)
return []
}
}
/**
* 获取扩展详情
*/
const fetchExtension = async (id: number) => {
try {
loading.value = true
const response = await extensionApi.getExtension(id)
currentExtension.value = response.data
return response.data
} catch (error: any) {
console.error('获取扩展详情失败:', error)
ElMessage.error(error.response?.data?.msg || '获取扩展详情失败')
throw error
} finally {
loading.value = false
}
}
/**
* 创建/安装扩展
*/
const createExtension = async (data: CreateExtensionRequest) => {
try {
loading.value = true
const response = await extensionApi.createExtension(data)
ElMessage.success('安装成功')
await fetchExtensionList()
return response.data
} catch (error: any) {
console.error('创建扩展失败:', error)
ElMessage.error(error.response?.data?.msg || '创建扩展失败')
throw error
} finally {
loading.value = false
}
}
/**
* 更新扩展
*/
const updateExtension = async (id: number, data: UpdateExtensionRequest) => {
try {
loading.value = true
await extensionApi.updateExtension(id, data)
ElMessage.success('更新成功')
await fetchExtensionList()
} catch (error: any) {
console.error('更新扩展失败:', error)
ElMessage.error(error.response?.data?.msg || '更新扩展失败')
throw error
} finally {
loading.value = false
}
}
/**
* 删除/卸载扩展
*/
const deleteExtension = async (id: number, deleteFiles = false) => {
try {
loading.value = true
await extensionApi.deleteExtension(id, deleteFiles)
ElMessage.success('卸载成功')
await fetchExtensionList()
} catch (error: any) {
console.error('删除扩展失败:', error)
ElMessage.error(error.response?.data?.msg || '删除扩展失败')
throw error
} finally {
loading.value = false
}
}
/**
* 启用/禁用扩展
*/
const toggleExtension = async (id: number, isEnabled: boolean) => {
try {
await extensionApi.toggleExtension(id, { isEnabled })
ElMessage.success(isEnabled ? '启用成功' : '禁用成功')
await fetchExtensionList()
await fetchEnabledExtensions()
} catch (error: any) {
console.error('切换扩展状态失败:', error)
ElMessage.error(error.response?.data?.msg || '操作失败')
throw error
}
}
/**
* 获取扩展配置
*/
const getExtensionSettings = async (id: number) => {
try {
const response = await extensionApi.getExtensionSettings(id)
return response.data
} catch (error: any) {
console.error('获取扩展配置失败:', error)
ElMessage.error(error.response?.data?.msg || '获取扩展配置失败')
throw error
}
}
/**
* 更新扩展配置
*/
const updateExtensionSettings = async (id: number, settings: Record<string, any>) => {
try {
await extensionApi.updateExtensionSettings(id, { settings })
ElMessage.success('配置更新成功')
} catch (error: any) {
console.error('更新扩展配置失败:', error)
ElMessage.error(error.response?.data?.msg || '更新配置失败')
throw error
}
}
/**
* 获取扩展 manifest
*/
const getExtensionManifest = async (id: number) => {
try {
const response = await extensionApi.getExtensionManifest(id)
return response.data
} catch (error: any) {
console.error('获取扩展 manifest 失败:', error)
throw error
}
}
/**
* 导入扩展
*/
const importExtension = async (file: File) => {
try {
loading.value = true
const response = await extensionApi.importExtension(file)
ElMessage.success('导入成功')
await fetchExtensionList()
return response.data
} catch (error: any) {
console.error('导入扩展失败:', error)
ElMessage.error(error.response?.data?.msg || '导入扩展失败')
throw error
} finally {
loading.value = false
}
}
/**
* 导出扩展
*/
const exportExtension = async (id: number, name: string) => {
try {
const response = await extensionApi.exportExtension(id)
// 创建下载链接
const blob = new Blob([response.data], { type: 'application/json' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${name}_manifest.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (error: any) {
console.error('导出扩展失败:', error)
ElMessage.error(error.response?.data?.msg || '导出扩展失败')
throw error
}
}
/**
* 智能安装扩展(自动识别 Git URL 或 Manifest URL
*/
const installExtensionFromUrl = async (url: string, branch = 'main') => {
try {
loading.value = true
const response = await extensionApi.installExtensionFromUrl(url, branch)
ElMessage.success('安装成功')
await fetchExtensionList()
return response.data
} catch (error: any) {
console.error('安装扩展失败:', error)
ElMessage.error(error.response?.data?.msg || error.message || '安装扩展失败')
throw error
} finally {
loading.value = false
}
}
/**
* 从 Git URL 安装扩展
*/
const installExtensionFromGit = async (gitUrl: string, branch = 'main') => {
try {
loading.value = true
const response = await extensionApi.installExtensionFromGit(gitUrl, branch)
ElMessage.success('安装成功')
await fetchExtensionList()
return response.data
} catch (error: any) {
console.error('从 Git 安装扩展失败:', error)
ElMessage.error(error.response?.data?.msg || '安装扩展失败')
throw error
} finally {
loading.value = false
}
}
/**
* 升级扩展版本(从源重新安装)
*/
const upgradeExtension = async (id: number, force = false) => {
try {
loading.value = true
const response = await extensionApi.upgradeExtension(id, force)
ElMessage.success('升级成功')
await fetchExtensionList()
return response.data
} catch (error: any) {
console.error('升级扩展失败:', error)
ElMessage.error(error.response?.data?.msg || '升级扩展失败')
throw error
} finally {
loading.value = false
}
}
/**
* 更新扩展统计
*/
const updateStats = async (extensionId: number, action: 'usage' | 'error' | 'load', value = 1) => {
try {
await extensionApi.updateExtensionStats({ extensionId, action, value })
} catch (error: any) {
console.error('更新扩展统计失败:', error)
}
}
/**
* 设置过滤条件
*/
const setFilters = (newFilters: Partial<ExtensionListParams>) => {
filters.value = { ...filters.value, ...newFilters }
currentPage.value = 1
fetchExtensionList()
}
/**
* 重置过滤条件
*/
const resetFilters = () => {
filters.value = {
extensionType: '',
category: '',
isEnabled: undefined,
tag: '',
}
currentPage.value = 1
fetchExtensionList()
}
return {
// 状态
extensions,
enabledExtensions,
currentExtension,
total,
currentPage,
pageSize,
loading,
filters,
// 操作
fetchExtensionList,
fetchEnabledExtensions,
fetchExtension,
createExtension,
updateExtension,
deleteExtension,
toggleExtension,
getExtensionSettings,
updateExtensionSettings,
getExtensionManifest,
importExtension,
exportExtension,
installExtensionFromUrl,
installExtensionFromGit,
upgradeExtension,
updateStats,
setFilters,
resetFilters,
}
})

View File

@@ -0,0 +1,229 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import * as regexScriptApi from '@/api/regexScript'
import type {
RegexScript,
RegexScriptListParams,
CreateRegexScriptRequest,
UpdateRegexScriptRequest,
TestRegexScriptRequest,
} from '@/types/regexScript'
export const useRegexScriptStore = defineStore('regexScript', () => {
// 状态
const regexScripts = ref<RegexScript[]>([])
const currentRegexScript = ref<RegexScript | null>(null)
const loading = ref(false)
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
// 获取正则脚本列表
const fetchRegexScriptList = async (params?: RegexScriptListParams) => {
try {
loading.value = true
const requestParams: RegexScriptListParams = {
page: params?.page || currentPage.value,
pageSize: params?.pageSize || pageSize.value,
...params,
}
const response = await regexScriptApi.getRegexScriptList(requestParams)
regexScripts.value = response.data.list
total.value = response.data.total
currentPage.value = response.data.page
pageSize.value = response.data.pageSize
} catch (error: any) {
console.error('获取正则脚本列表失败:', error)
ElMessage.error(error.response?.data?.msg || '获取正则脚本列表失败')
throw error
} finally {
loading.value = false
}
}
// 获取正则脚本详情
const fetchRegexScriptDetail = async (id: number): Promise<RegexScript> => {
try {
loading.value = true
const response = await regexScriptApi.getRegexScript(id)
currentRegexScript.value = response.data
return response.data
} catch (error: any) {
console.error('获取正则脚本详情失败:', error)
ElMessage.error(error.response?.data?.msg || '获取正则脚本详情失败')
throw error
} finally {
loading.value = false
}
}
// 创建正则脚本
const createRegexScript = async (data: CreateRegexScriptRequest): Promise<RegexScript> => {
try {
loading.value = true
const response = await regexScriptApi.createRegexScript(data)
ElMessage.success('创建成功')
await fetchRegexScriptList()
return response.data
} catch (error: any) {
console.error('创建正则脚本失败:', error)
ElMessage.error(error.response?.data?.msg || '创建正则脚本失败')
throw error
} finally {
loading.value = false
}
}
// 更新正则脚本
const updateRegexScript = async (id: number, data: UpdateRegexScriptRequest) => {
try {
loading.value = true
await regexScriptApi.updateRegexScript(id, data)
ElMessage.success('更新成功')
await fetchRegexScriptList()
} catch (error: any) {
console.error('更新正则脚本失败:', error)
ElMessage.error(error.response?.data?.msg || '更新正则脚本失败')
throw error
} finally {
loading.value = false
}
}
// 删除正则脚本
const deleteRegexScript = async (id: number) => {
try {
loading.value = true
await regexScriptApi.deleteRegexScript(id)
ElMessage.success('删除成功')
await fetchRegexScriptList()
} catch (error: any) {
console.error('删除正则脚本失败:', error)
ElMessage.error(error.response?.data?.msg || '删除正则脚本失败')
throw error
} finally {
loading.value = false
}
}
// 复制正则脚本
const duplicateRegexScript = async (id: number): Promise<RegexScript> => {
try {
loading.value = true
const response = await regexScriptApi.duplicateRegexScript(id)
ElMessage.success('复制成功')
await fetchRegexScriptList()
return response.data
} catch (error: any) {
console.error('复制正则脚本失败:', error)
ElMessage.error(error.response?.data?.msg || '复制正则脚本失败')
throw error
} finally {
loading.value = false
}
}
// 关联角色到脚本
const linkCharacters = async (id: number, characterIds: number[]) => {
try {
loading.value = true
await regexScriptApi.linkCharactersToRegex(id, characterIds)
ElMessage.success('关联成功')
await fetchRegexScriptDetail(id)
} catch (error: any) {
console.error('关联角色失败:', error)
ElMessage.error(error.response?.data?.msg || '关联角色失败')
throw error
} finally {
loading.value = false
}
}
// 获取角色的脚本
const fetchCharacterRegexScripts = async (characterId: number): Promise<RegexScript[]> => {
try {
loading.value = true
const response = await regexScriptApi.getCharacterRegexScripts(characterId)
return response.data
} catch (error: any) {
console.error('获取角色脚本失败:', error)
ElMessage.error(error.response?.data?.msg || '获取角色脚本失败')
throw error
} finally {
loading.value = false
}
}
// 测试正则脚本
const testRegexScript = async (data: TestRegexScriptRequest) => {
try {
const response = await regexScriptApi.testRegexScript(data)
return response.data
} catch (error: any) {
console.error('测试正则脚本失败:', error)
ElMessage.error(error.response?.data?.msg || '测试正则脚本失败')
throw error
}
}
// 导入正则脚本
const importRegexScripts = async (file: File, overwriteMode: string = 'skip') => {
try {
loading.value = true
await regexScriptApi.importRegexScripts(file, overwriteMode)
ElMessage.success('导入成功')
await fetchRegexScriptList()
} catch (error: any) {
console.error('导入正则脚本失败:', error)
ElMessage.error(error.response?.data?.msg || '导入正则脚本失败')
throw error
} finally {
loading.value = false
}
}
// 导出正则脚本
const exportRegexScripts = async (scriptIds?: number[]) => {
try {
await regexScriptApi.downloadRegexScriptsJSON(scriptIds)
ElMessage.success('导出成功')
} catch (error: any) {
console.error('导出正则脚本失败:', error)
ElMessage.error(error.response?.data?.msg || '导出正则脚本失败')
throw error
}
}
// 重置分页
const resetPagination = () => {
currentPage.value = 1
pageSize.value = 20
}
return {
// 状态
regexScripts,
currentRegexScript,
loading,
total,
currentPage,
pageSize,
// 方法
fetchRegexScriptList,
fetchRegexScriptDetail,
createRegexScript,
updateRegexScript,
deleteRegexScript,
duplicateRegexScript,
linkCharacters,
fetchCharacterRegexScripts,
testRegexScript,
importRegexScripts,
exportRegexScripts,
resetPagination,
}
})

View File

@@ -35,10 +35,10 @@ export const useWorldInfoStore = defineStore('worldInfo', () => {
}
const response = await worldInfoApi.getWorldBookList(requestParams)
worldBooks.value = response.data.data.list
total.value = response.data.data.total
currentPage.value = response.data.data.page
pageSize.value = response.data.data.pageSize
worldBooks.value = response.data.list
total.value = response.data.total
currentPage.value = response.data.page
pageSize.value = response.data.pageSize
} catch (error: any) {
console.error('获取世界书列表失败:', error)
ElMessage.error(error.response?.data?.msg || '获取世界书列表失败')
@@ -53,8 +53,8 @@ export const useWorldInfoStore = defineStore('worldInfo', () => {
try {
loading.value = true
const response = await worldInfoApi.getWorldBook(id)
currentWorldBook.value = response.data.data
return response.data.data
currentWorldBook.value = response.data
return response.data
} catch (error: any) {
console.error('获取世界书详情失败:', error)
ElMessage.error(error.response?.data?.msg || '获取世界书详情失败')
@@ -71,7 +71,7 @@ export const useWorldInfoStore = defineStore('worldInfo', () => {
const response = await worldInfoApi.createWorldBook(data)
ElMessage.success('创建成功')
await fetchWorldBookList()
return response.data.data
return response.data
} catch (error: any) {
console.error('创建世界书失败:', error)
ElMessage.error(error.response?.data?.msg || '创建世界书失败')
@@ -120,7 +120,7 @@ export const useWorldInfoStore = defineStore('worldInfo', () => {
const response = await worldInfoApi.duplicateWorldBook(id)
ElMessage.success('复制成功')
await fetchWorldBookList()
return response.data.data
return response.data
} catch (error: any) {
console.error('复制世界书失败:', error)
ElMessage.error(error.response?.data?.msg || '复制世界书失败')
@@ -207,8 +207,8 @@ export const useWorldInfoStore = defineStore('worldInfo', () => {
try {
loading.value = true
const response = await worldInfoApi.getCharacterWorldBooks(characterId)
characterWorldBooks.value = response.data.data
return response.data.data
characterWorldBooks.value = response.data
return response.data
} catch (error: any) {
console.error('获取角色世界书失败:', error)
ElMessage.error(error.response?.data?.msg || '获取角色世界书失败')
@@ -225,7 +225,7 @@ export const useWorldInfoStore = defineStore('worldInfo', () => {
const response = await worldInfoApi.importWorldBook(file, bookName)
ElMessage.success('导入成功')
await fetchWorldBookList()
return response.data.data
return response.data
} catch (error: any) {
console.error('导入世界书失败:', error)
ElMessage.error(error.response?.data?.msg || '导入世界书失败')
@@ -254,7 +254,7 @@ export const useWorldInfoStore = defineStore('worldInfo', () => {
const matchWorldInfo = async (params: MatchWorldInfoRequest): Promise<MatchedWorldInfoEntry[]> => {
try {
const response = await worldInfoApi.matchWorldInfo(params)
return response.data.data.entries
return response.data.entries
} catch (error: any) {
console.error('匹配世界书失败:', error)
return []

129
web-app-vue/src/types/extension.d.ts vendored Normal file
View File

@@ -0,0 +1,129 @@
// 扩展类型定义(兼容 SillyTavern Extension 规范)
export interface Extension {
id: number
userId: number
name: string
displayName: string
version: string
author: string
description: string
homepage: string
repository: string
license: string
tags: string[]
extensionType: 'ui' | 'server' | 'hybrid'
category: 'utilities' | 'themes' | 'integrations' | 'tools' | string
dependencies: Record<string, string>
conflicts: string[]
manifestData: Record<string, any>
scriptPath: string
stylePath: string
assetsPaths: string[]
settings: Record<string, any>
options: Record<string, any>
isEnabled: boolean
isInstalled: boolean
isSystemExt: boolean
installSource: 'url' | 'git' | 'file' | 'marketplace'
sourceUrl: string // 原始安装 URL用于更新
branch: string // Git 分支
autoUpdate: boolean // 是否自动更新
lastUpdateCheck?: string // 最后检查更新时间
availableVersion?: string // 可用的新版本
installDate: string
lastEnabled: string
usageCount: number
errorCount: number
loadTime: number
metadata: Record<string, any>
createdAt: string
updatedAt: string
}
export interface ExtensionManifest {
name: string
display_name?: string
version: string
description: string
author: string
homepage?: string
repository?: string
license?: string
tags?: string[]
type?: 'ui' | 'server' | 'hybrid'
category?: string
dependencies?: Record<string, string>
conflicts?: string[]
entry?: string
style?: string
assets?: string[]
settings?: Record<string, any>
options?: Record<string, any>
metadata?: Record<string, any>
}
export interface CreateExtensionRequest {
name: string
displayName?: string
version?: string
author?: string
description?: string
homepage?: string
repository?: string
license?: string
tags?: string[]
extensionType: 'ui' | 'server' | 'hybrid'
category?: string
dependencies?: Record<string, string>
conflicts?: string[]
manifestData: Record<string, any>
scriptPath?: string
stylePath?: string
assetsPaths?: string[]
settings?: Record<string, any>
options?: Record<string, any>
installSource?: string
metadata?: Record<string, any>
}
export interface UpdateExtensionRequest {
displayName?: string
description?: string
settings?: Record<string, any>
options?: Record<string, any>
metadata?: Record<string, any>
}
export interface ExtensionListParams {
page?: number
pageSize?: number
keyword?: string
name?: string
extensionType?: string
category?: string
isEnabled?: boolean
isInstalled?: boolean
tag?: string
}
export interface ExtensionListResponse {
list: Extension[]
total: number
page: number
pageSize: number
}
export interface ToggleExtensionRequest {
isEnabled: boolean
}
export interface UpdateExtensionSettingsRequest {
settings: Record<string, any>
}
export interface ExtensionStatsRequest {
extensionId: number
action: 'usage' | 'error' | 'load'
value?: number
}

123
web-app-vue/src/types/regexScript.d.ts vendored Normal file
View File

@@ -0,0 +1,123 @@
// 正则脚本类型定义
export interface RegexScript {
id: number
userId: number
scriptName: string
description: string
findRegex: string
replaceString: string
enabled: boolean
isGlobal: boolean
trimStrings: boolean
onlyFormat: boolean
runOnEdit: boolean
substituteRegex: boolean
minDepth?: number
maxDepth?: number
placement: string
affectMinDepth?: number
affectMaxDepth?: number
linkedChars: string[]
scriptData?: Record<string, any>
usageCount: number
lastUsedAt?: number
createdAt: number
updatedAt: number
}
export interface RegexScriptListParams {
page?: number
pageSize?: number
scriptName?: string
isGlobal?: boolean
enabled?: boolean
characterId?: number
}
export interface RegexScriptListResponse {
list: RegexScript[]
total: number
page: number
pageSize: number
}
export interface CreateRegexScriptRequest {
scriptName: string
description?: string
findRegex: string
replaceString?: string
enabled?: boolean
isGlobal?: boolean
trimStrings?: boolean
onlyFormat?: boolean
runOnEdit?: boolean
substituteRegex?: boolean
minDepth?: number
maxDepth?: number
placement?: string
affectMinDepth?: number
affectMaxDepth?: number
linkedChars?: string[]
scriptData?: Record<string, any>
}
export interface UpdateRegexScriptRequest {
scriptName?: string
description?: string
findRegex?: string
replaceString?: string
enabled?: boolean
isGlobal?: boolean
trimStrings?: boolean
onlyFormat?: boolean
runOnEdit?: boolean
substituteRegex?: boolean
minDepth?: number
maxDepth?: number
placement?: string
affectMinDepth?: number
affectMaxDepth?: number
linkedChars?: string[]
scriptData?: Record<string, any>
}
export interface TestRegexScriptRequest {
findRegex: string
replaceString?: string
testInput: string
trimStrings?: boolean
substituteRegex?: boolean
}
export interface TestRegexScriptResponse {
success: boolean
input: string
output: string
matchedCount: number
matches: string[]
error?: string
}
export interface ApplyRegexScriptsRequest {
text: string
regexIds?: number[]
characterId?: number
placement?: string
minDepth?: number
maxDepth?: number
useGlobal?: boolean
}
export interface ApplyRegexScriptsResponse {
originalText: string
processedText: string
appliedCount: number
appliedScripts: number[]
}
export interface RegexScriptExportData {
version: string
scripts: RegexScript[]
exportedAt: number
}

View File

@@ -0,0 +1,488 @@
/**
* SillyTavern 扩展运行时环境
* 负责加载、运行和管理扩展的生命周期
*/
import { ElMessage } from 'element-plus'
import type { Extension } from '@/types/extension'
interface ExtensionInstance {
extension: Extension
manifest: any
scriptElement?: HTMLScriptElement
styleElement?: HTMLLinkElement
isLoaded: boolean
isRunning: boolean
api?: any
}
class ExtensionRuntime {
private instances: Map<number, ExtensionInstance> = new Map()
private stAPI: any = null
// 扩展设置存储(兼容原版 SillyTavern 的 extension_settings
private extensionSettings: Record<string, any> = {}
constructor() {
this.initSillyTavernAPI()
}
/**
* 获取扩展的配置容器 DOM 元素
*/
private getExtensionSettingsContainer(extensionName: string): HTMLElement | null {
return document.getElementById(`extension-settings-${extensionName}`)
}
/**
* 加载扩展的已保存设置
*/
private loadExtensionSettings(extensionName: string): any {
if (!this.extensionSettings[extensionName]) {
const key = `ext_settings_${extensionName}`
const saved = localStorage.getItem(key)
if (saved) {
try {
this.extensionSettings[extensionName] = JSON.parse(saved)
} catch (e) {
console.error(`[ExtensionRuntime] 加载设置失败: ${extensionName}`, e)
this.extensionSettings[extensionName] = {}
}
} else {
this.extensionSettings[extensionName] = {}
}
}
return this.extensionSettings[extensionName]
}
/**
* 保存扩展设置到 localStorage
*/
private saveExtensionSettingsToStorage(extensionName: string) {
const key = `ext_settings_${extensionName}`
try {
localStorage.setItem(key, JSON.stringify(this.extensionSettings[extensionName] || {}))
} catch (e) {
console.error(`[ExtensionRuntime] 保存设置失败: ${extensionName}`, e)
}
}
/**
* 初始化 SillyTavern API兼容层
*/
private initSillyTavernAPI() {
// 创建全局 SillyTavern API 对象
this.stAPI = {
// 扩展管理
extensions: {
register: (name: string, init: Function) => {
console.log(`[Extension] 注册扩展: ${name}`)
// 执行扩展的初始化函数
try {
init()
} catch (error) {
console.error(`[Extension] 初始化失败: ${name}`, error)
}
},
// 获取扩展设置
getSettings: (extName: string) => {
// 从 localStorage 或 store 获取设置
const settings = localStorage.getItem(`ext_settings_${extName}`)
return settings ? JSON.parse(settings) : {}
},
// 保存扩展设置
saveSettings: (extName: string, settings: any) => {
localStorage.setItem(`ext_settings_${extName}`, JSON.stringify(settings))
// 触发设置变更事件
this.emitEvent('extensionSettingsLoaded', { name: extName })
},
// 获取扩展列表
list: () => {
return Array.from(this.instances.values()).map(inst => ({
name: inst.extension.name,
displayName: inst.extension.displayName,
version: inst.extension.version,
enabled: inst.extension.isEnabled,
loaded: inst.isLoaded,
running: inst.isRunning,
}))
},
},
// UI 工具
ui: {
// 创建设置面板
createSettings: (title: string, content: HTMLElement) => {
console.log(`[Extension] 创建设置面板: ${title}`)
// 可以在这里创建一个设置面板并添加到页面
},
// 显示通知
notify: (message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info') => {
ElMessage[type](message)
},
// 添加 UI 元素到指定位置
addElement: (element: HTMLElement, targetSelector: string) => {
const target = document.querySelector(targetSelector)
if (target) {
target.appendChild(element)
}
},
// 创建按钮
createButton: (text: string, onClick: Function) => {
const button = document.createElement('button')
button.textContent = text
button.onclick = onClick as any
return button
},
},
// 事件系统
eventSource: {
on: (event: string, callback: Function) => {
window.addEventListener(`st:${event}`, (e: any) => callback(e.detail))
},
once: (event: string, callback: Function) => {
const handler = (e: any) => {
callback(e.detail)
window.removeEventListener(`st:${event}`, handler)
}
window.addEventListener(`st:${event}`, handler)
},
off: (event: string, callback?: Function) => {
if (callback) {
window.removeEventListener(`st:${event}`, callback as any)
}
},
emit: (event: string, data?: any) => {
window.dispatchEvent(new CustomEvent(`st:${event}`, { detail: data }))
},
},
// 获取当前上下文
getContext: () => {
// 尝试从路由或 store 获取当前上下文
const context = {
characterId: null as number | null,
chatId: null as number | null,
groupId: null as number | null,
userName: '',
characterName: '',
// extension_settings 对象(兼容原版 SillyTavern
extension_settings: new Proxy(this.extensionSettings, {
get: (target, prop: string) => {
if (typeof prop === 'string') {
// 自动加载设置(如果还没加载)
this.loadExtensionSettings(prop)
return target[prop] || {}
}
return undefined
},
set: (target, prop: string, value) => {
if (typeof prop === 'string') {
target[prop] = value
// 自动保存到 localStorage
this.saveExtensionSettingsToStorage(prop)
return true
}
return false
},
}),
// 获取扩展配置容器的方法
getExtensionSettingsContainer: (extensionName: string) => {
return this.getExtensionSettingsContainer(extensionName)
},
}
// 可以从实际应用状态获取
try {
const route = (window as any).$route
if (route) {
context.characterId = route.params.characterId ? parseInt(route.params.characterId) : null
context.chatId = route.params.chatId ? parseInt(route.params.chatId) : null
}
} catch (e) {
// ignore
}
return context
},
// 工具函数
utils: {
// 延迟执行
delay: (ms: number) => new Promise(resolve => setTimeout(resolve, ms)),
// 生成唯一 ID
generateId: () => `ext_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
// 安全解析 JSON
parseJSON: (str: string, fallback: any = null) => {
try {
return JSON.parse(str)
} catch {
return fallback
}
},
},
// 存储工具
storage: {
get: (key: string, defaultValue: any = null) => {
const value = localStorage.getItem(`st_${key}`)
return value ? JSON.parse(value) : defaultValue
},
set: (key: string, value: any) => {
localStorage.setItem(`st_${key}`, JSON.stringify(value))
},
remove: (key: string) => {
localStorage.removeItem(`st_${key}`)
},
clear: () => {
const keys = Object.keys(localStorage).filter(k => k.startsWith('st_'))
keys.forEach(k => localStorage.removeItem(k))
},
},
}
// 挂载到 window 对象
;(window as any).SillyTavern = this.stAPI
;(window as any).st = this.stAPI // 简写别名
// 触发 API 初始化完成事件
this.emitEvent('apiReady')
}
/**
* 加载扩展
*/
async loadExtension(extension: Extension): Promise<boolean> {
try {
console.log(`[Extension] 加载扩展: ${extension.name}`)
// 检查是否已加载
if (this.instances.has(extension.id) && this.instances.get(extension.id)?.isLoaded) {
console.log(`[Extension] 扩展已加载: ${extension.name}`)
return true
}
// 解析 manifest
const manifest = typeof extension.manifestData === 'string'
? JSON.parse(extension.manifestData)
: extension.manifestData
const instance: ExtensionInstance = {
extension,
manifest,
isLoaded: false,
isRunning: false,
}
// 加载样式文件
if (extension.stylePath) {
await this.loadStyle(extension, instance)
}
// 加载脚本文件
if (extension.scriptPath) {
await this.loadScript(extension, instance)
} else if (manifest.inline_script) {
// 支持内联脚本
await this.loadInlineScript(extension, instance, manifest.inline_script)
}
instance.isLoaded = true
this.instances.set(extension.id, instance)
console.log(`[Extension] 加载成功: ${extension.name}`)
return true
} catch (error) {
console.error(`[Extension] 加载失败: ${extension.name}`, error)
ElMessage.error(`加载扩展失败: ${extension.name}`)
return false
}
}
/**
* 加载扩展样式
*/
private async loadStyle(extension: Extension, instance: ExtensionInstance): Promise<void> {
return new Promise((resolve, reject) => {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = this.getAssetURL(extension.stylePath!)
link.dataset.extension = extension.name
link.onload = () => {
instance.styleElement = link
resolve()
}
link.onerror = () => reject(new Error(`样式加载失败: ${extension.stylePath}`))
document.head.appendChild(link)
})
}
/**
* 加载扩展脚本
*/
private async loadScript(extension: Extension, instance: ExtensionInstance): Promise<void> {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = this.getAssetURL(extension.scriptPath!)
script.dataset.extension = extension.name
script.async = true
script.onload = () => {
instance.scriptElement = script
instance.isRunning = true
resolve()
}
script.onerror = () => reject(new Error(`脚本加载失败: ${extension.scriptPath}`))
document.body.appendChild(script)
})
}
/**
* 加载内联脚本
*/
private async loadInlineScript(extension: Extension, instance: ExtensionInstance, scriptCode: string): Promise<void> {
return new Promise((resolve, reject) => {
try {
const script = document.createElement('script')
script.textContent = scriptCode
script.dataset.extension = extension.name
script.dataset.inline = 'true'
document.body.appendChild(script)
instance.scriptElement = script
instance.isRunning = true
resolve()
} catch (error) {
reject(new Error(`内联脚本执行失败: ${error}`))
}
})
}
/**
* 卸载扩展
*/
async unloadExtension(extensionId: number): Promise<boolean> {
const instance = this.instances.get(extensionId)
if (!instance) {
return false
}
try {
console.log(`[Extension] 卸载扩展: ${instance.extension.name}`)
// 移除样式
if (instance.styleElement) {
instance.styleElement.remove()
}
// 移除脚本
if (instance.scriptElement) {
instance.scriptElement.remove()
}
instance.isLoaded = false
instance.isRunning = false
this.instances.delete(extensionId)
console.log(`[Extension] 卸载成功: ${instance.extension.name}`)
return true
} catch (error) {
console.error(`[Extension] 卸载失败: ${instance.extension.name}`, error)
return false
}
}
/**
* 启用扩展
*/
async enableExtension(extension: Extension): Promise<boolean> {
if (!extension.isEnabled) {
return false
}
return await this.loadExtension(extension)
}
/**
* 禁用扩展
*/
async disableExtension(extensionId: number): Promise<boolean> {
return await this.unloadExtension(extensionId)
}
/**
* 重新加载扩展
*/
async reloadExtension(extension: Extension): Promise<boolean> {
await this.unloadExtension(extension.id)
return await this.loadExtension(extension)
}
/**
* 获取资源 URL
* 这里需要根据实际情况处理:
* 1. 如果扩展文件存储在服务器上,需要通过 API 获取
* 2. 如果是 CDN直接使用 URL
* 3. 如果是 base64 编码,需要转换
*/
private getAssetURL(path: string): string {
// 如果是完整 URL直接返回
if (path.startsWith('http://') || path.startsWith('https://')) {
return path
}
// 如果是相对路径,需要根据实际部署情况构建完整 URL
// 这里假设扩展文件存储在 /api/extension/assets/ 路径下
return `/api/app/extension/assets/${path}`
}
/**
* 获取扩展实例
*/
getInstance(extensionId: number): ExtensionInstance | undefined {
return this.instances.get(extensionId)
}
/**
* 获取所有已加载的扩展
*/
getLoadedExtensions(): ExtensionInstance[] {
return Array.from(this.instances.values()).filter(inst => inst.isLoaded)
}
/**
* 触发扩展事件
*/
emitEvent(event: string, data?: any) {
this.stAPI.eventSource.emit(event, data)
}
}
// 创建单例
export const extensionRuntime = new ExtensionRuntime()
// 导出类型
export type { ExtensionInstance }

View File

@@ -0,0 +1,522 @@
<template>
<div class="extension-list-page">
<el-card>
<template #header>
<div class="card-header">
<h2>扩展管理</h2>
<div class="header-actions">
<el-button type="primary" @click="showInstallDialog = true">
<el-icon><Plus /></el-icon>
安装扩展
</el-button>
</div>
</div>
</template>
<!-- 过滤器 -->
<div class="filters">
<el-input
v-model="searchKeyword"
placeholder="搜索扩展名称..."
clearable
@change="handleSearch"
style="width: 300px; margin-right: 10px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="filterType"
placeholder="扩展类型"
clearable
@change="handleFilterChange"
style="width: 150px; margin-right: 10px"
>
<el-option label="UI 扩展" value="ui" />
<el-option label="服务端扩展" value="server" />
<el-option label="混合扩展" value="hybrid" />
</el-select>
<el-select
v-model="filterCategory"
placeholder="分类"
clearable
@change="handleFilterChange"
style="width: 150px; margin-right: 10px"
>
<el-option label="工具" value="utilities" />
<el-option label="主题" value="themes" />
<el-option label="集成" value="integrations" />
<el-option label="其他" value="tools" />
</el-select>
<el-select
v-model="filterEnabled"
placeholder="状态"
clearable
@change="handleFilterChange"
style="width: 120px; margin-right: 10px"
>
<el-option label="已启用" :value="true" />
<el-option label="已禁用" :value="false" />
</el-select>
<el-button @click="handleResetFilters">重置</el-button>
</div>
<!-- 扩展列表 -->
<el-table
v-loading="extensionStore.loading"
:data="extensionStore.extensions"
style="width: 100%; margin-top: 20px"
>
<el-table-column prop="displayName" label="扩展名称" width="200">
<template #default="{ row }">
<div class="extension-name">
<strong>{{ row.displayName || row.name }}</strong>
<el-tag
v-if="row.isSystemExt"
size="small"
type="info"
style="margin-left: 8px"
>
系统
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" show-overflow-tooltip />
<el-table-column prop="version" label="版本" width="100" />
<el-table-column prop="author" label="作者" width="120" />
<el-table-column prop="extensionType" label="类型" width="100">
<template #default="{ row }">
<el-tag :type="getTypeTagType(row.extensionType)" size="small">
{{ getTypeLabel(row.extensionType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="category" label="分类" width="120">
<template #default="{ row }">
{{ getCategoryLabel(row.category) }}
</template>
</el-table-column>
<el-table-column prop="isEnabled" label="状态" width="100">
<template #default="{ row }">
<el-switch
v-model="row.isEnabled"
:disabled="row.isSystemExt"
@change="handleToggle(row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button
size="small"
@click="handleViewDetail(row)"
>
详情
</el-button>
<el-button
size="small"
@click="handleSettings(row)"
>
配置
</el-button>
<el-button
size="small"
@click="handleExport(row)"
>
导出
</el-button>
<el-button
v-if="!row.isSystemExt"
size="small"
type="danger"
@click="handleDelete(row)"
>
卸载
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="extensionStore.currentPage"
v-model:page-size="extensionStore.pageSize"
:total="extensionStore.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
style="margin-top: 20px; justify-content: flex-end"
/>
</el-card>
<!-- 安装扩展对话框 -->
<el-dialog
v-model="showInstallDialog"
title="安装扩展"
width="600px"
>
<el-tabs v-model="installTab">
<el-tab-pane label="从文件导入" name="file">
<el-upload
ref="uploadRef"
:auto-upload="false"
:limit="1"
accept=".json"
:on-change="handleFileSelect"
drag
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">
manifest.json 拖到此处<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
只能上传 manifest.json 文件
</div>
</template>
</el-upload>
</el-tab-pane>
<el-tab-pane label="从 Git 安装" name="git">
<el-form :model="installForm" label-width="120px">
<el-form-item label="Git 仓库 URL">
<el-input
v-model="installForm.gitUrl"
placeholder="https://github.com/username/extension-name.git"
/>
<template #extra>
<div style="font-size: 12px; color: #909399; margin-top: 5px">
支持 GitHubGitLabGitee Git 仓库<br />
示例https://github.com/SillyTavern/Extension-Example.git
</div>
</template>
</el-form-item>
<el-form-item label="分支(可选)">
<el-input
v-model="installForm.branch"
placeholder="main"
/>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="从 URL 安装" name="url">
<el-form :model="installForm" label-width="120px">
<el-form-item label="Manifest URL">
<el-input
v-model="installForm.url"
placeholder="https://example.com/manifest.json"
/>
<template #extra>
<div style="font-size: 12px; color: #909399; margin-top: 5px">
直接指向 manifest.json 文件的 URL
</div>
</template>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button @click="showInstallDialog = false">取消</el-button>
<el-button type="primary" @click="handleInstall" :loading="extensionStore.loading">
安装
</el-button>
</template>
</el-dialog>
<!-- 扩展详情对话框 -->
<el-dialog
v-model="showDetailDialog"
:title="currentExtension?.displayName || currentExtension?.name"
width="800px"
>
<el-descriptions v-if="currentExtension" :column="2" border>
<el-descriptions-item label="扩展名称">
{{ currentExtension.displayName || currentExtension.name }}
</el-descriptions-item>
<el-descriptions-item label="版本">
{{ currentExtension.version }}
</el-descriptions-item>
<el-descriptions-item label="作者">
{{ currentExtension.author }}
</el-descriptions-item>
<el-descriptions-item label="许可证">
{{ currentExtension.license || '-' }}
</el-descriptions-item>
<el-descriptions-item label="类型">
{{ getTypeLabel(currentExtension.extensionType) }}
</el-descriptions-item>
<el-descriptions-item label="分类">
{{ getCategoryLabel(currentExtension.category) }}
</el-descriptions-item>
<el-descriptions-item label="主页" :span="2">
<a v-if="currentExtension.homepage" :href="currentExtension.homepage" target="_blank">
{{ currentExtension.homepage }}
</a>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="仓库" :span="2">
<a v-if="currentExtension.repository" :href="currentExtension.repository" target="_blank">
{{ currentExtension.repository }}
</a>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">
{{ currentExtension.description }}
</el-descriptions-item>
<el-descriptions-item label="标签" :span="2">
<el-tag
v-for="tag in currentExtension.tags"
:key="tag"
size="small"
style="margin-right: 5px"
>
{{ tag }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="安装日期">
{{ formatDate(currentExtension.installDate) }}
</el-descriptions-item>
<el-descriptions-item label="使用次数">
{{ currentExtension.usageCount }}
</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessageBox, ElMessage } from 'element-plus'
import { Plus, Search, UploadFilled } from '@element-plus/icons-vue'
import { useExtensionStore } from '@/stores/extension'
import type { Extension } from '@/types/extension'
const router = useRouter()
const extensionStore = useExtensionStore()
// 搜索和过滤
const searchKeyword = ref('')
const filterType = ref('')
const filterCategory = ref('')
const filterEnabled = ref<boolean | undefined>(undefined)
// 对话框
const showInstallDialog = ref(false)
const showDetailDialog = ref(false)
const installTab = ref('git')
const installForm = ref({
gitUrl: '',
branch: 'main',
url: '',
})
const uploadRef = ref()
const selectedFile = ref<File | null>(null)
const currentExtension = ref<Extension | null>(null)
onMounted(() => {
extensionStore.fetchExtensionList()
})
const handleSearch = () => {
extensionStore.fetchExtensionList({ keyword: searchKeyword.value })
}
const handleFilterChange = () => {
extensionStore.setFilters({
extensionType: filterType.value,
category: filterCategory.value,
isEnabled: filterEnabled.value,
})
}
const handleResetFilters = () => {
searchKeyword.value = ''
filterType.value = ''
filterCategory.value = ''
filterEnabled.value = undefined
extensionStore.resetFilters()
}
const handlePageChange = (page: number) => {
extensionStore.currentPage = page
extensionStore.fetchExtensionList()
}
const handleSizeChange = (size: number) => {
extensionStore.pageSize = size
extensionStore.fetchExtensionList()
}
const handleToggle = async (row: Extension) => {
try {
await extensionStore.toggleExtension(row.id, row.isEnabled)
} catch (error) {
// 切换失败,还原状态
row.isEnabled = !row.isEnabled
}
}
const handleViewDetail = (row: Extension) => {
currentExtension.value = row
showDetailDialog.value = true
}
const handleSettings = (row: Extension) => {
router.push(`/extension/settings/${row.id}`)
}
const handleExport = async (row: Extension) => {
try {
await extensionStore.exportExtension(row.id, row.name)
} catch (error) {
console.error('导出失败:', error)
}
}
const handleDelete = async (row: Extension) => {
try {
await ElMessageBox.confirm(
`确定要卸载扩展 "${row.displayName || row.name}" 吗?`,
'确认卸载',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
await extensionStore.deleteExtension(row.id, false)
} catch (error: any) {
if (error !== 'cancel') {
console.error('卸载失败:', error)
}
}
}
const handleFileSelect = (file: any) => {
selectedFile.value = file.raw
}
const handleInstall = async () => {
try {
if (installTab.value === 'file') {
if (!selectedFile.value) {
ElMessage.warning('请选择要上传的文件')
return
}
await extensionStore.importExtension(selectedFile.value)
} else if (installTab.value === 'git') {
if (!installForm.value.gitUrl) {
ElMessage.warning('请输入 Git 仓库 URL')
return
}
await extensionStore.installExtensionFromGit(
installForm.value.gitUrl,
installForm.value.branch || 'main'
)
} else {
if (!installForm.value.url) {
ElMessage.warning('请输入 Manifest URL')
return
}
await extensionStore.installExtensionFromUrl(installForm.value.url)
}
showInstallDialog.value = false
selectedFile.value = null
installForm.value = { gitUrl: '', branch: 'main', url: '' }
} catch (error) {
console.error('安装失败:', error)
}
}
const getTypeLabel = (type: string) => {
const labels: Record<string, string> = {
ui: 'UI',
server: '服务端',
hybrid: '混合',
}
return labels[type] || type
}
const getTypeTagType = (type: string) => {
const types: Record<string, any> = {
ui: 'primary',
server: 'success',
hybrid: 'warning',
}
return types[type] || ''
}
const getCategoryLabel = (category: string) => {
const labels: Record<string, string> = {
utilities: '工具',
themes: '主题',
integrations: '集成',
tools: '其他',
}
return labels[category] || category || '-'
}
const formatDate = (date: string) => {
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN')
}
</script>
<style scoped>
.extension-list-page {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h2 {
margin: 0;
font-size: 20px;
}
.header-actions {
display: flex;
gap: 10px;
}
.filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.extension-name {
display: flex;
align-items: center;
}
.el-icon--upload {
font-size: 67px;
color: #c0c4cc;
margin: 40px 0 16px;
line-height: 50px;
}
</style>

View File

@@ -0,0 +1,798 @@
<template>
<div class="extension-panel-page">
<el-card>
<template #header>
<div class="card-header">
<h2>扩展管理</h2>
<div class="header-actions">
<el-button type="primary" @click="showInstallDialog = true" size="small">
<el-icon><Plus /></el-icon>
安装扩展
</el-button>
<el-button @click="handleRefresh" size="small">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
</template>
<!-- 搜索和过滤 -->
<div class="filters">
<el-input
v-model="searchKeyword"
placeholder="搜索扩展..."
clearable
@input="handleSearch"
style="max-width: 400px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<!-- 折叠面板式扩展列表 -->
<div class="extension-panels" v-loading="extensionStore.loading">
<el-empty v-if="filteredExtensions.length === 0" description="暂无扩展" />
<el-collapse v-else v-model="activeNames" accordion>
<el-collapse-item
v-for="ext in filteredExtensions"
:key="ext.id"
:name="ext.id"
class="extension-item"
>
<!-- 扩展头部 -->
<template #title>
<div class="extension-header">
<div class="extension-info">
<div class="extension-title">
<el-switch
v-model="ext.isEnabled"
size="default"
@click.stop="handleToggle(ext)"
@change="handleToggle(ext)"
style="margin-right: 12px"
/>
<span class="name">{{ ext.displayName || ext.name }}</span>
<el-tag
v-if="ext.isSystemExt"
size="small"
type="info"
style="margin-left: 8px"
>
系统
</el-tag>
<el-tag
:type="getTypeTagType(ext.extensionType)"
size="small"
style="margin-left: 8px"
>
{{ getTypeLabel(ext.extensionType) }}
</el-tag>
</div>
<div class="extension-meta">
<span class="version">v{{ ext.version }}</span>
<span class="author" v-if="ext.author">by {{ ext.author }}</span>
</div>
</div>
<div class="extension-actions" @click.stop>
<el-button
v-if="!ext.isSystemExt"
size="small"
type="danger"
text
@click="handleDelete(ext)"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</template>
<!-- 扩展详情和配置 -->
<div class="extension-content">
<div class="extension-description" v-if="ext.description">
<p>{{ ext.description }}</p>
</div>
<!-- 扩展配置区域 -->
<div class="extension-settings" v-loading="loadingSettings[ext.id]">
<el-form
v-if="extensionSettings[ext.id]"
:model="extensionSettings[ext.id]"
label-position="top"
size="default"
>
<!-- 动态生成配置项 -->
<template v-for="(setting, key) in getSettingsSchema(ext)" :key="key">
<!-- 文本输入 -->
<el-form-item
v-if="setting.type === 'text' || setting.type === 'string'"
:label="setting.label || key"
>
<el-input
v-model="extensionSettings[ext.id][key]"
:placeholder="setting.placeholder || setting.description"
@change="handleSettingChange(ext.id, key)"
/>
<div v-if="setting.description" class="setting-hint">
{{ setting.description }}
</div>
</el-form-item>
<!-- 数字输入 -->
<el-form-item
v-else-if="setting.type === 'number'"
:label="setting.label || key"
>
<el-input-number
v-model="extensionSettings[ext.id][key]"
:min="setting.min"
:max="setting.max"
:step="setting.step || 1"
@change="handleSettingChange(ext.id, key)"
/>
<div v-if="setting.description" class="setting-hint">
{{ setting.description }}
</div>
</el-form-item>
<!-- 布尔开关 -->
<el-form-item
v-else-if="setting.type === 'boolean' || setting.type === 'checkbox'"
:label="setting.label || key"
>
<el-switch
v-model="extensionSettings[ext.id][key]"
@change="handleSettingChange(ext.id, key)"
/>
<div v-if="setting.description" class="setting-hint">
{{ setting.description }}
</div>
</el-form-item>
<!-- 下拉选择 -->
<el-form-item
v-else-if="setting.type === 'select'"
:label="setting.label || key"
>
<el-select
v-model="extensionSettings[ext.id][key]"
:placeholder="setting.placeholder"
@change="handleSettingChange(ext.id, key)"
>
<el-option
v-for="option in setting.options"
:key="option.value || option"
:label="option.label || option"
:value="option.value || option"
/>
</el-select>
<div v-if="setting.description" class="setting-hint">
{{ setting.description }}
</div>
</el-form-item>
<!-- 文本域 -->
<el-form-item
v-else-if="setting.type === 'textarea'"
:label="setting.label || key"
>
<el-input
v-model="extensionSettings[ext.id][key]"
type="textarea"
:rows="setting.rows || 3"
:placeholder="setting.placeholder || setting.description"
@change="handleSettingChange(ext.id, key)"
/>
<div v-if="setting.description" class="setting-hint">
{{ setting.description }}
</div>
</el-form-item>
<!-- 滑块 -->
<el-form-item
v-else-if="setting.type === 'slider'"
:label="setting.label || key"
>
<el-slider
v-model="extensionSettings[ext.id][key]"
:min="setting.min || 0"
:max="setting.max || 100"
:step="setting.step || 1"
:show-input="true"
@change="handleSettingChange(ext.id, key)"
/>
<div v-if="setting.description" class="setting-hint">
{{ setting.description }}
</div>
</el-form-item>
</template>
<!-- 如果没有配置项 -->
<el-empty
v-if="Object.keys(getSettingsSchema(ext)).length === 0"
description="此扩展没有可配置项"
:image-size="60"
/>
</el-form>
<el-alert
v-else-if="settingsError[ext.id]"
type="error"
:title="settingsError[ext.id]"
:closable="false"
/>
</div>
<!-- 扩展详细信息 -->
<el-divider />
<div class="extension-info-detail">
<el-descriptions :column="2" size="small" border>
<el-descriptions-item label="分类">
{{ getCategoryLabel(ext.category) }}
</el-descriptions-item>
<el-descriptions-item label="许可证">
{{ ext.license || '-' }}
</el-descriptions-item>
<el-descriptions-item label="主页" :span="2" v-if="ext.homepage">
<el-link :href="ext.homepage" target="_blank" type="primary">
{{ ext.homepage }}
</el-link>
</el-descriptions-item>
<el-descriptions-item label="仓库" :span="2" v-if="ext.repository">
<el-link :href="ext.repository" target="_blank" type="primary">
{{ ext.repository }}
</el-link>
</el-descriptions-item>
<el-descriptions-item label="安装来源">
{{ getInstallSourceLabel(ext.installSource) }}
</el-descriptions-item>
<el-descriptions-item label="安装日期">
{{ formatDate(ext.installDate) }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 操作按钮 -->
<div class="extension-actions-bar">
<el-button size="small" @click="handleExport(ext)">
<el-icon><Download /></el-icon>
导出
</el-button>
<el-button size="small" @click="handleRefreshExtension(ext)">
<el-icon><Refresh /></el-icon>
刷新配置
</el-button>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
</el-card>
<!-- 安装扩展对话框 -->
<el-dialog
v-model="showInstallDialog"
title="安装扩展"
width="600px"
>
<el-tabs v-model="installTab">
<el-tab-pane label="从文件导入" name="file">
<el-upload
ref="uploadRef"
:auto-upload="false"
:limit="1"
accept=".json"
:on-change="handleFileSelect"
drag
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">
manifest.json 拖到此处<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
只能上传 manifest.json 文件
</div>
</template>
</el-upload>
</el-tab-pane>
<el-tab-pane label="从 Git 安装" name="git">
<el-form :model="installForm" label-width="120px">
<el-form-item label="Git 仓库 URL">
<el-input
v-model="installForm.gitUrl"
placeholder="https://github.com/username/extension-name.git"
/>
<template #extra>
<div style="font-size: 12px; color: #909399; margin-top: 5px">
支持 GitHubGitLabGitee Git 仓库<br />
示例https://github.com/SillyTavern/Extension-Example.git
</div>
</template>
</el-form-item>
<el-form-item label="分支(可选)">
<el-input
v-model="installForm.branch"
placeholder="main"
/>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="从 URL 安装" name="url">
<el-form :model="installForm" label-width="120px">
<el-form-item label="Manifest URL">
<el-input
v-model="installForm.url"
placeholder="https://example.com/manifest.json"
/>
<template #extra>
<div style="font-size: 12px; color: #909399; margin-top: 5px">
直接指向 manifest.json 文件的 URL
</div>
</template>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button @click="showInstallDialog = false">取消</el-button>
<el-button type="primary" @click="handleInstall" :loading="extensionStore.loading">
安装
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useExtensionStore } from '@/stores/extension'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Search,
Refresh,
Delete,
Download,
UploadFilled,
} from '@element-plus/icons-vue'
import type { Extension } from '@/types/extension'
const extensionStore = useExtensionStore()
// 搜索和过滤
const searchKeyword = ref('')
const activeNames = ref<number | null>(null)
// 安装对话框
const showInstallDialog = ref(false)
const installTab = ref('git')
const installForm = reactive({
gitUrl: '',
branch: 'main',
url: '',
})
const selectedFile = ref<File | null>(null)
const uploadRef = ref()
// 扩展配置
const extensionSettings = ref<Record<number, any>>({})
const loadingSettings = ref<Record<number, boolean>>({})
const settingsError = ref<Record<number, string>>({})
// 计算属性:过滤后的扩展列表
const filteredExtensions = computed(() => {
let extensions = extensionStore.extensions
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
extensions = extensions.filter(ext =>
(ext.name?.toLowerCase().includes(keyword)) ||
(ext.displayName?.toLowerCase().includes(keyword)) ||
(ext.description?.toLowerCase().includes(keyword)) ||
(ext.author?.toLowerCase().includes(keyword))
)
}
return extensions
})
// 获取扩展的配置结构
const getSettingsSchema = (ext: Extension) => {
try {
// 从 manifest 中提取 settings 定义
if (ext.manifestData && typeof ext.manifestData === 'object') {
const manifest = ext.manifestData as any
if (manifest.settings && typeof manifest.settings === 'object') {
return manifest.settings
}
}
// 从扩展的 Settings 字段提取
if (ext.settings && typeof ext.settings === 'object') {
return ext.settings
}
return {}
} catch (error) {
console.error('解析配置结构失败:', error)
return {}
}
}
// 加载扩展配置
const loadExtensionSettings = async (extId: number) => {
if (loadingSettings.value[extId]) return
try {
loadingSettings.value[extId] = true
settingsError.value[extId] = ''
const settings = await extensionStore.getExtensionSettings(extId)
extensionSettings.value[extId] = settings || {}
} catch (error: any) {
console.error('加载扩展配置失败:', error)
settingsError.value[extId] = error.response?.data?.msg || '加载配置失败'
extensionSettings.value[extId] = {}
} finally {
loadingSettings.value[extId] = false
}
}
// 监听折叠面板展开,自动加载配置
watch(activeNames, (newVal) => {
if (newVal && !extensionSettings.value[newVal]) {
loadExtensionSettings(newVal)
}
})
// 配置项变更
const handleSettingChange = async (extId: number, key: string) => {
try {
await extensionStore.updateExtensionSettings(extId, extensionSettings.value[extId])
ElMessage.success('配置已保存')
} catch (error: any) {
console.error('保存配置失败:', error)
ElMessage.error(error.response?.data?.msg || '保存配置失败')
}
}
// 搜索
const handleSearch = () => {
// 搜索逻辑已在 computed 中处理
}
// 刷新
const handleRefresh = () => {
extensionStore.fetchExtensionList()
}
// 启用/禁用扩展
const handleToggle = async (ext: Extension) => {
try {
await extensionStore.toggleExtension(ext.id, ext.isEnabled)
ElMessage.success(ext.isEnabled ? '已启用' : '已禁用')
} catch (error: any) {
console.error('切换扩展状态失败:', error)
ElMessage.error(error.response?.data?.msg || '操作失败')
ext.isEnabled = !ext.isEnabled // 恢复状态
}
}
// 删除扩展
const handleDelete = async (ext: Extension) => {
try {
await ElMessageBox.confirm(
`确定要卸载扩展"${ext.displayName || ext.name}"吗?`,
'确认卸载',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
await extensionStore.deleteExtension(ext.id)
ElMessage.success('卸载成功')
} catch (error: any) {
if (error !== 'cancel') {
console.error('卸载扩展失败:', error)
ElMessage.error(error.response?.data?.msg || '卸载失败')
}
}
}
// 导出扩展
const handleExport = async (ext: Extension) => {
try {
await extensionStore.exportExtension(ext.id)
ElMessage.success('导出成功')
} catch (error: any) {
console.error('导出扩展失败:', error)
ElMessage.error(error.response?.data?.msg || '导出失败')
}
}
// 刷新扩展配置
const handleRefreshExtension = (ext: Extension) => {
delete extensionSettings.value[ext.id]
loadExtensionSettings(ext.id)
}
// 文件选择
const handleFileSelect = (file: any) => {
selectedFile.value = file.raw
}
// 安装扩展
const handleInstall = async () => {
try {
if (installTab.value === 'file') {
if (!selectedFile.value) {
ElMessage.warning('请选择 manifest.json 文件')
return
}
await extensionStore.importExtension(selectedFile.value)
} else if (installTab.value === 'git') {
if (!installForm.gitUrl) {
ElMessage.warning('请输入 Git 仓库 URL')
return
}
await extensionStore.installExtensionFromGit(
installForm.gitUrl,
installForm.branch || 'main'
)
} else {
if (!installForm.url) {
ElMessage.warning('请输入 Manifest URL')
return
}
await extensionStore.installExtensionFromUrl(installForm.url)
}
showInstallDialog.value = false
selectedFile.value = null
installForm.gitUrl = ''
installForm.branch = 'main'
installForm.url = ''
// 重置上传组件
if (uploadRef.value) {
uploadRef.value.clearFiles()
}
} catch (error) {
console.error('安装失败:', error)
}
}
// 格式化日期
const formatDate = (date: string | Date) => {
if (!date) return '-'
const d = new Date(date)
return d.toLocaleString('zh-CN')
}
// 类型标签颜色
const getTypeTagType = (type: string) => {
const types: Record<string, string> = {
ui: '',
server: 'success',
hybrid: 'warning',
}
return types[type] || ''
}
// 类型标签文本
const getTypeLabel = (type: string) => {
const labels: Record<string, string> = {
ui: 'UI',
server: 'Server',
hybrid: 'Hybrid',
}
return labels[type] || type
}
// 分类标签文本
const getCategoryLabel = (category: string) => {
const labels: Record<string, string> = {
utilities: '工具',
themes: '主题',
integrations: '集成',
tools: '其他',
}
return labels[category] || category || '-'
}
// 安装来源标签
const getInstallSourceLabel = (source: string) => {
const labels: Record<string, string> = {
file: '文件导入',
url: 'URL 安装',
git: 'Git 安装',
marketplace: '扩展市场',
}
return labels[source] || source || '-'
}
// 初始化
onMounted(() => {
extensionStore.fetchExtensionList()
})
</script>
<style scoped lang="scss">
.extension-panel-page {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 10px;
}
}
.filters {
margin-bottom: 20px;
}
.extension-panels {
margin-top: 20px;
:deep(.el-collapse) {
border: none;
}
:deep(.el-collapse-item) {
margin-bottom: 12px;
border: 1px solid var(--el-border-color);
border-radius: 8px;
overflow: hidden;
&:last-child {
margin-bottom: 0;
}
}
:deep(.el-collapse-item__header) {
height: auto;
min-height: 56px;
padding: 12px 16px;
background-color: var(--el-fill-color-light);
border: none;
font-size: 14px;
&:hover {
background-color: var(--el-fill-color);
}
&.is-active {
background-color: var(--el-color-primary-light-9);
border-bottom: 1px solid var(--el-border-color);
}
}
:deep(.el-collapse-item__wrap) {
border: none;
}
:deep(.el-collapse-item__content) {
padding: 20px;
}
}
.extension-header {
display: flex;
align-items: center;
justify-content: space-between;
flex: 1;
padding-right: 10px;
.extension-info {
flex: 1;
min-width: 0;
.extension-title {
display: flex;
align-items: center;
margin-bottom: 4px;
.name {
font-weight: 600;
font-size: 15px;
color: var(--el-text-color-primary);
}
}
.extension-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: var(--el-text-color-secondary);
.version {
font-family: 'Courier New', monospace;
}
}
}
.extension-actions {
display: flex;
gap: 4px;
}
}
.extension-content {
.extension-description {
margin-bottom: 20px;
padding: 12px;
background-color: var(--el-fill-color-light);
border-radius: 6px;
font-size: 14px;
color: var(--el-text-color-regular);
line-height: 1.6;
p {
margin: 0;
}
}
.extension-settings {
margin-bottom: 20px;
:deep(.el-form-item) {
margin-bottom: 20px;
.el-form-item__label {
font-weight: 600;
margin-bottom: 8px;
}
}
.setting-hint {
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.5;
}
}
.extension-info-detail {
margin-bottom: 20px;
}
.extension-actions-bar {
display: flex;
gap: 10px;
justify-content: flex-end;
padding-top: 16px;
border-top: 1px solid var(--el-border-color-lighter);
}
}
:deep(.el-upload-dragger) {
padding: 40px;
}
:deep(.el-descriptions) {
.el-descriptions__label {
width: 120px;
}
}
}
</style>

View File

@@ -0,0 +1,329 @@
<template>
<div class="extension-settings-page">
<el-card v-loading="loading">
<template #header>
<div class="card-header">
<div>
<h2>{{ extension?.displayName || extension?.name }} - 配置</h2>
<p class="subtitle">{{ extension?.description }}</p>
</div>
<div class="header-actions">
<el-button @click="handleBack">返回</el-button>
<el-button type="primary" @click="handleSave" :loading="saving">
保存配置
</el-button>
</div>
</div>
</template>
<!-- 扩展信息 -->
<el-descriptions :column="3" border style="margin-bottom: 20px">
<el-descriptions-item label="版本">
{{ extension?.version }}
</el-descriptions-item>
<el-descriptions-item label="作者">
{{ extension?.author }}
</el-descriptions-item>
<el-descriptions-item label="类型">
{{ getTypeLabel(extension?.extensionType) }}
</el-descriptions-item>
</el-descriptions>
<!-- 配置表单 -->
<el-form
v-if="settings && extension"
ref="formRef"
:model="formData"
label-width="200px"
style="max-width: 800px"
>
<el-divider content-position="left">扩展配置</el-divider>
<!-- 动态生成配置项 -->
<template v-if="hasSettings">
<el-form-item
v-for="(value, key) in settings"
:key="key"
:label="getSettingLabel(key)"
>
<!-- 布尔值 -->
<el-switch
v-if="typeof value === 'boolean'"
v-model="formData[key]"
/>
<!-- 数字 -->
<el-input-number
v-else-if="typeof value === 'number'"
v-model="formData[key]"
:min="0"
/>
<!-- 选择器如果有 options -->
<el-select
v-else-if="isSelectField(key)"
v-model="formData[key]"
placeholder="请选择"
>
<el-option
v-for="option in getSelectOptions(key)"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<!-- 多行文本 -->
<el-input
v-else-if="isTextareaField(key)"
v-model="formData[key]"
type="textarea"
:rows="4"
/>
<!-- 默认文本输入 -->
<el-input
v-else
v-model="formData[key]"
:placeholder="getSettingPlaceholder(key)"
/>
</el-form-item>
</template>
<!-- 没有配置项 -->
<el-empty v-else description="该扩展暂无可配置项" />
<!-- 高级选项 -->
<template v-if="extension.options && Object.keys(extension.options).length > 0">
<el-divider content-position="left">高级选项</el-divider>
<el-form-item
v-for="(value, key) in extension.options"
:key="'option_' + key"
:label="getSettingLabel(key)"
>
<el-input
v-model="optionsData[key]"
:placeholder="getSettingPlaceholder(key)"
/>
</el-form-item>
</template>
</el-form>
<!-- Manifest 信息 -->
<el-divider content-position="left">Manifest 信息</el-divider>
<el-descriptions v-if="extension" :column="2" border>
<el-descriptions-item label="主脚本">
{{ extension.scriptPath || '-' }}
</el-descriptions-item>
<el-descriptions-item label="样式文件">
{{ extension.stylePath || '-' }}
</el-descriptions-item>
<el-descriptions-item label="依赖扩展" :span="2">
<template v-if="extension.dependencies && Object.keys(extension.dependencies).length > 0">
<el-tag
v-for="(version, name) in extension.dependencies"
:key="name"
size="small"
style="margin-right: 5px"
>
{{ name }} {{ version }}
</el-tag>
</template>
<span v-else></span>
</el-descriptions-item>
<el-descriptions-item label="冲突扩展" :span="2">
<template v-if="extension.conflicts && extension.conflicts.length > 0">
<el-tag
v-for="conflict in extension.conflicts"
:key="conflict"
size="small"
type="danger"
style="margin-right: 5px"
>
{{ conflict }}
</el-tag>
</template>
<span v-else></span>
</el-descriptions-item>
</el-descriptions>
<!-- 统计信息 -->
<el-divider content-position="left">使用统计</el-divider>
<el-descriptions v-if="extension" :column="3" border>
<el-descriptions-item label="使用次数">
{{ extension.usageCount }}
</el-descriptions-item>
<el-descriptions-item label="错误次数">
{{ extension.errorCount }}
</el-descriptions-item>
<el-descriptions-item label="平均加载时间">
{{ extension.loadTime }}ms
</el-descriptions-item>
<el-descriptions-item label="安装日期">
{{ formatDate(extension.installDate) }}
</el-descriptions-item>
<el-descriptions-item label="最后启用时间">
{{ formatDate(extension.lastEnabled) }}
</el-descriptions-item>
<el-descriptions-item label="安装来源">
{{ getInstallSourceLabel(extension.installSource) }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useExtensionStore } from '@/stores/extension'
import type { Extension } from '@/types/extension'
const route = useRoute()
const router = useRouter()
const extensionStore = useExtensionStore()
const loading = ref(false)
const saving = ref(false)
const extension = ref<Extension | null>(null)
const settings = ref<Record<string, any> | null>(null)
const formData = ref<Record<string, any>>({})
const optionsData = ref<Record<string, any>>({})
const formRef = ref()
const extensionId = computed(() => Number(route.params.id))
const hasSettings = computed(() => {
return settings.value && Object.keys(settings.value).length > 0
})
onMounted(async () => {
await loadExtension()
await loadSettings()
})
const loadExtension = async () => {
try {
loading.value = true
extension.value = await extensionStore.fetchExtension(extensionId.value)
// 初始化 options 数据
if (extension.value.options) {
optionsData.value = { ...extension.value.options }
}
} catch (error) {
console.error('加载扩展失败:', error)
ElMessage.error('加载扩展失败')
router.back()
} finally {
loading.value = false
}
}
const loadSettings = async () => {
try {
settings.value = await extensionStore.getExtensionSettings(extensionId.value)
formData.value = { ...settings.value }
} catch (error) {
console.error('加载配置失败:', error)
}
}
const handleSave = async () => {
try {
saving.value = true
await extensionStore.updateExtensionSettings(extensionId.value, formData.value)
ElMessage.success('配置保存成功')
} catch (error) {
console.error('保存失败:', error)
} finally {
saving.value = false
}
}
const handleBack = () => {
router.back()
}
const getTypeLabel = (type?: string) => {
if (!type) return '-'
const labels: Record<string, string> = {
ui: 'UI 扩展',
server: '服务端扩展',
hybrid: '混合扩展',
}
return labels[type] || type
}
const getSettingLabel = (key: string) => {
// 将驼峰命名转换为可读标签
return key
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase())
.replace(/_/g, ' ')
}
const getSettingPlaceholder = (key: string) => {
return `请输入 ${getSettingLabel(key)}`
}
const isSelectField = (key: string) => {
// 判断是否是选择字段(可根据 extension.options 或 manifest 定义)
return false
}
const getSelectOptions = (key: string) => {
// 返回选择器选项
return []
}
const isTextareaField = (key: string) => {
// 判断是否是多行文本字段
const textareaFields = ['description', 'content', 'notes', 'script', 'code']
return textareaFields.some(field => key.toLowerCase().includes(field))
}
const getInstallSourceLabel = (source: string) => {
const labels: Record<string, string> = {
url: 'URL',
file: '文件',
marketplace: '应用市场',
}
return labels[source] || source
}
const formatDate = (date: string) => {
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN')
}
</script>
<style scoped>
.extension-settings-page {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.card-header h2 {
margin: 0 0 5px 0;
font-size: 20px;
}
.subtitle {
margin: 0;
font-size: 14px;
color: #909399;
}
.header-actions {
display: flex;
gap: 10px;
}
</style>

View File

@@ -0,0 +1,421 @@
<template>
<div class="regex-script-edit">
<el-page-header @back="handleBack" :title="isEdit ? '编辑正则脚本' : '创建正则脚本'">
<template #content>
<span class="page-title">{{ isEdit ? '编辑正则脚本' : '创建正则脚本' }}</span>
</template>
</el-page-header>
<el-card shadow="never" style="margin-top: 20px">
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="140px"
label-position="left"
>
<!-- 基础信息 -->
<el-divider content-position="left">基础信息</el-divider>
<el-form-item label="脚本名称" prop="scriptName">
<el-input
v-model="formData.scriptName"
placeholder="请输入脚本名称"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="脚本描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="2"
placeholder="请输入脚本描述"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="是否启用" prop="enabled">
<el-switch v-model="formData.enabled" />
</el-form-item>
<el-form-item label="全局脚本" prop="isGlobal">
<el-switch v-model="formData.isGlobal" />
<span style="margin-left: 10px; color: #909399; font-size: 12px">
全局脚本会应用到所有对话角色脚本仅应用到关联的角色
</span>
</el-form-item>
<!-- 正则表达式 -->
<el-divider content-position="left">正则表达式</el-divider>
<el-form-item label="查找正则" prop="findRegex">
<el-input
v-model="formData.findRegex"
type="textarea"
:rows="3"
placeholder="请输入正则表达式,例如:\b(Hello|Hi)\b"
@input="handleRegexChange"
/>
<div style="margin-top: 5px; font-size: 12px; color: #909399">
<el-link
href="https://regexr.com/"
target="_blank"
type="primary"
:underline="false"
style="font-size: 12px"
>
正则表达式测试工具
</el-link>
</div>
</el-form-item>
<el-form-item label="替换为" prop="replaceString">
<el-input
v-model="formData.replaceString"
type="textarea"
:rows="2"
placeholder="请输入替换字符串,支持 $1、$2 等捕获组引用"
@input="handleRegexChange"
/>
</el-form-item>
<!-- 测试区域 -->
<el-form-item label="测试输入">
<el-input
v-model="testInput"
type="textarea"
:rows="3"
placeholder="输入测试文本"
@input="handleTest"
/>
<el-button
type="primary"
size="small"
style="margin-top: 10px"
:loading="testing"
@click="handleTest"
>
测试
</el-button>
</el-form-item>
<el-form-item v-if="testResult" label="测试结果">
<div class="test-result">
<div v-if="testResult.success" class="result-success">
<div class="result-row">
<span class="label">匹配次数</span>
<el-tag type="success">{{ testResult.matchedCount }}</el-tag>
</div>
<div v-if="testResult.matches.length > 0" class="result-row">
<span class="label">匹配内容</span>
<el-tag
v-for="(match, idx) in testResult.matches"
:key="idx"
size="small"
style="margin-right: 5px"
>
{{ match }}
</el-tag>
</div>
<div class="result-row">
<span class="label">处理后</span>
<div class="output-text">{{ testResult.output }}</div>
</div>
</div>
<div v-else class="result-error">
<el-alert type="error" :title="testResult.error" :closable="false" />
</div>
</div>
</el-form-item>
<!-- 高级选项 -->
<el-divider content-position="left">高级选项</el-divider>
<el-form-item label="去除空格">
<el-switch v-model="formData.trimStrings" />
<span style="margin-left: 10px; color: #909399; font-size: 12px">
处理前去除文本首尾空格
</span>
</el-form-item>
<el-form-item label="正则替换">
<el-switch v-model="formData.substituteRegex" />
<span style="margin-left: 10px; color: #909399; font-size: 12px">
启用后支持 $1$2 等捕获组引用
</span>
</el-form-item>
<el-form-item label="仅格式化">
<el-switch v-model="formData.onlyFormat" />
<span style="margin-left: 10px; color: #909399; font-size: 12px">
仅用于格式化消息不影响 AI 处理
</span>
</el-form-item>
<el-form-item label="编辑时运行">
<el-switch v-model="formData.runOnEdit" />
<span style="margin-left: 10px; color: #909399; font-size: 12px">
在编辑消息时也运行脚本
</span>
</el-form-item>
<el-form-item label="应用位置">
<el-select
v-model="formData.placement"
placeholder="选择应用位置"
clearable
>
<el-option label="全部消息" value="" />
<el-option label="用户消息" value="user" />
<el-option label="AI消息" value="ai" />
<el-option label="系统消息" value="sys" />
<el-option label="斜杠命令" value="slash" />
</el-select>
</el-form-item>
<el-form-item label="最小深度">
<el-input-number
v-model="formData.minDepth"
:min="0"
:max="100"
placeholder="不限制"
controls-position="right"
/>
<span style="margin-left: 10px; color: #909399; font-size: 12px">
仅在消息深度 此值时应用
</span>
</el-form-item>
<el-form-item label="最大深度">
<el-input-number
v-model="formData.maxDepth"
:min="0"
:max="100"
placeholder="不限制"
controls-position="right"
/>
<span style="margin-left: 10px; color: #909399; font-size: 12px">
仅在消息深度 此值时应用
</span>
</el-form-item>
<!-- 操作按钮 -->
<el-form-item>
<el-button type="primary" :loading="regexScriptStore.loading" @click="handleSubmit">
{{ isEdit ? '保存' : '创建' }}
</el-button>
<el-button @click="handleBack">取消</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import type { FormInstance, FormRules } from 'element-plus'
import {
Search,
Plus,
Edit,
Delete,
CopyDocument,
Upload,
Download,
} from '@element-plus/icons-vue'
import { useRegexScriptStore } from '@/stores/regexScript'
import type { CreateRegexScriptRequest, TestRegexScriptResponse } from '@/types/regexScript'
const router = useRouter()
const route = useRoute()
const regexScriptStore = useRegexScriptStore()
const isEdit = ref(false)
const scriptId = ref<number | null>(null)
const formRef = ref<FormInstance>()
// 表单数据
const formData = reactive<CreateRegexScriptRequest>({
scriptName: '',
description: '',
findRegex: '',
replaceString: '',
enabled: true,
isGlobal: false,
trimStrings: false,
onlyFormat: false,
runOnEdit: false,
substituteRegex: false,
placement: '',
linkedChars: [],
})
// 测试相关
const testInput = ref('')
const testResult = ref<TestRegexScriptResponse | null>(null)
const testing = ref(false)
// 表单验证规则
const rules: FormRules = {
scriptName: [{ required: true, message: '请输入脚本名称', trigger: 'blur' }],
findRegex: [
{ required: true, message: '请输入正则表达式', trigger: 'blur' },
],
}
// 正则表达式变化时重新测试
const handleRegexChange = () => {
if (testInput.value) {
testResult.value = null
}
}
// 测试正则脚本
const handleTest = async () => {
if (!formData.findRegex || !testInput.value) {
return
}
try {
testing.value = true
testResult.value = await regexScriptStore.testRegexScript({
findRegex: formData.findRegex,
replaceString: formData.replaceString || '',
testInput: testInput.value,
trimStrings: formData.trimStrings,
substituteRegex: formData.substituteRegex,
})
} catch (error) {
console.error('测试失败:', error)
} finally {
testing.value = false
}
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
try {
if (isEdit.value && scriptId.value) {
await regexScriptStore.updateRegexScript(scriptId.value, formData)
} else {
await regexScriptStore.createRegexScript(formData)
}
router.push('/regex')
} catch (error) {
console.error('保存失败:', error)
}
})
}
// 返回列表
const handleBack = () => {
router.push('/regex')
}
// 加载脚本详情
const loadScriptDetail = async (id: number) => {
try {
const script = await regexScriptStore.fetchRegexScriptDetail(id)
// 填充表单
Object.assign(formData, {
scriptName: script.scriptName,
description: script.description,
findRegex: script.findRegex,
replaceString: script.replaceString,
enabled: script.enabled,
isGlobal: script.isGlobal,
trimStrings: script.trimStrings,
onlyFormat: script.onlyFormat,
runOnEdit: script.runOnEdit,
substituteRegex: script.substituteRegex,
minDepth: script.minDepth,
maxDepth: script.maxDepth,
placement: script.placement,
affectMinDepth: script.affectMinDepth,
affectMaxDepth: script.affectMaxDepth,
linkedChars: script.linkedChars,
scriptData: script.scriptData,
})
} catch (error) {
console.error('加载脚本详情失败:', error)
router.push('/regex')
}
}
// 初始化
onMounted(() => {
const id = route.params.id
if (id && typeof id === 'string') {
isEdit.value = true
scriptId.value = parseInt(id)
loadScriptDetail(scriptId.value)
}
})
</script>
<style scoped lang="scss">
.regex-script-edit {
padding: 20px;
.page-title {
font-size: 18px;
font-weight: 500;
}
.test-result {
width: 100%;
padding: 15px;
background: #f5f7fa;
border-radius: 4px;
.result-success {
.result-row {
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
.label {
font-weight: 500;
margin-right: 10px;
color: #606266;
}
.output-text {
margin-top: 8px;
padding: 10px;
background: white;
border-radius: 4px;
white-space: pre-wrap;
word-break: break-word;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 13px;
line-height: 1.6;
}
}
}
.result-error {
:deep(.el-alert) {
background: #fef0f0;
}
}
}
}
:deep(.el-divider__text) {
font-weight: 500;
color: #303133;
}
</style>

View File

@@ -0,0 +1,314 @@
<template>
<div class="regex-script-list">
<el-card shadow="never">
<!-- 搜索和操作栏 -->
<div class="header-actions">
<div class="search-bar">
<el-input
v-model="searchParams.scriptName"
placeholder="搜索脚本名称"
style="width: 300px"
clearable
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="searchParams.enabled"
placeholder="状态"
style="width: 120px; margin-left: 10px"
clearable
@change="handleSearch"
>
<el-option label="已启用" :value="true" />
<el-option label="已禁用" :value="false" />
</el-select>
<el-select
v-model="searchParams.isGlobal"
placeholder="范围"
style="width: 120px; margin-left: 10px"
clearable
@change="handleSearch"
>
<el-option label="全局脚本" :value="true" />
<el-option label="角色脚本" :value="false" />
</el-select>
<el-button type="primary" :icon="Search" @click="handleSearch" style="margin-left: 10px">
搜索
</el-button>
</div>
<div class="action-buttons">
<el-upload
:show-file-list="false"
:before-upload="handleImport"
accept=".json"
>
<el-button :icon="Upload">导入</el-button>
</el-upload>
<el-button :icon="Download" @click="handleExport">
导出
</el-button>
<el-button type="primary" :icon="Plus" @click="handleCreate">
新建脚本
</el-button>
</div>
</div>
<!-- 脚本列表 -->
<el-table
v-loading="regexScriptStore.loading"
:data="regexScriptStore.regexScripts"
style="width: 100%; margin-top: 20px"
>
<el-table-column prop="scriptName" label="脚本名称" min-width="180">
<template #default="{ row }">
<div>
<el-tag v-if="row.isGlobal" type="warning" size="small" style="margin-right: 8px">
全局
</el-tag>
{{ row.scriptName }}
</div>
</template>
</el-table-column>
<el-table-column prop="findRegex" label="正则表达式" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<code style="font-size: 12px">{{ row.findRegex }}</code>
</template>
</el-table-column>
<el-table-column prop="replaceString" label="替换为" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<code v-if="row.replaceString" style="font-size: 12px">{{ row.replaceString }}</code>
<span v-else style="color: #ccc">-</span>
</template>
</el-table-column>
<el-table-column prop="placement" label="应用位置" width="100">
<template #default="{ row }">
<el-tag v-if="row.placement" size="small">{{ row.placement }}</el-tag>
<span v-else style="color: #ccc">全部</span>
</template>
</el-table-column>
<el-table-column prop="usageCount" label="使用次数" width="100" align="center" />
<el-table-column prop="enabled" label="状态" width="80" align="center">
<template #default="{ row }">
<el-switch
v-model="row.enabled"
@change="handleToggleEnabled(row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
text
size="small"
:icon="Edit"
@click="handleEdit(row.id)"
>
编辑
</el-button>
<el-button
type="info"
text
size="small"
:icon="CopyDocument"
@click="handleDuplicate(row.id)"
>
复制
</el-button>
<el-popconfirm
title="确定要删除这个脚本吗?"
@confirm="handleDelete(row.id)"
>
<template #reference>
<el-button
type="danger"
text
size="small"
:icon="Delete"
>
删除
</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div style="margin-top: 20px; display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="regexScriptStore.currentPage"
v-model:page-size="regexScriptStore.pageSize"
:total="regexScriptStore.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSearch"
@current-change="handleSearch"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Search,
Plus,
Edit,
Delete,
CopyDocument,
Upload,
Download,
} from '@element-plus/icons-vue'
import { useRegexScriptStore } from '@/stores/regexScript'
import type { RegexScriptListParams } from '@/types/regexScript'
const router = useRouter()
const regexScriptStore = useRegexScriptStore()
// 搜索参数
const searchParams = ref<RegexScriptListParams>({
scriptName: '',
enabled: undefined,
isGlobal: undefined,
})
// 搜索
const handleSearch = () => {
regexScriptStore.resetPagination()
regexScriptStore.fetchRegexScriptList(searchParams.value)
}
// 新建
const handleCreate = () => {
router.push('/regex/create')
}
// 编辑
const handleEdit = (id: number) => {
router.push(`/regex/edit/${id}`)
}
// 删除
const handleDelete = async (id: number) => {
try {
await regexScriptStore.deleteRegexScript(id)
} catch (error) {
console.error('删除失败:', error)
}
}
// 复制
const handleDuplicate = async (id: number) => {
try {
await regexScriptStore.duplicateRegexScript(id)
} catch (error) {
console.error('复制失败:', error)
}
}
// 切换启用状态
const handleToggleEnabled = async (row: any) => {
try {
await regexScriptStore.updateRegexScript(row.id, {
enabled: row.enabled,
})
} catch (error) {
// 恢复状态
row.enabled = !row.enabled
}
}
// 导入
const handleImport = async (file: File) => {
try {
const mode = await ElMessageBox.prompt(
'选择导入模式:\nskip - 跳过已存在的脚本\noverwrite - 覆盖已存在的脚本\nmerge - 合并(重命名)',
'导入正则脚本',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: 'skip',
inputPattern: /^(skip|overwrite|merge)$/,
inputErrorMessage: '请输入 skip、overwrite 或 merge',
}
)
await regexScriptStore.importRegexScripts(file, mode.value)
} catch (error) {
if (error !== 'cancel') {
console.error('导入失败:', error)
}
}
return false // 阻止自动上传
}
// 导出
const handleExport = async () => {
try {
await regexScriptStore.exportRegexScripts()
} catch (error) {
console.error('导出失败:', error)
}
}
// 初始化
onMounted(() => {
regexScriptStore.fetchRegexScriptList()
})
</script>
<style scoped lang="scss">
.regex-script-list {
padding: 20px;
.header-actions {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
.search-bar {
display: flex;
align-items: center;
flex: 1;
min-width: 400px;
}
.action-buttons {
display: flex;
gap: 10px;
}
}
}
code {
background: #f5f7fa;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
}
</style>

View File

@@ -0,0 +1,302 @@
<template>
<div class="world-book-edit">
<!-- 页面标题 -->
<div class="page-header">
<h2>{{ isEdit ? '编辑世界书' : '创建世界书' }}</h2>
<div class="actions">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" :loading="worldInfoStore.loading" @click="handleSave">
保存
</el-button>
</div>
</div>
<!-- 世界书基本信息 -->
<el-card class="info-card">
<template #header>
<span>基本信息</span>
</template>
<el-form :model="formData" :rules="rules" ref="formRef" label-width="120px">
<el-form-item label="世界书名称" prop="bookName">
<el-input
v-model="formData.bookName"
placeholder="请输入世界书名称"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="类型">
<el-switch
v-model="formData.isGlobal"
active-text="全局世界书"
inactive-text="角色专属"
/>
<el-tooltip
content="全局世界书将应用于所有角色,角色专属世界书需要手动关联角色"
placement="right"
>
<el-icon style="margin-left: 8px"><QuestionFilled /></el-icon>
</el-tooltip>
</el-form-item>
</el-form>
</el-card>
<!-- 条目列表 -->
<el-card class="entries-card">
<template #header>
<div class="card-header">
<span>条目列表{{ formData.entries.length }}</span>
<el-button type="primary" size="small" :icon="Plus" @click="handleAddEntry">
添加条目
</el-button>
</div>
</template>
<!-- 空状态 -->
<el-empty
v-if="formData.entries.length === 0"
description="暂无条目,点击上方按钮添加"
/>
<!-- 条目列表 -->
<div v-else class="entry-list">
<el-collapse v-model="activeEntries" accordion>
<el-collapse-item
v-for="(entry, index) in formData.entries"
:key="entry.uid"
:name="entry.uid"
>
<template #title>
<div class="entry-title">
<el-tag :type="entry.enabled ? 'success' : 'info'" size="small">
{{ entry.enabled ? '启用' : '禁用' }}
</el-tag>
<span class="entry-index">#{{ index + 1 }}</span>
<span class="entry-keys">{{ formatKeys(entry.keys) }}</span>
<el-tag v-if="entry.constant" type="warning" size="small">常驻</el-tag>
</div>
</template>
<!-- 条目编辑表单 -->
<WorldInfoEntryForm
:entry="entry"
@update="(updated) => handleUpdateEntry(index, updated)"
@delete="() => handleDeleteEntry(index)"
/>
</el-collapse-item>
</el-collapse>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, QuestionFilled } from '@element-plus/icons-vue'
import { useWorldInfoStore } from '@/stores/worldInfo'
import type { CreateWorldBookRequest, WorldInfoEntry } from '@/types/worldInfo'
import { v4 as uuidv4 } from 'uuid'
import WorldInfoEntryForm from './WorldInfoEntryForm.vue'
const route = useRoute()
const router = useRouter()
const worldInfoStore = useWorldInfoStore()
// 表单引用
const formRef = ref()
// 是否为编辑模式
const isEdit = computed(() => !!route.params.id)
// 展开的条目
const activeEntries = ref<string[]>([])
// 表单数据
const formData = ref<CreateWorldBookRequest>({
bookName: '',
isGlobal: false,
entries: [],
linkedChars: []
})
// 表单验证规则
const rules = {
bookName: [
{ required: true, message: '请输入世界书名称', trigger: 'blur' },
{ min: 1, max: 500, message: '长度在 1 到 500 个字符', trigger: 'blur' }
]
}
// 格式化关键词显示
const formatKeys = (keys: string[]) => {
if (!keys || keys.length === 0) return '无关键词'
return keys.slice(0, 3).join(', ') + (keys.length > 3 ? '...' : '')
}
// 创建默认条目
const createDefaultEntry = (): WorldInfoEntry => ({
uid: uuidv4(),
keys: [],
secondary_keys: [],
content: '',
comment: '',
enabled: true,
constant: false,
selective: false,
order: formData.value.entries.length,
position: 'before_char',
depth: 4,
probability: 100,
use_probability: false,
group: '',
group_override: false,
group_weight: 0,
prevent_recursion: false,
delay_until_recursion: false,
scan_depth: null,
case_sensitive: null,
match_whole_words: null,
use_regex: null,
automation_id: '',
role: '',
vectorized: '',
extensions: {}
})
// 添加条目
const handleAddEntry = () => {
const newEntry = createDefaultEntry()
formData.value.entries.push(newEntry)
activeEntries.value = [newEntry.uid]
}
// 更新条目
const handleUpdateEntry = (index: number, updatedEntry: WorldInfoEntry) => {
formData.value.entries[index] = updatedEntry
}
// 删除条目
const handleDeleteEntry = async (index: number) => {
try {
await ElMessageBox.confirm('确定要删除此条目吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
formData.value.entries.splice(index, 1)
} catch (error) {
// 用户取消
}
}
// 保存
const handleSave = async () => {
try {
// 验证表单
await formRef.value.validate()
// 验证至少有一个条目
if (formData.value.entries.length === 0) {
ElMessage.warning('请至少添加一个条目')
return
}
// 保存
if (isEdit.value) {
await worldInfoStore.updateWorldBook(Number(route.params.id), {
bookName: formData.value.bookName,
isGlobal: formData.value.isGlobal,
entries: formData.value.entries,
linkedChars: formData.value.linkedChars
})
} else {
await worldInfoStore.createWorldBook(formData.value)
}
router.push('/worldbook')
} catch (error) {
console.error('保存失败:', error)
}
}
// 取消
const handleCancel = () => {
router.back()
}
// 初始化
onMounted(async () => {
if (isEdit.value) {
try {
const book = await worldInfoStore.fetchWorldBookDetail(Number(route.params.id))
formData.value = {
bookName: book.bookName,
isGlobal: book.isGlobal,
entries: book.entries || [],
linkedChars: book.linkedChars || []
}
} catch (error) {
ElMessage.error('加载世界书失败')
router.push('/worldbook')
}
}
})
</script>
<style scoped lang="scss">
.world-book-edit {
padding: 20px;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 24px;
color: #303133;
}
.actions {
display: flex;
gap: 10px;
}
}
.info-card {
margin-bottom: 20px;
}
.entries-card {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.entry-list {
.entry-title {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
.entry-index {
font-weight: bold;
color: #909399;
}
.entry-keys {
color: #606266;
flex: 1;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,236 @@
<template>
<div class="entry-form">
<el-form :model="localEntry" label-width="140px" size="default">
<!-- 基础信息 -->
<el-divider content-position="left">基础设置</el-divider>
<el-form-item label="启用状态">
<el-switch v-model="localEntry.enabled" active-text="启用" inactive-text="禁用" />
</el-form-item>
<el-form-item label="主要关键词" required>
<el-select
v-model="localEntry.keys"
multiple
filterable
allow-create
default-first-option
placeholder="输入关键词后按回车添加"
style="width: 100%"
>
</el-select>
<div class="form-tip">当消息中包含这些关键词时条目将被激活</div>
</el-form-item>
<el-form-item label="次要关键词">
<el-select
v-model="localEntry.secondary_keys"
multiple
filterable
allow-create
default-first-option
placeholder="输入关键词后按回车添加(可选)"
style="width: 100%"
>
</el-select>
<div class="form-tip">启用"选择性激活"需同时匹配主要和次要关键词</div>
</el-form-item>
<el-form-item label="条目内容" required>
<el-input
v-model="localEntry.content"
type="textarea"
:rows="6"
placeholder="输入条目内容,这将被插入到提示词中"
maxlength="5000"
show-word-limit
/>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="localEntry.comment"
placeholder="添加备注(不会被插入到提示词中)"
maxlength="500"
/>
</el-form-item>
<!-- 激活设置 -->
<el-divider content-position="left">激活设置</el-divider>
<el-form-item label="常驻激活">
<el-switch v-model="localEntry.constant" active-text="是" inactive-text="否" />
<div class="form-tip">启用后此条目将始终激活忽略关键词匹配</div>
</el-form-item>
<el-form-item label="选择性激活">
<el-switch v-model="localEntry.selective" active-text="是" inactive-text="否" />
<div class="form-tip">启用后需要同时匹配主要和次要关键词才能激活</div>
</el-form-item>
<el-form-item label="使用概率">
<el-switch v-model="localEntry.use_probability" active-text="是" inactive-text="否" />
</el-form-item>
<el-form-item v-if="localEntry.use_probability" label="激活概率">
<el-slider v-model="localEntry.probability" :min="0" :max="100" show-input />
<div class="form-tip">即使匹配成功也有 {{ localEntry.probability }}% 的概率激活</div>
</el-form-item>
<!-- 匹配设置 -->
<el-divider content-position="left">匹配设置</el-divider>
<el-form-item label="大小写敏感">
<el-select v-model="localEntry.case_sensitive" placeholder="使用全局设置">
<el-option label="使用全局设置" :value="null" />
<el-option label="是" :value="true" />
<el-option label="否" :value="false" />
</el-select>
</el-form-item>
<el-form-item label="匹配整词">
<el-select v-model="localEntry.match_whole_words" placeholder="使用全局设置">
<el-option label="使用全局设置" :value="null" />
<el-option label="是" :value="true" />
<el-option label="否" :value="false" />
</el-select>
<div class="form-tip">启用后"world" 不会匹配 "worldbuilding"</div>
</el-form-item>
<el-form-item label="使用正则表达式">
<el-select v-model="localEntry.use_regex" placeholder="使用全局设置">
<el-option label="使用全局设置" :value="null" />
<el-option label="是" :value="true" />
<el-option label="否" :value="false" />
</el-select>
<div class="form-tip">启用后关键词将被视为正则表达式</div>
</el-form-item>
<el-form-item label="扫描深度">
<el-input-number
v-model="localEntry.scan_depth"
:min="0"
:max="100"
placeholder="使用全局设置"
/>
<div class="form-tip">从最近消息往前扫描几条null=使用全局设置</div>
</el-form-item>
<!-- 插入设置 -->
<el-divider content-position="left">插入设置</el-divider>
<el-form-item label="插入顺序">
<el-input-number v-model="localEntry.order" :min="0" :max="999" />
<div class="form-tip">数字越小越靠前</div>
</el-form-item>
<el-form-item label="插入位置">
<el-select v-model="localEntry.position">
<el-option label="在角色定义之前" value="before_char" />
<el-option label="在角色定义之后" value="after_char" />
</el-select>
</el-form-item>
<el-form-item label="角色">
<el-select v-model="localEntry.role" placeholder="默认" clearable>
<el-option label="系统" value="system" />
<el-option label="用户" value="user" />
<el-option label="助手" value="assistant" />
</el-select>
<div class="form-tip">指定此条目的角色类型</div>
</el-form-item>
<!-- 递归设置 -->
<el-divider content-position="left">递归设置</el-divider>
<el-form-item label="防止递归">
<el-switch v-model="localEntry.prevent_recursion" active-text="是" inactive-text="否" />
<div class="form-tip">防止此条目在递归激活中被触发</div>
</el-form-item>
<el-form-item label="延迟递归">
<el-switch v-model="localEntry.delay_until_recursion" active-text="是" inactive-text="否" />
<div class="form-tip">仅在递归激活时触发此条目</div>
</el-form-item>
<!-- 分组设置 -->
<el-divider content-position="left">分组设置</el-divider>
<el-form-item label="分组">
<el-input v-model="localEntry.group" placeholder="留空表示不分组" />
<div class="form-tip">同一分组中只会激活一个条目按权重选择</div>
</el-form-item>
<el-form-item v-if="localEntry.group" label="分组权重">
<el-input-number v-model="localEntry.group_weight" :min="0" :max="999" />
<div class="form-tip">数字越大优先级越高</div>
</el-form-item>
<el-form-item v-if="localEntry.group" label="分组覆盖">
<el-switch v-model="localEntry.group_override" active-text="是" inactive-text="否" />
</el-form-item>
<!-- 操作按钮 -->
<el-divider />
<el-form-item>
<el-button type="primary" @click="handleUpdate">保存更改</el-button>
<el-button type="danger" @click="handleDelete">删除条目</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { WorldInfoEntry } from '@/types/worldInfo'
const props = defineProps<{
entry: WorldInfoEntry
}>()
const emit = defineEmits<{
update: [entry: WorldInfoEntry]
delete: []
}>()
// 本地编辑副本
const localEntry = ref<WorldInfoEntry>({ ...props.entry })
// 监听 props 变化
watch(
() => props.entry,
(newEntry) => {
localEntry.value = { ...newEntry }
},
{ deep: true }
)
// 保存更改
const handleUpdate = () => {
emit('update', { ...localEntry.value })
}
// 删除条目
const handleDelete = () => {
emit('delete')
}
</script>
<style scoped lang="scss">
.entry-form {
padding: 20px;
background-color: #f5f7fa;
border-radius: 4px;
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
:deep(.el-divider__text) {
font-weight: bold;
color: #409eff;
}
}
</style>