🎨 优化扩展模块,完成ai接入和对话功能
This commit is contained in:
@@ -8,6 +8,9 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<!-- SillyTavern 扩展兼容:全局扩展设置容器(扩展 JS 会用 jQuery 往这里追加 UI) -->
|
||||
<div id="extensions_settings" style="display:none;"></div>
|
||||
<div id="extensions_settings2" style="display:none;"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
27
web-app-vue/package-lock.json
generated
27
web-app-vue/package-lock.json
generated
@@ -8,16 +8,20 @@
|
||||
"name": "web-app-vue",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@types/lodash": "^4.17.23",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"axios": "^1.13.5",
|
||||
"element-plus": "^2.13.2",
|
||||
"jquery": "^4.0.0",
|
||||
"lodash": "^4.17.23",
|
||||
"pinia": "^3.0.4",
|
||||
"uuid": "^13.0.0",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jquery": "^3.5.33",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
@@ -1294,6 +1298,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jquery": {
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.33.tgz",
|
||||
"integrity": "sha512-SeyVJXlCZpEki5F0ghuYe+L+PprQta6nRZqhONt9F13dWBtR/ftoaIbdRQ7cis7womE+X2LKhsDdDtkkDhJS6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/sizzle": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
|
||||
@@ -1319,6 +1333,13 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sizzle": {
|
||||
"version": "2.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz",
|
||||
"integrity": "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
@@ -2204,6 +2225,12 @@
|
||||
"url": "https://github.com/sponsors/mesqueeb"
|
||||
}
|
||||
},
|
||||
"node_modules/jquery": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz",
|
||||
"integrity": "sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||
|
||||
@@ -9,16 +9,20 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/lodash": "^4.17.23",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"axios": "^1.13.5",
|
||||
"element-plus": "^2.13.2",
|
||||
"jquery": "^4.0.0",
|
||||
"lodash": "^4.17.23",
|
||||
"pinia": "^3.0.4",
|
||||
"uuid": "^13.0.0",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jquery": "^3.5.33",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
|
||||
140
web-app-vue/src/api/chat.ts
Normal file
140
web-app-vue/src/api/chat.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// ==================== 对话管理 ====================
|
||||
|
||||
/**
|
||||
* 创建对话
|
||||
*/
|
||||
export function createChat(data: CreateChatRequest) {
|
||||
return request.post<Chat>('/app/chat', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对话列表
|
||||
*/
|
||||
export function getChatList(params: { page: number; pageSize: number }) {
|
||||
return request.get<ChatListResponse>('/app/chat/list', { params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对话详情(含消息)
|
||||
*/
|
||||
export function getChatDetail(id: number) {
|
||||
return request.get<ChatDetailResponse>(`/app/chat/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对话消息
|
||||
*/
|
||||
export function getChatMessages(id: number, params: { page: number; pageSize: number }) {
|
||||
return request.get<MessageListResponse>(`/app/chat/${id}/messages`, { params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除对话
|
||||
*/
|
||||
export function deleteChat(id: number) {
|
||||
return request.delete(`/app/chat/${id}`)
|
||||
}
|
||||
|
||||
// ==================== 消息操作 ====================
|
||||
|
||||
/**
|
||||
* 发送消息(SSE 流式响应)
|
||||
* 返回 EventSource 连接
|
||||
*/
|
||||
export function sendMessageSSE(
|
||||
data: SendMessageRequest,
|
||||
onContent: (content: string) => void,
|
||||
onDone: (event: SSEEvent) => void,
|
||||
onError: (error: string) => void,
|
||||
): AbortController {
|
||||
const controller = new AbortController()
|
||||
const token = localStorage.getItem('st_access_token') || ''
|
||||
const baseURL = (import.meta as any).env?.VITE_API_BASE_URL || 'http://localhost:8888'
|
||||
|
||||
fetch(`${baseURL}/app/chat/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-token': token,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
try {
|
||||
const json = JSON.parse(text)
|
||||
onError(json.msg || '请求失败')
|
||||
} catch {
|
||||
onError(`请求失败 (HTTP ${response.status})`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) {
|
||||
onError('无法读取响应流')
|
||||
return
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
const jsonStr = line.slice(6).trim()
|
||||
if (!jsonStr) continue
|
||||
|
||||
try {
|
||||
const event: SSEEvent = JSON.parse(jsonStr)
|
||||
|
||||
switch (event.type) {
|
||||
case 'content':
|
||||
if (event.content) onContent(event.content)
|
||||
break
|
||||
case 'done':
|
||||
onDone(event)
|
||||
break
|
||||
case 'error':
|
||||
onError(event.error || '未知错误')
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name !== 'AbortError') {
|
||||
onError(err.message || '网络错误')
|
||||
}
|
||||
})
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑消息
|
||||
*/
|
||||
export function editMessage(data: { messageId: number; content: string }) {
|
||||
return request.post<ChatMessage>('/app/chat/message/edit', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息
|
||||
*/
|
||||
export function deleteMessage(data: { messageId: number }) {
|
||||
return request.post('/app/chat/message/delete', data)
|
||||
}
|
||||
134
web-app-vue/src/api/provider.ts
Normal file
134
web-app-vue/src/api/provider.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// ==================== 提供商 CRUD ====================
|
||||
|
||||
/**
|
||||
* 创建AI提供商
|
||||
*/
|
||||
export function createProvider(data: CreateProviderRequest) {
|
||||
return request.post<AIProvider>('/app/provider', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取AI提供商列表
|
||||
*/
|
||||
export function getProviderList(params: ProviderListParams) {
|
||||
return request.get<ProviderListResponse>('/app/provider/list', { params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取AI提供商详情
|
||||
*/
|
||||
export function getProviderDetail(id: number) {
|
||||
return request.get<AIProvider>(`/app/provider/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新AI提供商
|
||||
*/
|
||||
export function updateProvider(data: UpdateProviderRequest) {
|
||||
return request.put<AIProvider>('/app/provider', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除AI提供商
|
||||
*/
|
||||
export function deleteProvider(id: number) {
|
||||
return request.delete(`/app/provider/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置默认提供商
|
||||
*/
|
||||
export function setDefaultProvider(providerId: number) {
|
||||
return request.post('/app/provider/setDefault', { providerId })
|
||||
}
|
||||
|
||||
// ==================== 连通性测试 ====================
|
||||
|
||||
/**
|
||||
* 测试提供商连通性(不需要先保存)
|
||||
*/
|
||||
export function testProvider(data: TestProviderRequest) {
|
||||
return request.post<TestProviderResponse>('/app/provider/test', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试已保存的提供商连通性
|
||||
*/
|
||||
export function testExistingProvider(id: number) {
|
||||
return request.get<TestProviderResponse>(`/app/provider/test/${id}`)
|
||||
}
|
||||
|
||||
// ==================== 模型管理 ====================
|
||||
|
||||
/**
|
||||
* 添加AI模型
|
||||
*/
|
||||
export function addModel(data: CreateModelRequest) {
|
||||
return request.post<AIModel>('/app/provider/model', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新AI模型
|
||||
*/
|
||||
export function updateModel(data: UpdateModelRequest) {
|
||||
return request.put<AIModel>('/app/provider/model', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除AI模型
|
||||
*/
|
||||
export function deleteModel(id: number) {
|
||||
return request.delete(`/app/provider/model/${id}`)
|
||||
}
|
||||
|
||||
// ==================== 远程模型获取 ====================
|
||||
|
||||
/**
|
||||
* 获取远程可用模型列表(不需要先保存)
|
||||
*/
|
||||
export function fetchRemoteModels(data: { providerType: string; baseUrl?: string; apiKey: string }) {
|
||||
return request.post<FetchRemoteModelsResponse>('/app/provider/fetchModels', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已保存提供商的远程可用模型列表
|
||||
*/
|
||||
export function fetchRemoteModelsExisting(id: number) {
|
||||
return request.get<FetchRemoteModelsResponse>(`/app/provider/fetchModels/${id}`)
|
||||
}
|
||||
|
||||
// ==================== 测试消息 ====================
|
||||
|
||||
/**
|
||||
* 发送测试消息(不需要先保存)
|
||||
*/
|
||||
export function sendTestMessage(data: { providerType: string; baseUrl?: string; apiKey: string; modelName: string; message?: string }) {
|
||||
return request.post<SendTestMessageResponse>('/app/provider/testMessage', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用已保存的提供商发送测试消息
|
||||
*/
|
||||
export function sendTestMessageExisting(id: number, data: { modelName: string; message?: string }) {
|
||||
return request.post<SendTestMessageResponse>(`/app/provider/testMessage/${id}`, data)
|
||||
}
|
||||
|
||||
// ==================== 辅助接口 ====================
|
||||
|
||||
/**
|
||||
* 获取支持的提供商类型列表
|
||||
*/
|
||||
export function getProviderTypes() {
|
||||
return request.get<ProviderTypeOption[]>('/app/provider/types')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预设模型列表
|
||||
*/
|
||||
export function getPresetModels(providerType: string) {
|
||||
return request.get<PresetModelOption[]>('/app/provider/presetModels', {
|
||||
params: { type: providerType }
|
||||
})
|
||||
}
|
||||
6
web-app-vue/src/components.d.ts
vendored
6
web-app-vue/src/components.d.ts
vendored
@@ -16,6 +16,7 @@ declare module 'vue' {
|
||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
@@ -39,9 +40,14 @@ declare module 'vue' {
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPageHeader: typeof import('element-plus/es')['ElPageHeader']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
|
||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElSlider: typeof import('element-plus/es')['ElSlider']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
title="扩展管理"
|
||||
:size="600"
|
||||
direction="rtl"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="extension-drawer-content">
|
||||
<!-- 扩展注入的设置面板(从全局 #extensions_settings 搬入显示) -->
|
||||
<div v-show="hasExtensionSettingsUI" class="extensions-settings-section">
|
||||
<el-divider content-position="left">扩展设置面板</el-divider>
|
||||
<div ref="settingsMountRef" class="extensions-settings-mount"></div>
|
||||
</div>
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
@@ -319,7 +323,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { ref, reactive, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useExtensionStore } from '@/stores/extension'
|
||||
import { extensionRuntime } from '@/utils/extensionRuntime'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
@@ -369,6 +373,62 @@ const installForm = reactive({
|
||||
branch: 'main',
|
||||
})
|
||||
|
||||
// 扩展设置面板挂载点
|
||||
const settingsMountRef = ref<HTMLElement | null>(null)
|
||||
const hasExtensionSettingsUI = ref(false)
|
||||
|
||||
/**
|
||||
* 将全局 #extensions_settings 容器中的扩展 UI 搬入 Drawer 中显示
|
||||
* 原版 SillyTavern 扩展通过 jQuery 向 #extensions_settings 追加自定义 HTML,
|
||||
* 我们在 Drawer 打开时把这些内容移入显示,关闭时归还。
|
||||
*/
|
||||
const mountExtensionSettings = () => {
|
||||
const globalContainer = document.getElementById('extensions_settings')
|
||||
const globalContainer2 = document.getElementById('extensions_settings2')
|
||||
const mountPoint = settingsMountRef.value
|
||||
if (!mountPoint) return
|
||||
|
||||
const hasContent = (globalContainer && globalContainer.children.length > 0) ||
|
||||
(globalContainer2 && globalContainer2.children.length > 0)
|
||||
|
||||
if (hasContent) {
|
||||
// 将全局容器移入 Drawer 挂载点中(保持 DOM 引用不变,扩展的事件绑定不会丢失)
|
||||
if (globalContainer && globalContainer.children.length > 0) {
|
||||
globalContainer.style.display = ''
|
||||
mountPoint.appendChild(globalContainer)
|
||||
}
|
||||
if (globalContainer2 && globalContainer2.children.length > 0) {
|
||||
globalContainer2.style.display = ''
|
||||
mountPoint.appendChild(globalContainer2)
|
||||
}
|
||||
hasExtensionSettingsUI.value = true
|
||||
} else {
|
||||
hasExtensionSettingsUI.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将扩展设置容器归还到 body(Drawer 关闭时调用)
|
||||
*/
|
||||
const unmountExtensionSettings = () => {
|
||||
const globalContainer = document.getElementById('extensions_settings')
|
||||
const globalContainer2 = document.getElementById('extensions_settings2')
|
||||
|
||||
if (globalContainer) {
|
||||
globalContainer.style.display = 'none'
|
||||
document.body.appendChild(globalContainer)
|
||||
}
|
||||
if (globalContainer2) {
|
||||
globalContainer2.style.display = 'none'
|
||||
document.body.appendChild(globalContainer2)
|
||||
}
|
||||
}
|
||||
|
||||
// 组件卸载时归还容器
|
||||
onBeforeUnmount(() => {
|
||||
unmountExtensionSettings()
|
||||
})
|
||||
|
||||
// Computed
|
||||
const filteredExtensions = computed(() => {
|
||||
let extensions = extensionStore.extensions
|
||||
@@ -661,10 +721,15 @@ watch(
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
// 当抽屉打开时刷新列表
|
||||
watch(drawerVisible, (visible) => {
|
||||
// 当抽屉打开时刷新列表并挂载扩展设置 UI
|
||||
watch(drawerVisible, async (visible) => {
|
||||
if (visible) {
|
||||
handleRefresh()
|
||||
// 等待 DOM 更新后再挂载扩展设置面板
|
||||
await nextTick()
|
||||
setTimeout(mountExtensionSettings, 100)
|
||||
} else {
|
||||
unmountExtensionSettings()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -685,6 +750,30 @@ watch(drawerVisible, (visible) => {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.extensions-settings-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.extensions-settings-mount {
|
||||
width: 100%;
|
||||
|
||||
:deep(#extensions_settings),
|
||||
:deep(#extensions_settings2) {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.extension_container) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.inline-drawer) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.extension-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -29,6 +29,14 @@
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
<span>正则脚本</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="authStore.isLoggedIn" index="/chats">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
<span>对话</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="authStore.isLoggedIn" index="/ai-config">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>AI 配置</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
|
||||
<!-- 扩展快捷按钮 -->
|
||||
@@ -87,7 +95,7 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Grid, Files, Reading, Connection, MagicStick } from '@element-plus/icons-vue'
|
||||
import { Grid, Files, Reading, Connection, MagicStick, Setting, ChatDotRound } from '@element-plus/icons-vue'
|
||||
import ExtensionDrawer from '@/components/ExtensionDrawer.vue'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -105,6 +113,8 @@ 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'
|
||||
if (route.path.startsWith('/chats') || route.path.startsWith('/chat/')) return '/chats'
|
||||
if (route.path.startsWith('/ai-config')) return '/ai-config'
|
||||
return '/'
|
||||
})
|
||||
|
||||
|
||||
@@ -7,6 +7,14 @@ import router from './router'
|
||||
import App from './App.vue'
|
||||
import './assets/styles/index.scss'
|
||||
|
||||
// SillyTavern 扩展兼容:注入 jQuery 到全局(扩展 JS 依赖 $ 和 jQuery)
|
||||
import jQuery from 'jquery'
|
||||
; (window as any).$ = jQuery
|
||||
; (window as any).jQuery = jQuery
|
||||
|
||||
// 引入兼容层 (lodash, toastr)
|
||||
import '@/utils/compatibility'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
|
||||
@@ -106,6 +106,24 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/regex/RegexScriptEdit.vue'),
|
||||
meta: { title: '编辑正则脚本', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'ai-config',
|
||||
name: 'AIConfig',
|
||||
component: () => import('@/views/provider/ProviderList.vue'),
|
||||
meta: { title: 'AI 接口配置', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'chats',
|
||||
name: 'ChatList',
|
||||
component: () => import('@/views/chat/ChatList.vue'),
|
||||
meta: { title: '我的对话', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'chat/:id',
|
||||
name: 'ChatView',
|
||||
component: () => import('@/views/chat/ChatView.vue'),
|
||||
meta: { title: '对话', requiresAuth: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
283
web-app-vue/src/stores/provider.ts
Normal file
283
web-app-vue/src/stores/provider.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as providerApi from '@/api/provider'
|
||||
|
||||
export const useProviderStore = defineStore('provider', () => {
|
||||
// 状态
|
||||
const providers = ref<AIProvider[]>([])
|
||||
const providerTypes = ref<ProviderTypeOption[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const testLoading = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const defaultProvider = computed(() => providers.value.find(p => p.isDefault))
|
||||
const enabledProviders = computed(() => providers.value.filter(p => p.isEnabled))
|
||||
const hasProvider = computed(() => providers.value.length > 0)
|
||||
|
||||
// 获取所有可用的聊天模型(跨提供商)
|
||||
const chatModels = computed(() => {
|
||||
const models: Array<AIModel & { providerName: string }> = []
|
||||
for (const provider of enabledProviders.value) {
|
||||
for (const model of provider.models) {
|
||||
if (model.modelType === 'chat' && model.isEnabled) {
|
||||
models.push({ ...model, providerName: provider.providerName })
|
||||
}
|
||||
}
|
||||
}
|
||||
return models
|
||||
})
|
||||
|
||||
// 获取所有可用的绘图模型(跨提供商)
|
||||
const imageModels = computed(() => {
|
||||
const models: Array<AIModel & { providerName: string }> = []
|
||||
for (const provider of enabledProviders.value) {
|
||||
for (const model of provider.models) {
|
||||
if (model.modelType === 'image_gen' && model.isEnabled) {
|
||||
models.push({ ...model, providerName: provider.providerName })
|
||||
}
|
||||
}
|
||||
}
|
||||
return models
|
||||
})
|
||||
|
||||
// ==================== 提供商操作 ====================
|
||||
|
||||
/** 获取提供商列表 */
|
||||
async function fetchProviders() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await providerApi.getProviderList({ page: 1, pageSize: 100 }) as any
|
||||
providers.value = data.list
|
||||
total.value = data.total
|
||||
} catch {
|
||||
// 错误已在 request.ts 中处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建提供商 */
|
||||
async function createProvider(reqData: CreateProviderRequest) {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await providerApi.createProvider(reqData) as any
|
||||
await fetchProviders() // 重新加载列表
|
||||
ElMessage.success('创建成功')
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新提供商 */
|
||||
async function updateProvider(reqData: UpdateProviderRequest) {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await providerApi.updateProvider(reqData) as any
|
||||
await fetchProviders()
|
||||
ElMessage.success('更新成功')
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除提供商 */
|
||||
async function deleteProvider(id: number) {
|
||||
loading.value = true
|
||||
try {
|
||||
await providerApi.deleteProvider(id)
|
||||
await fetchProviders()
|
||||
ElMessage.success('删除成功')
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置默认提供商 */
|
||||
async function setDefault(providerId: number) {
|
||||
try {
|
||||
await providerApi.setDefaultProvider(providerId)
|
||||
await fetchProviders()
|
||||
ElMessage.success('已设为默认')
|
||||
} catch {
|
||||
// 错误已处理
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 连通性测试 ====================
|
||||
|
||||
/** 测试提供商连通性(新建时用) */
|
||||
async function testConnection(reqData: TestProviderRequest): Promise<TestProviderResponse | null> {
|
||||
testLoading.value = true
|
||||
try {
|
||||
const { data } = await providerApi.testProvider(reqData) as any
|
||||
return data as TestProviderResponse
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
testLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 测试已保存的提供商连通性 */
|
||||
async function testExisting(id: number): Promise<TestProviderResponse | null> {
|
||||
testLoading.value = true
|
||||
try {
|
||||
const { data } = await providerApi.testExistingProvider(id) as any
|
||||
return data as TestProviderResponse
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
testLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 模型操作 ====================
|
||||
|
||||
/** 添加模型 */
|
||||
async function addModel(reqData: CreateModelRequest) {
|
||||
try {
|
||||
const { data } = await providerApi.addModel(reqData) as any
|
||||
await fetchProviders()
|
||||
ElMessage.success('添加成功')
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新模型 */
|
||||
async function updateModel(reqData: UpdateModelRequest) {
|
||||
try {
|
||||
const { data } = await providerApi.updateModel(reqData) as any
|
||||
await fetchProviders()
|
||||
ElMessage.success('更新成功')
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除模型 */
|
||||
async function deleteModel(id: number) {
|
||||
try {
|
||||
await providerApi.deleteModel(id)
|
||||
await fetchProviders()
|
||||
ElMessage.success('删除成功')
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 辅助数据 ====================
|
||||
|
||||
/** 获取支持的提供商类型 */
|
||||
async function fetchProviderTypes() {
|
||||
if (providerTypes.value.length > 0) return // 缓存
|
||||
try {
|
||||
const { data } = await providerApi.getProviderTypes() as any
|
||||
providerTypes.value = data
|
||||
} catch {
|
||||
// 错误已处理
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取预设模型 */
|
||||
async function fetchPresetModels(providerType: string): Promise<PresetModelOption[]> {
|
||||
try {
|
||||
const { data } = await providerApi.getPresetModels(providerType) as any
|
||||
return data as PresetModelOption[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 远程模型获取 ====================
|
||||
|
||||
/** 获取远程可用模型列表(用于新建时) */
|
||||
async function fetchRemoteModels(reqData: { providerType: string; baseUrl?: string; apiKey: string }): Promise<FetchRemoteModelsResponse | null> {
|
||||
try {
|
||||
const { data } = await providerApi.fetchRemoteModels(reqData) as any
|
||||
return data as FetchRemoteModelsResponse
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取已保存提供商的远程模型列表 */
|
||||
async function fetchRemoteModelsExisting(id: number): Promise<FetchRemoteModelsResponse | null> {
|
||||
try {
|
||||
const { data } = await providerApi.fetchRemoteModelsExisting(id) as any
|
||||
return data as FetchRemoteModelsResponse
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 测试消息 ====================
|
||||
|
||||
/** 发送测试消息(用于新建时) */
|
||||
async function sendTestMessage(reqData: { providerType: string; baseUrl?: string; apiKey: string; modelName: string; message?: string }): Promise<SendTestMessageResponse | null> {
|
||||
try {
|
||||
const { data } = await providerApi.sendTestMessage(reqData) as any
|
||||
return data as SendTestMessageResponse
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 使用已保存的提供商发送测试消息 */
|
||||
async function sendTestMessageExisting(id: number, modelName: string, message?: string): Promise<SendTestMessageResponse | null> {
|
||||
try {
|
||||
const { data } = await providerApi.sendTestMessageExisting(id, { modelName, message }) as any
|
||||
return data as SendTestMessageResponse
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
providers,
|
||||
providerTypes,
|
||||
total,
|
||||
loading,
|
||||
testLoading,
|
||||
|
||||
// 计算属性
|
||||
defaultProvider,
|
||||
enabledProviders,
|
||||
hasProvider,
|
||||
chatModels,
|
||||
imageModels,
|
||||
|
||||
// 方法
|
||||
fetchProviders,
|
||||
createProvider,
|
||||
updateProvider,
|
||||
deleteProvider,
|
||||
setDefault,
|
||||
testConnection,
|
||||
testExisting,
|
||||
addModel,
|
||||
updateModel,
|
||||
deleteModel,
|
||||
fetchProviderTypes,
|
||||
fetchPresetModels,
|
||||
fetchRemoteModels,
|
||||
fetchRemoteModelsExisting,
|
||||
sendTestMessage,
|
||||
sendTestMessageExisting,
|
||||
}
|
||||
})
|
||||
86
web-app-vue/src/types/chat.d.ts
vendored
Normal file
86
web-app-vue/src/types/chat.d.ts
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 对话与消息相关类型定义
|
||||
*/
|
||||
|
||||
/** 对话 */
|
||||
interface Chat {
|
||||
id: number
|
||||
title: string
|
||||
characterId: number | null
|
||||
characterName: string
|
||||
characterAvatar: string
|
||||
chatType: string
|
||||
lastMessageAt: string | null
|
||||
messageCount: number
|
||||
isPinned: boolean
|
||||
lastMessage?: MessageBrief
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 消息摘要 */
|
||||
interface MessageBrief {
|
||||
content: string
|
||||
role: string
|
||||
}
|
||||
|
||||
/** 消息 */
|
||||
interface ChatMessage {
|
||||
id: number
|
||||
chatId: number
|
||||
content: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
characterId?: number
|
||||
characterName?: string
|
||||
model?: string
|
||||
promptTokens?: number
|
||||
completionTokens?: number
|
||||
totalTokens?: number
|
||||
sequenceNumber: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 对话列表响应 */
|
||||
interface ChatListResponse {
|
||||
list: Chat[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 消息列表响应 */
|
||||
interface MessageListResponse {
|
||||
list: ChatMessage[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 对话详情响应 */
|
||||
interface ChatDetailResponse {
|
||||
chat: Chat
|
||||
messages: ChatMessage[]
|
||||
}
|
||||
|
||||
/** 创建对话请求 */
|
||||
interface CreateChatRequest {
|
||||
characterId: number
|
||||
title?: string
|
||||
}
|
||||
|
||||
/** 发送消息请求 */
|
||||
interface SendMessageRequest {
|
||||
chatId: number
|
||||
content: string
|
||||
providerId?: number
|
||||
modelName?: string
|
||||
}
|
||||
|
||||
/** SSE 流式事件 */
|
||||
interface SSEEvent {
|
||||
type: 'content' | 'done' | 'error'
|
||||
content?: string
|
||||
model?: string
|
||||
promptTokens?: number
|
||||
completionTokens?: number
|
||||
error?: string
|
||||
}
|
||||
153
web-app-vue/src/types/provider.d.ts
vendored
Normal file
153
web-app-vue/src/types/provider.d.ts
vendored
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* AI 提供商相关类型定义
|
||||
*/
|
||||
|
||||
/** 提供商类型 */
|
||||
type ProviderType = 'openai' | 'claude' | 'gemini' | 'custom'
|
||||
|
||||
/** 模型类型 */
|
||||
type ModelType = 'chat' | 'image_gen'
|
||||
|
||||
/** AI 提供商 */
|
||||
interface AIProvider {
|
||||
id: number
|
||||
providerName: string
|
||||
providerType: ProviderType
|
||||
baseUrl: string
|
||||
apiKeySet: boolean
|
||||
apiKeyHint: string
|
||||
apiConfig: Record<string, any>
|
||||
capabilities: string[]
|
||||
isEnabled: boolean
|
||||
isDefault: boolean
|
||||
sortOrder: number
|
||||
models: AIModel[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** AI 模型 */
|
||||
interface AIModel {
|
||||
id: number
|
||||
providerId: number
|
||||
modelName: string
|
||||
displayName: string
|
||||
modelType: ModelType
|
||||
config: Record<string, any>
|
||||
isEnabled: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 创建提供商请求 */
|
||||
interface CreateProviderRequest {
|
||||
providerName: string
|
||||
providerType: ProviderType
|
||||
baseUrl?: string
|
||||
apiKey: string
|
||||
apiConfig?: Record<string, any>
|
||||
models?: CreateModelRequest[]
|
||||
}
|
||||
|
||||
/** 更新提供商请求 */
|
||||
interface UpdateProviderRequest {
|
||||
id: number
|
||||
providerName: string
|
||||
providerType: ProviderType
|
||||
baseUrl?: string
|
||||
apiKey?: string // 为空不修改
|
||||
apiConfig?: Record<string, any>
|
||||
isEnabled?: boolean
|
||||
isDefault?: boolean
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
/** 创建模型请求 */
|
||||
interface CreateModelRequest {
|
||||
providerId?: number
|
||||
modelName: string
|
||||
displayName?: string
|
||||
modelType: ModelType
|
||||
config?: Record<string, any>
|
||||
isEnabled?: boolean
|
||||
}
|
||||
|
||||
/** 更新模型请求 */
|
||||
interface UpdateModelRequest {
|
||||
id: number
|
||||
modelName: string
|
||||
displayName?: string
|
||||
modelType: ModelType
|
||||
config?: Record<string, any>
|
||||
isEnabled?: boolean
|
||||
}
|
||||
|
||||
/** 提供商列表参数 */
|
||||
interface ProviderListParams {
|
||||
page: number
|
||||
pageSize: number
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
/** 提供商列表响应 */
|
||||
interface ProviderListResponse {
|
||||
list: AIProvider[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 测试提供商请求 */
|
||||
interface TestProviderRequest {
|
||||
providerType: ProviderType
|
||||
baseUrl?: string
|
||||
apiKey: string
|
||||
modelName?: string
|
||||
}
|
||||
|
||||
/** 测试提供商响应 */
|
||||
interface TestProviderResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
models?: string[]
|
||||
latency: number
|
||||
}
|
||||
|
||||
/** 提供商类型选项 */
|
||||
interface ProviderTypeOption {
|
||||
value: string
|
||||
label: string
|
||||
description: string
|
||||
defaultUrl: string
|
||||
}
|
||||
|
||||
/** 预设模型选项 */
|
||||
interface PresetModelOption {
|
||||
modelName: string
|
||||
displayName: string
|
||||
modelType: ModelType
|
||||
}
|
||||
|
||||
/** 远程模型 */
|
||||
interface RemoteModel {
|
||||
id: string
|
||||
displayName: string
|
||||
ownedBy: string
|
||||
}
|
||||
|
||||
/** 获取远程模型列表响应 */
|
||||
interface FetchRemoteModelsResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
models: RemoteModel[]
|
||||
latency: number
|
||||
}
|
||||
|
||||
/** 发送测试消息响应 */
|
||||
interface SendTestMessageResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
reply: string
|
||||
model: string
|
||||
latency: number
|
||||
tokens: number
|
||||
}
|
||||
27
web-app-vue/src/utils/compatibility.ts
Normal file
27
web-app-vue/src/utils/compatibility.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import _ from 'lodash'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// SillyTavern 扩展兼容层:注入全局库
|
||||
// 原版扩展依赖 jquery, lodash, toastr 等全局变量
|
||||
|
||||
// 1. jQuery (需要在 main.ts 中引入,因为它是通过 import jQuery form 'jquery' 引入的,这里再引入一份可能导致多例,但挂载 window 是安全的)
|
||||
// 由于 main.ts 已经处理了 jQuery,这里暂不处理,或者为了统一可以移过来。
|
||||
// 目前 main.ts 已经有了,就先不管 jQuery。
|
||||
|
||||
// 2. Lodash
|
||||
if (!(window as any)._) {
|
||||
; (window as any)._ = _
|
||||
}
|
||||
|
||||
// 3. Toastr (使用 ElementPlus Message 模拟)
|
||||
if (!(window as any).toastr) {
|
||||
; (window as any).toastr = {
|
||||
info: (msg: string) => ElMessage.info(msg),
|
||||
success: (msg: string) => ElMessage.success(msg),
|
||||
warning: (msg: string) => ElMessage.warning(msg),
|
||||
error: (msg: string) => ElMessage.error(msg),
|
||||
clear: () => { },
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Compatibility] Global compatibility layer initialized (lodash, toastr)')
|
||||
@@ -6,11 +6,46 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { Extension } from '@/types/extension'
|
||||
|
||||
/**
|
||||
* SillyTavern 扩展兼容层:注入扩展依赖的全局 API
|
||||
* 原版 SillyTavern 扩展依赖 window.toastr、lodash 等全局库
|
||||
*/
|
||||
function initGlobalCompat() {
|
||||
// toastr 兼容层(使用 ElMessage 替代)
|
||||
if (!(window as any).toastr) {
|
||||
; (window as any).toastr = {
|
||||
info: (msg: string) => ElMessage.info(msg),
|
||||
success: (msg: string) => ElMessage.success(msg),
|
||||
warning: (msg: string) => ElMessage.warning(msg),
|
||||
error: (msg: string) => ElMessage.error(msg),
|
||||
clear: () => { },
|
||||
}
|
||||
}
|
||||
|
||||
// 创建扩展设置容器(原版 SillyTavern 扩展通过 jQuery 向这些容器追加设置面板 UI)
|
||||
// ExtensionDrawer.vue 在打开时会将这些容器移入 Drawer 中显示
|
||||
if (!document.getElementById('extensions_settings')) {
|
||||
const container = document.createElement('div')
|
||||
container.id = 'extensions_settings'
|
||||
container.style.display = 'none'
|
||||
document.body.appendChild(container)
|
||||
}
|
||||
if (!document.getElementById('extensions_settings2')) {
|
||||
const container2 = document.createElement('div')
|
||||
container2.id = 'extensions_settings2'
|
||||
container2.style.display = 'none'
|
||||
document.body.appendChild(container2)
|
||||
}
|
||||
}
|
||||
|
||||
// 立即初始化兼容层
|
||||
initGlobalCompat()
|
||||
|
||||
interface ExtensionInstance {
|
||||
extension: Extension
|
||||
manifest: any
|
||||
scriptElement?: HTMLScriptElement
|
||||
styleElement?: HTMLLinkElement
|
||||
scriptElement?: HTMLScriptElement | HTMLStyleElement
|
||||
styleElement?: HTMLStyleElement
|
||||
isLoaded: boolean
|
||||
isRunning: boolean
|
||||
api?: any
|
||||
@@ -25,14 +60,14 @@ class ExtensionRuntime {
|
||||
constructor() {
|
||||
this.initSillyTavernAPI()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取扩展的配置容器 DOM 元素
|
||||
*/
|
||||
private getExtensionSettingsContainer(extensionName: string): HTMLElement | null {
|
||||
return document.getElementById(`extension-settings-${extensionName}`)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 加载扩展的已保存设置
|
||||
*/
|
||||
@@ -53,7 +88,7 @@ class ExtensionRuntime {
|
||||
}
|
||||
return this.extensionSettings[extensionName]
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 保存扩展设置到 localStorage
|
||||
*/
|
||||
@@ -83,21 +118,21 @@ class ExtensionRuntime {
|
||||
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 => ({
|
||||
@@ -114,16 +149,16 @@ class ExtensionRuntime {
|
||||
// UI 工具
|
||||
ui: {
|
||||
// 创建设置面板
|
||||
createSettings: (title: string, content: HTMLElement) => {
|
||||
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)
|
||||
@@ -131,7 +166,7 @@ class ExtensionRuntime {
|
||||
target.appendChild(element)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// 创建按钮
|
||||
createButton: (text: string, onClick: Function) => {
|
||||
const button = document.createElement('button')
|
||||
@@ -146,7 +181,7 @@ class ExtensionRuntime {
|
||||
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)
|
||||
@@ -154,13 +189,13 @@ class ExtensionRuntime {
|
||||
}
|
||||
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 }))
|
||||
},
|
||||
@@ -175,7 +210,7 @@ class ExtensionRuntime {
|
||||
groupId: null as number | null,
|
||||
userName: '',
|
||||
characterName: '',
|
||||
|
||||
|
||||
// extension_settings 对象(兼容原版 SillyTavern)
|
||||
extension_settings: new Proxy(this.extensionSettings, {
|
||||
get: (target, prop: string) => {
|
||||
@@ -196,13 +231,23 @@ class ExtensionRuntime {
|
||||
return false
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
// 获取扩展配置容器的方法
|
||||
getExtensionSettingsContainer: (extensionName: string) => {
|
||||
return this.getExtensionSettingsContainer(extensionName)
|
||||
},
|
||||
|
||||
// 原版酒馆:保存设置(防抖),扩展会调用
|
||||
saveSettingsDebounced: () => {
|
||||
// 我们通过 extension_settings 的 set 已自动存 localStorage,此处可触发事件通知
|
||||
this.emitEvent('settingsSaved')
|
||||
},
|
||||
|
||||
saveSettings: () => {
|
||||
this.emitEvent('settingsSaved')
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
// 可以从实际应用状态获取
|
||||
try {
|
||||
const route = (window as any).$route
|
||||
@@ -213,18 +258,18 @@ class ExtensionRuntime {
|
||||
} 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 {
|
||||
@@ -234,22 +279,22 @@ class ExtensionRuntime {
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
// 存储工具
|
||||
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))
|
||||
@@ -257,10 +302,35 @@ class ExtensionRuntime {
|
||||
},
|
||||
}
|
||||
|
||||
// 挂载到 window 对象
|
||||
;(window as any).SillyTavern = this.stAPI
|
||||
;(window as any).st = this.stAPI // 简写别名
|
||||
|
||||
// 原版酒馆:渲染扩展 HTML 模板(扩展会调用)
|
||||
// 注意:大部分扩展通过 import { renderExtensionTemplateAsync } from '...extensions.js' 使用原版实现,
|
||||
// 此处仅作为后备兼容(如果扩展直接调用 window.SillyTavern.renderExtensionTemplateAsync)
|
||||
this.stAPI.renderExtensionTemplateAsync = async (extensionName: string, templateId: string, _templateData: Record<string, any> = {}) => {
|
||||
// 与原版 SillyTavern 一致:模板路径为 /scripts/extensions/{extensionName}/{templateId}.html
|
||||
const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8888'
|
||||
const url = `${apiBase}/scripts/extensions/${extensionName}/${templateId}.html`
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return document.createElement('div')
|
||||
const html = await res.text()
|
||||
const wrap = document.createElement('div')
|
||||
wrap.innerHTML = html
|
||||
return wrap.firstElementChild || wrap
|
||||
} catch (e) {
|
||||
console.warn(`[ExtensionRuntime] 加载模板失败: ${url}`, e)
|
||||
return document.createElement('div')
|
||||
}
|
||||
}
|
||||
|
||||
this.stAPI.renderExtensionTemplate = (_extensionName: string, _templateId: string, _templateData: Record<string, any> = {}) => {
|
||||
console.warn('[ExtensionRuntime] renderExtensionTemplate 同步版未实现,请使用 renderExtensionTemplateAsync')
|
||||
return document.createElement('div')
|
||||
}
|
||||
|
||||
// 挂载到 window 对象
|
||||
; (window as any).SillyTavern = this.stAPI
|
||||
; (window as any).st = this.stAPI // 简写别名
|
||||
|
||||
// 触发 API 初始化完成事件
|
||||
this.emitEvent('apiReady')
|
||||
}
|
||||
@@ -279,8 +349,8 @@ class ExtensionRuntime {
|
||||
}
|
||||
|
||||
// 解析 manifest
|
||||
const manifest = typeof extension.manifestData === 'string'
|
||||
? JSON.parse(extension.manifestData)
|
||||
const manifest = typeof extension.manifestData === 'string'
|
||||
? JSON.parse(extension.manifestData)
|
||||
: extension.manifestData
|
||||
|
||||
const instance: ExtensionInstance = {
|
||||
@@ -317,43 +387,65 @@ class ExtensionRuntime {
|
||||
|
||||
/**
|
||||
* 加载扩展样式
|
||||
* 资源路由为公开路由,直接使用 <link> 标签加载(与原版 SillyTavern 一致)
|
||||
*/
|
||||
private async loadStyle(extension: Extension, instance: ExtensionInstance): Promise<void> {
|
||||
const url = this.getAssetURL(extension, extension.stylePath!)
|
||||
console.log(`[Extension] 加载样式: ${extension.name}, URL=${url}`)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = this.getAssetURL(extension.stylePath!)
|
||||
link.href = url
|
||||
link.dataset.extension = extension.name
|
||||
|
||||
|
||||
link.onload = () => {
|
||||
instance.styleElement = link
|
||||
instance.styleElement = link as any
|
||||
resolve()
|
||||
}
|
||||
|
||||
link.onerror = () => reject(new Error(`样式加载失败: ${extension.stylePath}`))
|
||||
|
||||
|
||||
link.onerror = () => {
|
||||
reject(new Error(`样式加载失败: ${extension.stylePath}`))
|
||||
}
|
||||
|
||||
document.head.appendChild(link)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载扩展脚本
|
||||
* 与原版 SillyTavern 一致:使用 <script type="module"> 加载 ES module 格式的扩展脚本。
|
||||
* 不能用 Blob URL,因为 ES module 的 import 语句使用相对路径,
|
||||
* 需要从实际 URL 加载才能正确解析(如 ../../../../../scripts/utils.js -> /scripts/utils.js)。
|
||||
* 资源路由为公开路由,不需要 JWT header。
|
||||
*/
|
||||
private async loadScript(extension: Extension, instance: ExtensionInstance): Promise<void> {
|
||||
const url = this.getAssetURL(extension, extension.scriptPath!)
|
||||
console.log(`[Extension] 加载脚本: ${extension.name}, URL=${url}`)
|
||||
|
||||
// 记录 base URL 供扩展内部使用(renderExtensionTemplateAsync 用)
|
||||
const baseUrl = url.replace(/\/[^/]*$/, '')
|
||||
; (window as any).__extensionBaseUrl = (window as any).__extensionBaseUrl || {}
|
||||
; (window as any).__extensionBaseUrl[extension.name] = baseUrl
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.src = this.getAssetURL(extension.scriptPath!)
|
||||
script.type = 'module'
|
||||
script.src = url
|
||||
script.dataset.extension = extension.name
|
||||
script.async = true
|
||||
|
||||
|
||||
script.onload = () => {
|
||||
instance.scriptElement = script
|
||||
instance.isRunning = true
|
||||
resolve()
|
||||
}
|
||||
|
||||
script.onerror = () => reject(new Error(`脚本加载失败: ${extension.scriptPath}`))
|
||||
|
||||
|
||||
script.onerror = (e) => {
|
||||
console.error(`[Extension] 脚本加载详细错误:`, e)
|
||||
reject(new Error(`脚本加载失败: ${extension.scriptPath} (请检查控制台 Network 标签页的响应)`))
|
||||
}
|
||||
|
||||
document.body.appendChild(script)
|
||||
})
|
||||
}
|
||||
@@ -368,9 +460,9 @@ class ExtensionRuntime {
|
||||
script.textContent = scriptCode
|
||||
script.dataset.extension = extension.name
|
||||
script.dataset.inline = 'true'
|
||||
|
||||
|
||||
document.body.appendChild(script)
|
||||
|
||||
|
||||
instance.scriptElement = script
|
||||
instance.isRunning = true
|
||||
resolve()
|
||||
@@ -404,9 +496,9 @@ class ExtensionRuntime {
|
||||
|
||||
instance.isLoaded = false
|
||||
instance.isRunning = false
|
||||
|
||||
|
||||
this.instances.delete(extensionId)
|
||||
|
||||
|
||||
console.log(`[Extension] 卸载成功: ${instance.extension.name}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
@@ -442,21 +534,17 @@ class ExtensionRuntime {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源 URL
|
||||
* 这里需要根据实际情况处理:
|
||||
* 1. 如果扩展文件存储在服务器上,需要通过 API 获取
|
||||
* 2. 如果是 CDN,直接使用 URL
|
||||
* 3. 如果是 base64 编码,需要转换
|
||||
* 获取资源 URL(扩展文件存储在后端本地,通过 API 接口访问)
|
||||
*/
|
||||
private getAssetURL(path: string): string {
|
||||
// 如果是完整 URL,直接返回
|
||||
private getAssetURL(extension: Extension, path: string): string {
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path
|
||||
}
|
||||
|
||||
// 如果是相对路径,需要根据实际部署情况构建完整 URL
|
||||
// 这里假设扩展文件存储在 /api/extension/assets/ 路径下
|
||||
return `/api/app/extension/assets/${path}`
|
||||
// 与原版 SillyTavern 完全一致的路径结构:/scripts/extensions/third-party/{name}/{path}
|
||||
// 这样扩展 JS 中的相对路径 import(如 ../../../extensions.js)能正确解析
|
||||
// 资源由后端 Router.Static("/scripts", ...) 直接提供,无需额外 API 路由
|
||||
const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8888'
|
||||
return `${apiBase}/scripts/extensions/third-party/${extension.name}/${path.replace(/^\//, '')}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -211,6 +211,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useCharacterStore } from '@/stores/character'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import * as chatApi from '@/api/chat'
|
||||
import {
|
||||
User,
|
||||
Clock,
|
||||
@@ -250,9 +251,29 @@ function formatDateTime(dateStr: string) {
|
||||
}
|
||||
|
||||
// 开始对话
|
||||
function startChat() {
|
||||
ElMessage.info('对话功能开发中...')
|
||||
// TODO: router.push(`/chat/${character.value?.id}`)
|
||||
async function startChat() {
|
||||
if (!authStore.isLoggedIn) {
|
||||
ElMessage.warning('请先登录')
|
||||
router.push('/auth/login')
|
||||
return
|
||||
}
|
||||
|
||||
if (!character.value) return
|
||||
|
||||
try {
|
||||
const res = await chatApi.createChat({ characterId: character.value.id }) as any
|
||||
const chatId = res.data?.id
|
||||
|
||||
if (!chatId) {
|
||||
ElMessage.error('创建对话失败')
|
||||
return
|
||||
}
|
||||
|
||||
router.push(`/chat/${chatId}`)
|
||||
} catch (error) {
|
||||
ElMessage.error('创建对话失败')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换收藏
|
||||
|
||||
351
web-app-vue/src/views/chat/ChatList.vue
Normal file
351
web-app-vue/src/views/chat/ChatList.vue
Normal file
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<div class="chat-list-page">
|
||||
<div class="page-header">
|
||||
<h2>我的对话</h2>
|
||||
<el-button type="primary" @click="showNewChat = true">
|
||||
<el-icon class="mr-1"><ChatDotSquare /></el-icon>
|
||||
新对话
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 对话列表 -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<el-skeleton :rows="5" animated />
|
||||
</div>
|
||||
|
||||
<div v-else-if="chats.length === 0" class="empty-state">
|
||||
<el-empty description="还没有对话">
|
||||
<el-button type="primary" @click="showNewChat = true">开始第一次对话</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<div v-else class="chat-items">
|
||||
<div
|
||||
v-for="chat in chats"
|
||||
:key="chat.id"
|
||||
class="chat-item"
|
||||
@click="router.push(`/chat/${chat.id}`)"
|
||||
>
|
||||
<div class="chat-avatar">
|
||||
<el-avatar :src="chat.characterAvatar" :size="48">
|
||||
{{ chat.characterName?.charAt(0) || '?' }}
|
||||
</el-avatar>
|
||||
</div>
|
||||
<div class="chat-info">
|
||||
<div class="chat-title">
|
||||
{{ chat.title }}
|
||||
<el-tag v-if="chat.isPinned" type="warning" size="small" class="ml-1">置顶</el-tag>
|
||||
</div>
|
||||
<div class="chat-preview">
|
||||
{{ chat.lastMessage?.content || '暂无消息' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-meta">
|
||||
<div class="chat-time">{{ formatTime(chat.lastMessageAt || chat.createdAt) }}</div>
|
||||
<div class="chat-count">{{ chat.messageCount }} 条消息</div>
|
||||
<el-button
|
||||
text
|
||||
type="danger"
|
||||
size="small"
|
||||
class="delete-btn"
|
||||
@click.stop="handleDelete(chat)"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建对话对话框 -->
|
||||
<el-dialog v-model="showNewChat" title="选择角色开始对话" width="500px">
|
||||
<div class="character-search">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索角色..."
|
||||
prefix-icon="Search"
|
||||
clearable
|
||||
@input="searchCharacters"
|
||||
/>
|
||||
</div>
|
||||
<div class="character-list" v-loading="searchLoading">
|
||||
<div
|
||||
v-for="char in characterResults"
|
||||
:key="char.id"
|
||||
class="character-option"
|
||||
@click="handleCreateChat(char)"
|
||||
>
|
||||
<el-avatar :src="char.avatar" :size="40">{{ char.name?.charAt(0) }}</el-avatar>
|
||||
<div class="char-info">
|
||||
<div class="char-name">{{ char.name }}</div>
|
||||
<div class="char-desc">{{ (char.description || '').slice(0, 60) }}{{ (char.description || '').length > 60 ? '...' : '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="characterResults.length === 0 && !searchLoading" description="没有找到角色" />
|
||||
</div>
|
||||
</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 { ChatDotSquare, Delete } from '@element-plus/icons-vue'
|
||||
import * as chatApi from '@/api/chat'
|
||||
import * as characterApi from '@/api/character'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const chats = ref<Chat[]>([])
|
||||
const loading = ref(false)
|
||||
const showNewChat = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const searchLoading = ref(false)
|
||||
const characterResults = ref<any[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchChats()
|
||||
await searchCharacters()
|
||||
})
|
||||
|
||||
async function fetchChats() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await chatApi.getChatList({ page: 1, pageSize: 50 }) as any
|
||||
chats.value = res.data?.list || []
|
||||
} catch {
|
||||
// 错误已处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
async function searchCharacters() {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(async () => {
|
||||
searchLoading.value = true
|
||||
try {
|
||||
// 同时获取「我的角色卡」和「公开角色卡」,并合并去重
|
||||
const [myRes, publicRes] = await Promise.all([
|
||||
characterApi.getMyCharacterList({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
keyword: searchKeyword.value,
|
||||
}) as any,
|
||||
characterApi.getPublicCharacterList({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
keyword: searchKeyword.value,
|
||||
}) as any,
|
||||
])
|
||||
|
||||
const mergedMap = new Map<number, any>()
|
||||
|
||||
const appendList = (list?: any[]) => {
|
||||
if (!list) return
|
||||
for (const item of list) {
|
||||
if (!item || item.id == null) continue
|
||||
// 以 id 为键去重;优先保留「我的角色卡」信息
|
||||
if (!mergedMap.has(item.id)) {
|
||||
mergedMap.set(item.id, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appendList(myRes?.data?.list)
|
||||
appendList(publicRes?.data?.list)
|
||||
|
||||
characterResults.value = Array.from(mergedMap.values())
|
||||
} catch {
|
||||
characterResults.value = []
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function handleCreateChat(character: any) {
|
||||
try {
|
||||
const res = await chatApi.createChat({ characterId: character.id }) as any
|
||||
const chatId = res.data?.id
|
||||
showNewChat.value = false
|
||||
if (chatId) {
|
||||
router.push(`/chat/${chatId}`)
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('创建对话失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(chat: Chat) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除与「${chat.characterName || chat.title}」的对话吗?所有消息都会被删除。`,
|
||||
'删除对话',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
await chatApi.deleteChat(chat.id)
|
||||
ElMessage.success('删除成功')
|
||||
await fetchChats()
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(timeStr: string): string {
|
||||
if (!timeStr) return ''
|
||||
const date = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const days = Math.floor(diff / 86400000)
|
||||
|
||||
if (minutes < 1) return '刚刚'
|
||||
if (minutes < 60) return `${minutes}分钟前`
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
if (days < 7) return `${days}天前`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-list-page {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.mr-1 { margin-right: 4px; }
|
||||
.ml-1 { margin-left: 4px; }
|
||||
|
||||
.chat-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
|
||||
.delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.chat-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-preview {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-meta {
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
|
||||
.chat-time {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.chat-count {
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.character-search {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.character-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.character-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.char-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.char-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.char-desc {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
536
web-app-vue/src/views/chat/ChatView.vue
Normal file
536
web-app-vue/src/views/chat/ChatView.vue
Normal file
@@ -0,0 +1,536 @@
|
||||
<template>
|
||||
<div class="chat-view">
|
||||
<!-- 顶部信息栏 -->
|
||||
<div class="chat-header">
|
||||
<el-button text @click="router.push('/chats')">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
</el-button>
|
||||
<div class="header-info">
|
||||
<el-avatar :src="chat?.characterAvatar" :size="36">
|
||||
{{ chat?.characterName?.charAt(0) || '?' }}
|
||||
</el-avatar>
|
||||
<div class="header-text">
|
||||
<div class="header-title">{{ chat?.characterName || chat?.title }}</div>
|
||||
<div class="header-sub">{{ chat?.messageCount || 0 }} 条消息</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-dropdown trigger="click">
|
||||
<el-button text>
|
||||
<el-icon><MoreFilled /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="handleDeleteChat">删除对话</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 正则脚本应用情况(开发/调试用) -->
|
||||
<div v-if="appliedRegexIds.length" class="regex-debug">
|
||||
<span class="label">已应用正则脚本:</span>
|
||||
<el-tag
|
||||
v-for="id in appliedRegexIds"
|
||||
:key="id"
|
||||
size="small"
|
||||
type="info"
|
||||
class="regex-tag"
|
||||
>
|
||||
{{ regexNameMap[id] || `#${id}` }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- 消息区域 -->
|
||||
<div ref="messagesContainer" class="messages-container" @scroll="handleScroll">
|
||||
<div v-if="loadingMessages" class="loading-messages">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
加载中...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
class="message-wrapper"
|
||||
:class="msg.role"
|
||||
>
|
||||
<!-- 角色消息 -->
|
||||
<template v-if="msg.role === 'assistant'">
|
||||
<el-avatar :src="chat?.characterAvatar" :size="32" class="msg-avatar">
|
||||
{{ chat?.characterName?.charAt(0) || '?' }}
|
||||
</el-avatar>
|
||||
<div class="message-bubble assistant" :class="{ 'html-doc': isFullHtmlDocument(msg.content) }">
|
||||
<!-- 如果是完整 HTML 文档(如 demo.html),用 iframe 独立渲染,保留原样式和脚本 -->
|
||||
<template v-if="isFullHtmlDocument(msg.content)">
|
||||
<div class="html-doc-wrapper">
|
||||
<iframe
|
||||
class="html-doc-frame"
|
||||
:srcdoc="msg.content"
|
||||
sandbox="allow-scripts"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="msg-content" v-html="renderMarkdown(msg.content)"></div>
|
||||
</template>
|
||||
<div class="msg-meta">
|
||||
<span v-if="msg.model" class="msg-model">{{ msg.model }}</span>
|
||||
<span class="msg-time">{{ formatMsgTime(msg.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 用户消息 -->
|
||||
<template v-else-if="msg.role === 'user'">
|
||||
<div class="message-bubble user">
|
||||
<div class="msg-content">{{ msg.content }}</div>
|
||||
<div class="msg-meta">
|
||||
<span class="msg-time">{{ formatMsgTime(msg.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- AI 正在输入 -->
|
||||
<div v-if="isStreaming" class="message-wrapper assistant">
|
||||
<el-avatar :src="chat?.characterAvatar" :size="32" class="msg-avatar">
|
||||
{{ chat?.characterName?.charAt(0) || '?' }}
|
||||
</el-avatar>
|
||||
<div class="message-bubble assistant streaming">
|
||||
<div class="msg-content" v-html="renderMarkdown(streamingContent)"></div>
|
||||
<div class="typing-indicator" v-if="!streamingContent">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="input-area">
|
||||
<div class="input-wrapper">
|
||||
<el-input
|
||||
v-model="inputContent"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 1, maxRows: 6 }"
|
||||
placeholder="输入消息..."
|
||||
:disabled="isStreaming"
|
||||
@keydown.enter.exact="handleSend"
|
||||
@keydown.shift.enter.prevent="inputContent += '\n'"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
circle
|
||||
:loading="isStreaming"
|
||||
:disabled="!inputContent.trim() || isStreaming"
|
||||
@click="handleSend"
|
||||
class="send-btn"
|
||||
>
|
||||
<el-icon v-if="!isStreaming"><Promotion /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="input-hint">
|
||||
Enter 发送,Shift + Enter 换行
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||
import { ArrowLeft, MoreFilled, Promotion, Loading } from '@element-plus/icons-vue'
|
||||
import * as chatApi from '@/api/chat'
|
||||
import * as regexScriptApi from '@/api/regexScript'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const chatId = Number(route.params.id)
|
||||
const chat = ref<Chat | null>(null)
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const inputContent = ref('')
|
||||
const isStreaming = ref(false)
|
||||
const streamingContent = ref('')
|
||||
const loadingMessages = ref(false)
|
||||
const messagesContainer = ref<HTMLElement>()
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
// 最近一次 AI 回复应用到的正则脚本(用于在界面上展示,便于确认是否生效)
|
||||
const appliedRegexIds = ref<number[]>([])
|
||||
const regexNameMap = ref<Record<number, string>>({})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadChat()
|
||||
})
|
||||
|
||||
async function loadChat() {
|
||||
loadingMessages.value = true
|
||||
try {
|
||||
const res = await chatApi.getChatDetail(chatId) as any
|
||||
const detail = res.data || res
|
||||
chat.value = detail.chat
|
||||
messages.value = detail.messages || []
|
||||
|
||||
// 加载当前角色关联的正则脚本元数据(id -> 名称),用于展示调试信息
|
||||
if (chat.value?.characterId) {
|
||||
try {
|
||||
const regexRes = await regexScriptApi.getCharacterRegexScripts(chat.value.characterId) as any
|
||||
const list = regexRes.data || []
|
||||
const map: Record<number, string> = {}
|
||||
for (const item of list) {
|
||||
if (item && typeof item.id === 'number') {
|
||||
map[item.id] = item.scriptName || `脚本 #${item.id}`
|
||||
}
|
||||
}
|
||||
regexNameMap.value = map
|
||||
} catch {
|
||||
// 正则脚本元数据加载失败不影响对话功能
|
||||
}
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
scrollToBottom()
|
||||
} catch {
|
||||
ElMessage.error('加载对话失败')
|
||||
} finally {
|
||||
loadingMessages.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend(e?: KeyboardEvent) {
|
||||
if (e) e.preventDefault()
|
||||
const content = inputContent.value.trim()
|
||||
if (!content || isStreaming.value) return
|
||||
|
||||
// 添加用户消息到界面
|
||||
const userMsg: ChatMessage = {
|
||||
id: Date.now(),
|
||||
chatId: chatId,
|
||||
content: content,
|
||||
role: 'user',
|
||||
sequenceNumber: messages.value.length + 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
messages.value.push(userMsg)
|
||||
inputContent.value = ''
|
||||
await nextTick()
|
||||
scrollToBottom()
|
||||
|
||||
// 开始 SSE 流式响应
|
||||
isStreaming.value = true
|
||||
streamingContent.value = ''
|
||||
|
||||
abortController = chatApi.sendMessageSSE(
|
||||
{ chatId, content },
|
||||
// onContent
|
||||
(chunk: string) => {
|
||||
streamingContent.value += chunk
|
||||
scrollToBottom()
|
||||
},
|
||||
// onDone
|
||||
async (event: SSEEvent) => {
|
||||
// 将流式内容添加为正式消息,并在前端应用正则脚本做美化
|
||||
if (streamingContent.value) {
|
||||
let finalContent = streamingContent.value
|
||||
|
||||
// 针对当前角色应用「AI 侧」正则脚本(含全局脚本)
|
||||
if (chat.value?.characterId) {
|
||||
try {
|
||||
const res = await regexScriptApi.applyRegexScripts({
|
||||
text: streamingContent.value,
|
||||
characterId: chat.value.characterId,
|
||||
placement: 'ai',
|
||||
useGlobal: true,
|
||||
} as any)
|
||||
const processed = (res as any).data?.processedText
|
||||
const applied: number[] = (res as any).data?.appliedScripts || []
|
||||
appliedRegexIds.value = applied
|
||||
if (processed) {
|
||||
finalContent = processed
|
||||
}
|
||||
} catch {
|
||||
// 正则失败不阻塞对话
|
||||
}
|
||||
}
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: Date.now() + 1,
|
||||
chatId: chatId,
|
||||
content: finalContent,
|
||||
role: 'assistant',
|
||||
characterId: chat.value?.characterId || undefined,
|
||||
characterName: chat.value?.characterName,
|
||||
model: event.model,
|
||||
promptTokens: event.promptTokens,
|
||||
completionTokens: event.completionTokens,
|
||||
sequenceNumber: messages.value.length + 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
messages.value.push(assistantMsg)
|
||||
}
|
||||
isStreaming.value = false
|
||||
streamingContent.value = ''
|
||||
scrollToBottom()
|
||||
},
|
||||
// onError
|
||||
(error: string) => {
|
||||
ElMessage.error(error)
|
||||
isStreaming.value = false
|
||||
streamingContent.value = ''
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function handleDeleteChat() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除这个对话吗?所有消息都会被删除。', '删除对话', { type: 'warning' })
|
||||
await chatApi.deleteChat(chatId)
|
||||
ElMessage.success('已删除')
|
||||
router.push('/chats')
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
// 未来可以实现上拉加载更多历史消息
|
||||
}
|
||||
|
||||
function formatMsgTime(timeStr: string): string {
|
||||
if (!timeStr) return ''
|
||||
const date = new Date(timeStr)
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
// 判断消息内容是否为完整 HTML 文档(如 demo.html,用于第一条场景卡片类消息)
|
||||
function isFullHtmlDocument(text: string | undefined): boolean {
|
||||
if (!text) return false
|
||||
const t = text.trim().toLowerCase()
|
||||
return t.startsWith('<!doctype html') || t.startsWith('<html') || t.includes('<body>')
|
||||
}
|
||||
|
||||
/** 简单的 Markdown 渲染(粗体、斜体、代码、换行) */
|
||||
function renderMarkdown(text: string): string {
|
||||
if (!text) return ''
|
||||
return text
|
||||
// 仅做最基本的 & 转义,保留 HTML 标签以支持角色卡/正则脚本输出的富文本
|
||||
.replace(/&/g, '&')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
.replace(/\n/g, '<br>')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 100px);
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 12px 12px 0 0;
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-sub {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.loading-messages {
|
||||
text-align: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-wrapper {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
max-width: 80%;
|
||||
|
||||
&.user {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
&.assistant {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.msg-avatar {
|
||||
flex-shrink: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 10px 14px;
|
||||
border-radius: 16px;
|
||||
max-width: 100%;
|
||||
word-break: break-word;
|
||||
|
||||
&.user {
|
||||
background: var(--el-color-primary);
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
|
||||
.msg-meta {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
&.assistant {
|
||||
background: var(--el-fill-color);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
&.streaming {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
|
||||
:deep(code) {
|
||||
background: var(--el-fill-color-darker);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-meta {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.msg-model {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 完整 HTML 文档消息样式(使用 iframe 渲染 demo.html 这类内容) */
|
||||
.message-bubble.html-doc {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.html-doc-wrapper {
|
||||
// width: 50%;
|
||||
max-width: 70vh;
|
||||
width: 70vh;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.html-doc-frame {
|
||||
width: 100%;
|
||||
height: 50vh; /* 默认占用对话区域约一半高度 */
|
||||
border: none;
|
||||
display: block;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
|
||||
span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--el-text-color-placeholder);
|
||||
border-radius: 50%;
|
||||
animation: typing 1.4s infinite ease-in-out;
|
||||
|
||||
&:nth-child(2) { animation-delay: 0.2s; }
|
||||
&:nth-child(3) { animation-delay: 0.4s; }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%, 60%, 100% { transform: translateY(0); }
|
||||
30% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
.input-area {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 0 0 12px 12px;
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.input-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,12 @@
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button @click="handleBack">返回</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">
|
||||
<el-button
|
||||
v-if="hasSettings"
|
||||
type="primary"
|
||||
@click="handleSave"
|
||||
:loading="saving"
|
||||
>
|
||||
保存配置
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -31,7 +36,7 @@
|
||||
|
||||
<!-- 配置表单 -->
|
||||
<el-form
|
||||
v-if="settings && extension"
|
||||
v-if="extension"
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
label-width="200px"
|
||||
@@ -51,14 +56,14 @@
|
||||
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)"
|
||||
@@ -72,7 +77,7 @@
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
|
||||
<!-- 多行文本 -->
|
||||
<el-input
|
||||
v-else-if="isTextareaField(key)"
|
||||
@@ -80,7 +85,7 @@
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
/>
|
||||
|
||||
|
||||
<!-- 默认文本输入 -->
|
||||
<el-input
|
||||
v-else
|
||||
@@ -90,13 +95,28 @@
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 没有配置项 -->
|
||||
<el-empty v-else description="该扩展暂无可配置项" />
|
||||
<!-- 没有配置项:SillyTavern 扩展的配置由扩展自身管理 -->
|
||||
<template v-else>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 20px"
|
||||
>
|
||||
<template #title>
|
||||
此扩展的配置由扩展自身管理
|
||||
</template>
|
||||
<p style="margin: 5px 0 0 0">
|
||||
SillyTavern 扩展通过内置的 extension_settings API 管理配置,
|
||||
启用扩展后可在聊天界面中进行配置。
|
||||
</p>
|
||||
</el-alert>
|
||||
</template>
|
||||
|
||||
<!-- 高级选项 -->
|
||||
<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"
|
||||
@@ -113,12 +133,32 @@
|
||||
<!-- Manifest 信息 -->
|
||||
<el-divider content-position="left">Manifest 信息</el-divider>
|
||||
<el-descriptions v-if="extension" :column="2" border>
|
||||
<el-descriptions-item label="扩展名称">
|
||||
{{ extension.name || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="显示名称">
|
||||
{{ extension.displayName || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="主脚本">
|
||||
{{ extension.scriptPath || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="样式文件">
|
||||
{{ extension.stylePath || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="安装来源">
|
||||
{{ getInstallSourceLabel(extension.installSource) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="源地址">
|
||||
<el-link
|
||||
v-if="extension.sourceUrl"
|
||||
:href="extension.sourceUrl"
|
||||
type="primary"
|
||||
target="_blank"
|
||||
>
|
||||
{{ extension.sourceUrl }}
|
||||
</el-link>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="依赖扩展" :span="2">
|
||||
<template v-if="extension.dependencies && Object.keys(extension.dependencies).length > 0">
|
||||
<el-tag
|
||||
@@ -148,6 +188,18 @@
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- Manifest 原始数据 -->
|
||||
<template v-if="extension?.manifestData && Object.keys(extension.manifestData).length > 0">
|
||||
<el-divider content-position="left">Manifest 原始数据</el-divider>
|
||||
<el-input
|
||||
type="textarea"
|
||||
:model-value="JSON.stringify(extension.manifestData, null, 2)"
|
||||
:rows="10"
|
||||
readonly
|
||||
style="font-family: monospace"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<el-divider content-position="left">使用统计</el-divider>
|
||||
<el-descriptions v-if="extension" :column="3" border>
|
||||
@@ -166,8 +218,8 @@
|
||||
<el-descriptions-item label="最后启用时间">
|
||||
{{ formatDate(extension.lastEnabled) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="安装来源">
|
||||
{{ getInstallSourceLabel(extension.installSource) }}
|
||||
<el-descriptions-item label="自动更新">
|
||||
{{ extension.autoUpdate ? '是' : '否' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
@@ -208,7 +260,7 @@ const loadExtension = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
extension.value = await extensionStore.fetchExtension(extensionId.value)
|
||||
|
||||
|
||||
// 初始化 options 数据
|
||||
if (extension.value.options) {
|
||||
optionsData.value = { ...extension.value.options }
|
||||
@@ -258,7 +310,6 @@ const getTypeLabel = (type?: string) => {
|
||||
}
|
||||
|
||||
const getSettingLabel = (key: string) => {
|
||||
// 将驼峰命名转换为可读标签
|
||||
return key
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, (str) => str.toUpperCase())
|
||||
@@ -269,26 +320,24 @@ const getSettingPlaceholder = (key: string) => {
|
||||
return `请输入 ${getSettingLabel(key)}`
|
||||
}
|
||||
|
||||
const isSelectField = (key: string) => {
|
||||
// 判断是否是选择字段(可根据 extension.options 或 manifest 定义)
|
||||
const isSelectField = (_key: string) => {
|
||||
return false
|
||||
}
|
||||
|
||||
const getSelectOptions = (key: string) => {
|
||||
// 返回选择器选项
|
||||
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: '文件',
|
||||
url: 'URL 下载',
|
||||
git: 'Git 仓库',
|
||||
file: '文件导入',
|
||||
marketplace: '应用市场',
|
||||
}
|
||||
return labels[source] || source
|
||||
|
||||
461
web-app-vue/src/views/provider/ProviderList.vue
Normal file
461
web-app-vue/src/views/provider/ProviderList.vue
Normal file
@@ -0,0 +1,461 @@
|
||||
<template>
|
||||
<div class="provider-page">
|
||||
<!-- 顶部说明 -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<h2>AI 接口配置</h2>
|
||||
<p class="header-desc">
|
||||
配置你的 AI 接口后,就可以和角色卡进行对话了。
|
||||
支持 OpenAI、Claude、Gemini,以及所有兼容 OpenAI 格式的接口。
|
||||
</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="showCreateDialog = true">
|
||||
<el-icon class="mr-1"><Plus /></el-icon>
|
||||
添加接口
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 无数据提示 -->
|
||||
<div v-if="!store.loading && !store.hasProvider" class="empty-state">
|
||||
<el-empty description="还没有配置 AI 接口">
|
||||
<el-button type="primary" @click="showCreateDialog = true">
|
||||
立即配置
|
||||
</el-button>
|
||||
</el-empty>
|
||||
<div class="quick-guide">
|
||||
<h3>快速上手</h3>
|
||||
<ol>
|
||||
<li>点击"添加接口"按钮</li>
|
||||
<li>选择你使用的 AI 平台(如 OpenAI、Claude)</li>
|
||||
<li>填入 API Key,点击测试连接</li>
|
||||
<li>连接成功后保存,就可以开始对话了!</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提供商列表 -->
|
||||
<div v-else class="provider-list">
|
||||
<el-card
|
||||
v-for="provider in store.providers"
|
||||
:key="provider.id"
|
||||
class="provider-card"
|
||||
:class="{ 'is-default': provider.isDefault, 'is-disabled': !provider.isEnabled }"
|
||||
shadow="hover"
|
||||
>
|
||||
<div class="card-header">
|
||||
<div class="card-info">
|
||||
<div class="provider-icon">
|
||||
<img :src="getProviderIcon(provider.providerType)" :alt="provider.providerType" />
|
||||
</div>
|
||||
<div class="provider-meta">
|
||||
<div class="provider-name">
|
||||
{{ provider.providerName }}
|
||||
<el-tag v-if="provider.isDefault" type="primary" size="small" class="ml-2">默认</el-tag>
|
||||
<el-tag v-if="!provider.isEnabled" type="info" size="small" class="ml-2">已禁用</el-tag>
|
||||
</div>
|
||||
<div class="provider-type">{{ getProviderTypeLabel(provider.providerType) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<el-button
|
||||
text
|
||||
type="primary"
|
||||
:loading="testingId === provider.id"
|
||||
@click="handleTest(provider.id)"
|
||||
>
|
||||
测试连接
|
||||
</el-button>
|
||||
<el-button
|
||||
text
|
||||
type="success"
|
||||
@click="handleTestMessage(provider)"
|
||||
>
|
||||
测试消息
|
||||
</el-button>
|
||||
<el-dropdown trigger="click" @command="(cmd: string) => handleCommand(cmd, provider)">
|
||||
<el-button text>
|
||||
<el-icon><MoreFilled /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="edit">编辑</el-dropdown-item>
|
||||
<el-dropdown-item v-if="!provider.isDefault" command="setDefault">设为默认</el-dropdown-item>
|
||||
<el-dropdown-item :command="provider.isEnabled ? 'disable' : 'enable'">
|
||||
{{ provider.isEnabled ? '禁用' : '启用' }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" divided style="color: var(--el-color-danger)">
|
||||
删除
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key 和 URL 信息 -->
|
||||
<div class="card-body">
|
||||
<div class="info-row">
|
||||
<span class="info-label">接口地址:</span>
|
||||
<span class="info-value">{{ provider.baseUrl || '默认' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">API Key:</span>
|
||||
<span class="info-value">{{ provider.apiKeyHint || '未设置' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型列表 -->
|
||||
<div class="card-models">
|
||||
<div class="models-header">
|
||||
<span class="models-title">可用模型</span>
|
||||
<el-button text type="primary" size="small" @click="handleAddModel(provider)">
|
||||
+ 添加模型
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="model-tags">
|
||||
<el-tag
|
||||
v-for="model in provider.models"
|
||||
:key="model.id"
|
||||
:type="model.modelType === 'image_gen' ? 'warning' : undefined"
|
||||
:effect="model.isEnabled ? 'light' : 'plain'"
|
||||
closable
|
||||
class="model-tag"
|
||||
@close="handleDeleteModel(model)"
|
||||
>
|
||||
{{ model.displayName || model.modelName }}
|
||||
<span v-if="model.modelType === 'image_gen'" class="model-type-badge">绘图</span>
|
||||
</el-tag>
|
||||
<el-tag v-if="provider.models.length === 0" type="info" effect="plain">
|
||||
暂无模型
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 创建/编辑对话框 -->
|
||||
<ProviderDialog
|
||||
v-model="showCreateDialog"
|
||||
:editing-provider="editingProvider"
|
||||
@saved="handleSaved"
|
||||
/>
|
||||
|
||||
<!-- 添加模型对话框 -->
|
||||
<ModelDialog
|
||||
v-model="showModelDialog"
|
||||
:provider="modelDialogProvider"
|
||||
@saved="handleModelSaved"
|
||||
/>
|
||||
|
||||
<!-- 测试消息对话框 -->
|
||||
<TestMessageDialog
|
||||
v-model="showTestMessageDialog"
|
||||
:provider="testMessageProvider"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Plus, MoreFilled } from '@element-plus/icons-vue'
|
||||
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||
import { useProviderStore } from '@/stores/provider'
|
||||
import ProviderDialog from './components/ProviderDialog.vue'
|
||||
import ModelDialog from './components/ModelDialog.vue'
|
||||
import TestMessageDialog from './components/TestMessageDialog.vue'
|
||||
|
||||
const store = useProviderStore()
|
||||
|
||||
const showCreateDialog = ref(false)
|
||||
const showModelDialog = ref(false)
|
||||
const showTestMessageDialog = ref(false)
|
||||
const editingProvider = ref<AIProvider | null>(null)
|
||||
const modelDialogProvider = ref<AIProvider | null>(null)
|
||||
const testMessageProvider = ref<AIProvider | null>(null)
|
||||
const testingId = ref<number | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchProviders()
|
||||
await store.fetchProviderTypes()
|
||||
})
|
||||
|
||||
/** 获取提供商图标 */
|
||||
function getProviderIcon(type: string) {
|
||||
const icons: Record<string, string> = {
|
||||
openai: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><text y="18" font-size="16">🤖</text></svg>',
|
||||
claude: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><text y="18" font-size="16">🧠</text></svg>',
|
||||
gemini: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><text y="18" font-size="16">✨</text></svg>',
|
||||
custom: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><text y="18" font-size="16">⚙️</text></svg>',
|
||||
}
|
||||
return icons[type] || icons.custom
|
||||
}
|
||||
|
||||
/** 获取提供商类型标签 */
|
||||
function getProviderTypeLabel(type: string) {
|
||||
const labels: Record<string, string> = {
|
||||
openai: 'OpenAI / 兼容接口',
|
||||
claude: 'Anthropic Claude',
|
||||
gemini: 'Google Gemini',
|
||||
custom: '自定义接口',
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
/** 测试连接 */
|
||||
async function handleTest(providerId: number) {
|
||||
testingId.value = providerId
|
||||
const result = await store.testExisting(providerId)
|
||||
testingId.value = null
|
||||
|
||||
if (result) {
|
||||
if (result.success) {
|
||||
ElMessage.success(`连接成功!延迟 ${result.latency}ms`)
|
||||
} else {
|
||||
ElMessage.error(`连接失败: ${result.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理下拉菜单 */
|
||||
async function handleCommand(command: string, provider: AIProvider) {
|
||||
switch (command) {
|
||||
case 'edit':
|
||||
editingProvider.value = provider
|
||||
showCreateDialog.value = true
|
||||
break
|
||||
case 'setDefault':
|
||||
await store.setDefault(provider.id)
|
||||
break
|
||||
case 'enable':
|
||||
await store.updateProvider({ ...provider, isEnabled: true })
|
||||
break
|
||||
case 'disable':
|
||||
await store.updateProvider({ ...provider, isEnabled: false })
|
||||
break
|
||||
case 'delete':
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除「${provider.providerName}」吗?关联的模型也会一起删除。`,
|
||||
'删除确认',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
await store.deleteProvider(provider.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/** 测试消息 */
|
||||
function handleTestMessage(provider: AIProvider) {
|
||||
testMessageProvider.value = provider
|
||||
showTestMessageDialog.value = true
|
||||
}
|
||||
|
||||
/** 添加模型 */
|
||||
function handleAddModel(provider: AIProvider) {
|
||||
modelDialogProvider.value = provider
|
||||
showModelDialog.value = true
|
||||
}
|
||||
|
||||
/** 删除模型 */
|
||||
async function handleDeleteModel(model: AIModel) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除模型「${model.displayName || model.modelName}」吗?`,
|
||||
'删除确认',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
await store.deleteModel(model.id)
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存提供商回调 */
|
||||
function handleSaved() {
|
||||
showCreateDialog.value = false
|
||||
editingProvider.value = null
|
||||
}
|
||||
|
||||
/** 保存模型回调 */
|
||||
function handleModelSaved() {
|
||||
showModelDialog.value = false
|
||||
modelDialogProvider.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.provider-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.header-content h2 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.header-desc {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
|
||||
.quick-guide {
|
||||
max-width: 400px;
|
||||
margin: 32px auto 0;
|
||||
text-align: left;
|
||||
background: var(--el-bg-color-page);
|
||||
border-radius: 8px;
|
||||
padding: 20px 24px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
ol {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
line-height: 2;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.provider-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.provider-card {
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.is-default {
|
||||
border-left: 3px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.provider-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: var(--el-bg-color-page);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.provider-type {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
margin-top: 16px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
line-height: 2;
|
||||
|
||||
.info-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: var(--el-text-color-regular);
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-models {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
.models-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.models-title {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.model-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.model-tag {
|
||||
.model-type-badge {
|
||||
font-size: 10px;
|
||||
margin-left: 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ml-1 { margin-left: 4px; }
|
||||
.ml-2 { margin-left: 8px; }
|
||||
.mr-1 { margin-right: 4px; }
|
||||
</style>
|
||||
455
web-app-vue/src/views/provider/components/ModelDialog.vue
Normal file
455
web-app-vue/src/views/provider/components/ModelDialog.vue
Normal file
@@ -0,0 +1,455 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="添加模型"
|
||||
width="560px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
@open="onOpen"
|
||||
>
|
||||
<!-- 数据源切换标签 -->
|
||||
<el-tabs v-model="activeTab" class="model-tabs">
|
||||
<!-- Tab 1: 从接口获取 -->
|
||||
<el-tab-pane label="从接口获取" name="remote">
|
||||
<div class="remote-section">
|
||||
<div class="remote-header">
|
||||
<p class="remote-desc">自动从 API 接口获取可用模型列表,勾选后一键添加</p>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="fetchingModels"
|
||||
@click="handleFetchModels"
|
||||
>
|
||||
<el-icon class="mr-1"><Refresh /></el-icon>
|
||||
{{ fetchingModels ? '获取中...' : '获取模型列表' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 获取结果提示 -->
|
||||
<div v-if="fetchResult" class="fetch-result" :class="{ success: fetchResult.success, error: !fetchResult.success }">
|
||||
<el-icon v-if="fetchResult.success"><CircleCheckFilled /></el-icon>
|
||||
<el-icon v-else><CircleCloseFilled /></el-icon>
|
||||
<span>{{ fetchResult.message }}{{ fetchResult.latency ? ` (${fetchResult.latency}ms)` : '' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 搜索过滤 -->
|
||||
<el-input
|
||||
v-if="remoteModels.length > 0"
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索模型..."
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
size="small"
|
||||
class="search-input"
|
||||
/>
|
||||
|
||||
<!-- 模型列表 -->
|
||||
<div v-if="filteredRemoteModels.length > 0" class="remote-model-list">
|
||||
<el-scrollbar max-height="320px">
|
||||
<div
|
||||
v-for="model in filteredRemoteModels"
|
||||
:key="model.id"
|
||||
class="remote-model-item"
|
||||
:class="{ selected: selectedRemoteModels.has(model.id), added: existingModelNames.has(model.id) }"
|
||||
@click="toggleRemoteModel(model)"
|
||||
>
|
||||
<el-checkbox
|
||||
:model-value="selectedRemoteModels.has(model.id)"
|
||||
:disabled="existingModelNames.has(model.id)"
|
||||
@click.stop
|
||||
@change="toggleRemoteModel(model)"
|
||||
/>
|
||||
<div class="model-info">
|
||||
<span class="model-id">{{ model.id }}</span>
|
||||
<span v-if="model.displayName" class="model-display-name">{{ model.displayName }}</span>
|
||||
<el-tag v-if="existingModelNames.has(model.id)" type="info" size="small">已添加</el-tag>
|
||||
</div>
|
||||
<span v-if="model.ownedBy" class="model-owner">{{ model.ownedBy }}</span>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
|
||||
<!-- 没有结果 -->
|
||||
<div v-else-if="fetchResult && fetchResult.success" class="empty-models">
|
||||
<el-empty :image-size="60" description="没有匹配的模型" />
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: 预设模型 -->
|
||||
<el-tab-pane label="预设模型" name="preset">
|
||||
<div v-if="presetModels.length > 0" class="preset-section">
|
||||
<div class="preset-list">
|
||||
<el-tag
|
||||
v-for="preset in availablePresets"
|
||||
:key="preset.modelName"
|
||||
:type="preset.modelType === 'image_gen' ? 'warning' : 'primary'"
|
||||
effect="plain"
|
||||
class="preset-tag"
|
||||
@click="selectPreset(preset)"
|
||||
>
|
||||
{{ preset.displayName || preset.modelName }}
|
||||
<span v-if="preset.modelType === 'image_gen'" class="type-badge">绘图</span>
|
||||
</el-tag>
|
||||
<el-tag v-if="availablePresets.length === 0" type="info" effect="plain">
|
||||
已全部添加
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-models">
|
||||
<el-empty :image-size="60" description="该接口类型没有预设模型" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 3: 手动输入 -->
|
||||
<el-tab-pane label="手动输入" name="manual">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<el-form-item label="模型标识" prop="modelName">
|
||||
<el-input
|
||||
v-model="form.modelName"
|
||||
placeholder="如 gpt-4o、claude-3-5-sonnet-20241022"
|
||||
/>
|
||||
<div class="form-tip">
|
||||
填写 API 调用时使用的模型名称
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="显示名称(可选)">
|
||||
<el-input
|
||||
v-model="form.displayName"
|
||||
placeholder="给模型起个好认的名字"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="模型类型" prop="modelType">
|
||||
<el-radio-group v-model="form.modelType">
|
||||
<el-radio value="chat">对话模型</el-radio>
|
||||
<el-radio value="image_gen">绘图模型</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="$emit('update:modelValue', false)">取消</el-button>
|
||||
<!-- 远程模式:批量添加选中的 -->
|
||||
<el-button
|
||||
v-if="activeTab === 'remote'"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
:disabled="selectedRemoteModels.size === 0"
|
||||
@click="handleAddRemoteModels"
|
||||
>
|
||||
添加选中({{ selectedRemoteModels.size }})
|
||||
</el-button>
|
||||
<!-- 手动模式:添加 -->
|
||||
<el-button
|
||||
v-if="activeTab === 'manual'"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
添加
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useProviderStore } from '@/stores/provider'
|
||||
import { Refresh, CircleCheckFilled, CircleCloseFilled, Search } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
provider?: AIProvider | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const store = useProviderStore()
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const fetchingModels = ref(false)
|
||||
const presetModels = ref<PresetModelOption[]>([])
|
||||
const remoteModels = ref<RemoteModel[]>([])
|
||||
const selectedRemoteModels = ref<Set<string>>(new Set())
|
||||
const fetchResult = ref<FetchRemoteModelsResponse | null>(null)
|
||||
const searchKeyword = ref('')
|
||||
const activeTab = ref('remote')
|
||||
|
||||
const form = ref({
|
||||
modelName: '',
|
||||
displayName: '',
|
||||
modelType: 'chat' as ModelType,
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
modelName: [{ required: true, message: '请输入模型标识', trigger: 'blur' }],
|
||||
modelType: [{ required: true, message: '请选择模型类型', trigger: 'change' }],
|
||||
}
|
||||
|
||||
/** 已有模型名列表,用于过滤 */
|
||||
const existingModelNames = computed(() => {
|
||||
if (!props.provider) return new Set<string>()
|
||||
return new Set(props.provider.models.map(m => m.modelName))
|
||||
})
|
||||
|
||||
/** 过滤掉已添加的预设 */
|
||||
const availablePresets = computed(() => {
|
||||
return presetModels.value.filter(p => !existingModelNames.value.has(p.modelName))
|
||||
})
|
||||
|
||||
/** 搜索过滤远程模型 */
|
||||
const filteredRemoteModels = computed(() => {
|
||||
if (!searchKeyword.value) return remoteModels.value
|
||||
const kw = searchKeyword.value.toLowerCase()
|
||||
return remoteModels.value.filter(m =>
|
||||
m.id.toLowerCase().includes(kw) ||
|
||||
(m.displayName && m.displayName.toLowerCase().includes(kw)) ||
|
||||
(m.ownedBy && m.ownedBy.toLowerCase().includes(kw))
|
||||
)
|
||||
})
|
||||
|
||||
/** 获取远程模型列表 */
|
||||
async function handleFetchModels() {
|
||||
if (!props.provider) return
|
||||
fetchingModels.value = true
|
||||
fetchResult.value = null
|
||||
|
||||
const result = await store.fetchRemoteModelsExisting(props.provider.id)
|
||||
fetchingModels.value = false
|
||||
|
||||
if (result) {
|
||||
fetchResult.value = result
|
||||
if (result.success) {
|
||||
remoteModels.value = result.models || []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换远程模型选中状态 */
|
||||
function toggleRemoteModel(model: RemoteModel) {
|
||||
if (existingModelNames.value.has(model.id)) return
|
||||
const newSet = new Set(selectedRemoteModels.value)
|
||||
if (newSet.has(model.id)) {
|
||||
newSet.delete(model.id)
|
||||
} else {
|
||||
newSet.add(model.id)
|
||||
}
|
||||
selectedRemoteModels.value = newSet
|
||||
}
|
||||
|
||||
/** 批量添加远程选中的模型 */
|
||||
async function handleAddRemoteModels() {
|
||||
if (!props.provider || selectedRemoteModels.value.size === 0) return
|
||||
loading.value = true
|
||||
|
||||
for (const modelId of selectedRemoteModels.value) {
|
||||
const remote = remoteModels.value.find(m => m.id === modelId)
|
||||
await store.addModel({
|
||||
providerId: props.provider.id,
|
||||
modelName: modelId,
|
||||
displayName: remote?.displayName || '',
|
||||
modelType: 'chat', // 默认为对话模型
|
||||
})
|
||||
}
|
||||
|
||||
selectedRemoteModels.value = new Set()
|
||||
loading.value = false
|
||||
// 不关闭对话框,允许继续操作
|
||||
}
|
||||
|
||||
/** 选择预设模型直接添加 */
|
||||
async function selectPreset(preset: PresetModelOption) {
|
||||
if (!props.provider) return
|
||||
loading.value = true
|
||||
await store.addModel({
|
||||
providerId: props.provider.id,
|
||||
modelName: preset.modelName,
|
||||
displayName: preset.displayName,
|
||||
modelType: preset.modelType,
|
||||
})
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
/** 手动提交 */
|
||||
async function handleSubmit() {
|
||||
await formRef.value?.validate()
|
||||
if (!props.provider) return
|
||||
|
||||
loading.value = true
|
||||
const result = await store.addModel({
|
||||
providerId: props.provider.id,
|
||||
modelName: form.value.modelName,
|
||||
displayName: form.value.displayName || undefined,
|
||||
modelType: form.value.modelType,
|
||||
})
|
||||
loading.value = false
|
||||
|
||||
if (result) {
|
||||
emit('saved')
|
||||
}
|
||||
}
|
||||
|
||||
/** 对话框打开 */
|
||||
async function onOpen() {
|
||||
form.value = { modelName: '', displayName: '', modelType: 'chat' }
|
||||
remoteModels.value = []
|
||||
selectedRemoteModels.value = new Set()
|
||||
fetchResult.value = null
|
||||
searchKeyword.value = ''
|
||||
activeTab.value = 'remote'
|
||||
|
||||
if (props.provider) {
|
||||
const models = await store.fetchPresetModels(props.provider.providerType)
|
||||
presetModels.value = models
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.model-tabs {
|
||||
:deep(.el-tabs__content) {
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.remote-section {
|
||||
.remote-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.remote-desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.fetch-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&.success {
|
||||
color: var(--el-color-success);
|
||||
background: var(--el-color-success-light-9);
|
||||
}
|
||||
&.error {
|
||||
color: var(--el-color-danger);
|
||||
background: var(--el-color-danger-light-9);
|
||||
}
|
||||
}
|
||||
|
||||
.search-input {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.remote-model-list {
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.remote-model-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover:not(.added) {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
&.added {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.model-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
|
||||
.model-id {
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.model-display-name {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.model-owner {
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preset-section {
|
||||
.preset-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-tag {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
font-size: 10px;
|
||||
margin-left: 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-models {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.mr-1 { margin-right: 4px; }
|
||||
</style>
|
||||
338
web-app-vue/src/views/provider/components/ProviderDialog.vue
Normal file
338
web-app-vue/src/views/provider/components/ProviderDialog.vue
Normal file
@@ -0,0 +1,338 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
:title="isEditing ? '编辑 AI 接口' : '添加 AI 接口'"
|
||||
width="560px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
@open="onOpen"
|
||||
@closed="onClosed"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<!-- 步骤1:选择平台 -->
|
||||
<el-form-item label="AI 平台" prop="providerType">
|
||||
<div class="platform-selector">
|
||||
<div
|
||||
v-for="option in store.providerTypes"
|
||||
:key="option.value"
|
||||
class="platform-option"
|
||||
:class="{ active: form.providerType === option.value }"
|
||||
@click="selectPlatform(option)"
|
||||
>
|
||||
<div class="platform-icon">{{ getPlatformEmoji(option.value) }}</div>
|
||||
<div class="platform-name">{{ option.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="selectedPlatformDesc" class="platform-desc">{{ selectedPlatformDesc }}</p>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 步骤2:配置名称 -->
|
||||
<el-form-item label="配置名称" prop="providerName">
|
||||
<el-input
|
||||
v-model="form.providerName"
|
||||
placeholder="给这个配置起个名字,如「我的 GPT」"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 步骤3:填写 API Key -->
|
||||
<el-form-item label="API Key" prop="apiKey">
|
||||
<el-input
|
||||
v-model="form.apiKey"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="isEditing ? '留空表示不修改' : '粘贴你的 API Key'"
|
||||
/>
|
||||
<div class="form-tip">
|
||||
你的 Key 会加密保存在服务器上,不会泄露。
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 步骤4:API 地址(可选) -->
|
||||
<el-form-item label="API 地址(可选)">
|
||||
<el-input
|
||||
v-model="form.baseUrl"
|
||||
:placeholder="defaultUrl || '请输入 API 地址'"
|
||||
/>
|
||||
<div class="form-tip">
|
||||
<span v-if="form.providerType === 'custom'">
|
||||
填写兼容 OpenAI 格式的接口地址,如 DeepSeek、通义千问的地址
|
||||
</span>
|
||||
<span v-else>
|
||||
留空使用官方地址。如果你使用中转站,填写中转站地址。
|
||||
</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 测试连接 -->
|
||||
<div class="test-section">
|
||||
<el-button
|
||||
:loading="store.testLoading"
|
||||
:type="testResult?.success ? 'success' : 'default'"
|
||||
@click="handleTestConnection"
|
||||
>
|
||||
{{ store.testLoading ? '测试中...' : '测试连接' }}
|
||||
</el-button>
|
||||
<div v-if="testResult" class="test-result" :class="{ success: testResult.success, error: !testResult.success }">
|
||||
<el-icon v-if="testResult.success"><CircleCheckFilled /></el-icon>
|
||||
<el-icon v-else><CircleCloseFilled /></el-icon>
|
||||
<span>{{ testResult.message }}{{ testResult.latency ? ` (${testResult.latency}ms)` : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="$emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="store.loading" @click="handleSubmit">
|
||||
{{ isEditing ? '保存' : '添加' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
|
||||
import { useProviderStore } from '@/stores/provider'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
editingProvider?: AIProvider | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const store = useProviderStore()
|
||||
const formRef = ref<FormInstance>()
|
||||
const testResult = ref<TestProviderResponse | null>(null)
|
||||
|
||||
const isEditing = computed(() => !!props.editingProvider)
|
||||
|
||||
const form = ref({
|
||||
providerType: 'openai' as ProviderType,
|
||||
providerName: '',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
providerType: [{ required: true, message: '请选择 AI 平台', trigger: 'change' }],
|
||||
providerName: [{ required: true, message: '请输入配置名称', trigger: 'blur' }],
|
||||
apiKey: [{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (!isEditing.value && !value) {
|
||||
callback(new Error('请输入 API Key'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}],
|
||||
}
|
||||
|
||||
const defaultUrl = computed(() => {
|
||||
const option = store.providerTypes.find(t => t.value === form.value.providerType)
|
||||
return option?.defaultUrl || ''
|
||||
})
|
||||
|
||||
const selectedPlatformDesc = computed(() => {
|
||||
const option = store.providerTypes.find(t => t.value === form.value.providerType)
|
||||
return option?.description || ''
|
||||
})
|
||||
|
||||
function getPlatformEmoji(type: string): string {
|
||||
const emojis: Record<string, string> = {
|
||||
openai: '🤖',
|
||||
claude: '🧠',
|
||||
gemini: '✨',
|
||||
custom: '⚙️',
|
||||
}
|
||||
return emojis[type] || '⚙️'
|
||||
}
|
||||
|
||||
function selectPlatform(option: ProviderTypeOption) {
|
||||
form.value.providerType = option.value as ProviderType
|
||||
// 自动填充名称
|
||||
if (!form.value.providerName || form.value.providerName === getAutoName(form.value.providerType)) {
|
||||
form.value.providerName = getAutoName(option.value)
|
||||
}
|
||||
testResult.value = null
|
||||
}
|
||||
|
||||
function getAutoName(type: string): string {
|
||||
const names: Record<string, string> = {
|
||||
openai: '我的 OpenAI',
|
||||
claude: '我的 Claude',
|
||||
gemini: '我的 Gemini',
|
||||
custom: '我的 AI 接口',
|
||||
}
|
||||
return names[type] || '我的 AI 接口'
|
||||
}
|
||||
|
||||
/** 测试连接 */
|
||||
async function handleTestConnection() {
|
||||
if (!form.value.apiKey && !isEditing.value) {
|
||||
testResult.value = { success: false, message: '请先填写 API Key', latency: 0 }
|
||||
return
|
||||
}
|
||||
|
||||
// 如果编辑模式且没有填新的 Key,提示
|
||||
if (isEditing.value && !form.value.apiKey) {
|
||||
if (props.editingProvider) {
|
||||
// 使用已保存的进行测试
|
||||
const result = await store.testExisting(props.editingProvider.id)
|
||||
testResult.value = result
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const result = await store.testConnection({
|
||||
providerType: form.value.providerType,
|
||||
baseUrl: form.value.baseUrl,
|
||||
apiKey: form.value.apiKey,
|
||||
})
|
||||
testResult.value = result
|
||||
}
|
||||
|
||||
/** 提交表单 */
|
||||
async function handleSubmit() {
|
||||
await formRef.value?.validate()
|
||||
|
||||
if (isEditing.value && props.editingProvider) {
|
||||
// 更新
|
||||
const data: UpdateProviderRequest = {
|
||||
id: props.editingProvider.id,
|
||||
providerName: form.value.providerName,
|
||||
providerType: form.value.providerType,
|
||||
baseUrl: form.value.baseUrl,
|
||||
apiKey: form.value.apiKey || undefined,
|
||||
}
|
||||
const result = await store.updateProvider(data)
|
||||
if (result) {
|
||||
emit('saved')
|
||||
}
|
||||
} else {
|
||||
// 创建
|
||||
const data: CreateProviderRequest = {
|
||||
providerName: form.value.providerName,
|
||||
providerType: form.value.providerType,
|
||||
baseUrl: form.value.baseUrl,
|
||||
apiKey: form.value.apiKey,
|
||||
}
|
||||
const result = await store.createProvider(data)
|
||||
if (result) {
|
||||
emit('saved')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 对话框打开时初始化表单 */
|
||||
function onOpen() {
|
||||
if (props.editingProvider) {
|
||||
form.value = {
|
||||
providerType: props.editingProvider.providerType as ProviderType,
|
||||
providerName: props.editingProvider.providerName,
|
||||
apiKey: '',
|
||||
baseUrl: props.editingProvider.baseUrl,
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
providerType: 'openai',
|
||||
providerName: '我的 OpenAI',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
}
|
||||
}
|
||||
testResult.value = null
|
||||
}
|
||||
|
||||
/** 对话框关闭时重置 */
|
||||
function onClosed() {
|
||||
formRef.value?.resetFields()
|
||||
testResult.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.platform-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.platform-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px 8px;
|
||||
border: 2px solid var(--el-border-color-light);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary-light-3);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.platform-icon {
|
||||
font-size: 28px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.platform-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.platform-desc {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color-page);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.test-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
|
||||
&.success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
&.error {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
253
web-app-vue/src/views/provider/components/TestMessageDialog.vue
Normal file
253
web-app-vue/src/views/provider/components/TestMessageDialog.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="发送测试消息"
|
||||
width="560px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
@open="onOpen"
|
||||
@closed="onClosed"
|
||||
>
|
||||
<div class="test-message-content">
|
||||
<!-- 提供商信息 -->
|
||||
<div class="provider-info">
|
||||
<span class="provider-label">{{ provider?.providerName }}</span>
|
||||
<el-tag size="small">{{ getProviderTypeLabel(provider?.providerType || '') }}</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- 模型选择 -->
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="选择模型">
|
||||
<el-select
|
||||
v-model="selectedModel"
|
||||
placeholder="请选择要测试的模型"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="model in chatModels"
|
||||
:key="model.id"
|
||||
:label="model.displayName || model.modelName"
|
||||
:value="model.modelName"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 消息内容 -->
|
||||
<el-form-item label="测试消息">
|
||||
<el-input
|
||||
v-model="message"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="留空使用默认消息:你好,请用一句话介绍你自己。"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 发送按钮 -->
|
||||
<div class="send-section">
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="sending"
|
||||
:disabled="!selectedModel"
|
||||
@click="handleSend"
|
||||
>
|
||||
<el-icon class="mr-1"><Promotion /></el-icon>
|
||||
{{ sending ? '等待回复...' : '发送测试消息' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 测试结果 -->
|
||||
<div v-if="testResult" class="result-section">
|
||||
<div class="result-header" :class="{ success: testResult.success, error: !testResult.success }">
|
||||
<el-icon v-if="testResult.success"><CircleCheckFilled /></el-icon>
|
||||
<el-icon v-else><CircleCloseFilled /></el-icon>
|
||||
<span>{{ testResult.message }}</span>
|
||||
<span v-if="testResult.latency" class="result-meta">
|
||||
{{ testResult.latency }}ms
|
||||
</span>
|
||||
<span v-if="testResult.tokens" class="result-meta">
|
||||
{{ testResult.tokens }} tokens
|
||||
</span>
|
||||
<span v-if="testResult.model" class="result-meta">
|
||||
模型: {{ testResult.model }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- AI 回复内容 -->
|
||||
<div v-if="testResult.reply" class="result-reply">
|
||||
<div class="reply-label">AI 回复:</div>
|
||||
<div class="reply-content">{{ testResult.reply }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="$emit('update:modelValue', false)">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Promotion, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
|
||||
import { useProviderStore } from '@/stores/provider'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
provider?: AIProvider | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const store = useProviderStore()
|
||||
const selectedModel = ref('')
|
||||
const message = ref('')
|
||||
const sending = ref(false)
|
||||
const testResult = ref<SendTestMessageResponse | null>(null)
|
||||
|
||||
/** 只显示对话类型模型 */
|
||||
const chatModels = computed(() => {
|
||||
if (!props.provider) return []
|
||||
return props.provider.models.filter(m => m.modelType === 'chat' && m.isEnabled)
|
||||
})
|
||||
|
||||
function getProviderTypeLabel(type: string) {
|
||||
const labels: Record<string, string> = {
|
||||
openai: 'OpenAI / 兼容接口',
|
||||
claude: 'Anthropic Claude',
|
||||
gemini: 'Google Gemini',
|
||||
custom: '自定义接口',
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
/** 发送测试消息 */
|
||||
async function handleSend() {
|
||||
if (!props.provider || !selectedModel.value) return
|
||||
|
||||
sending.value = true
|
||||
testResult.value = null
|
||||
|
||||
const result = await store.sendTestMessageExisting(
|
||||
props.provider.id,
|
||||
selectedModel.value,
|
||||
message.value || undefined,
|
||||
)
|
||||
|
||||
sending.value = false
|
||||
|
||||
if (result) {
|
||||
testResult.value = result
|
||||
} else {
|
||||
testResult.value = {
|
||||
success: false,
|
||||
message: '请求失败,请检查网络',
|
||||
reply: '',
|
||||
model: '',
|
||||
latency: 0,
|
||||
tokens: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 对话框打开 */
|
||||
function onOpen() {
|
||||
testResult.value = null
|
||||
message.value = ''
|
||||
// 默认选中第一个对话模型
|
||||
if (chatModels.value.length > 0) {
|
||||
selectedModel.value = chatModels.value[0].modelName
|
||||
} else {
|
||||
selectedModel.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 对话框关闭 */
|
||||
function onClosed() {
|
||||
testResult.value = null
|
||||
sending.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.test-message-content {
|
||||
.provider-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 14px;
|
||||
background: var(--el-bg-color-page);
|
||||
border-radius: 8px;
|
||||
|
||||
.provider-label {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.send-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&.success {
|
||||
background: var(--el-color-success-light-9);
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
&.error {
|
||||
background: var(--el-color-danger-light-9);
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
margin-left: 4px;
|
||||
|
||||
&::before {
|
||||
content: '·';
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.result-reply {
|
||||
padding: 14px;
|
||||
|
||||
.reply-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.reply-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-primary);
|
||||
background: var(--el-bg-color-page);
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mr-1 { margin-right: 4px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user