🎨 优化扩展模块,完成ai接入和对话功能

This commit is contained in:
2026-02-12 23:12:28 +08:00
parent 4e611d3a5e
commit 572f3aa15b
779 changed files with 194400 additions and 3136 deletions

View File

@@ -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>

View File

@@ -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",

View File

@@ -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
View 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)
}

View 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 }
})
}

View File

@@ -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']

View File

@@ -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
}
}
/**
* 将扩展设置容器归还到 bodyDrawer 关闭时调用)
*/
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;

View File

@@ -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 '/'
})

View File

@@ -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()

View File

@@ -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 },
},
],
},
]

View 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
View 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
View 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
}

View 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)')

View File

@@ -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(/^\//, '')}`
}
/**

View File

@@ -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)
}
}
// 切换收藏

View 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>

View 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, '&amp;')
.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>

View File

@@ -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

View 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 接口后就可以和角色卡进行对话了
支持 OpenAIClaudeGemini以及所有兼容 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 平台 OpenAIClaude</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>

View 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>

View 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>
<!-- 步骤4API 地址可选 -->
<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>

View 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>