feat: 更新 Web 前端页面
- 更新 Agents、Chat、Settings 等页面 - 新增 ModelAPIs 页面 - 更新各个模块的 composables - 更新 vite 配置和依赖版本 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
11
web/.env.example
Normal file
11
web/.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
# X-Agents Web 前端配置
|
||||
# 复制此文件为 .env.local 后修改
|
||||
|
||||
# 开发环境配置
|
||||
VITE_APP_TITLE=X-Agents
|
||||
|
||||
# API 基础地址(Go 后端)
|
||||
VITE_API_BASE=http://localhost:8082
|
||||
|
||||
# 开发服务器端口
|
||||
VITE_PORT=5173
|
||||
95
web/package-lock.json
generated
95
web/package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"axios": "^1.13.6",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.13.3",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-vue-next": "^0.577.0",
|
||||
"marked": "^17.0.4",
|
||||
"monaco-editor": "^0.55.1",
|
||||
@@ -1563,6 +1564,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -2033,6 +2040,18 @@
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -2095,6 +2114,12 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz",
|
||||
@@ -2105,6 +2130,27 @@
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -2356,6 +2402,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/papaparse": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmmirror.com/papaparse/-/papaparse-5.5.3.tgz",
|
||||
@@ -2599,6 +2651,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@@ -2636,6 +2694,21 @@
|
||||
"pify": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
|
||||
@@ -2750,6 +2823,18 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2759,6 +2844,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.1.tgz",
|
||||
@@ -2986,7 +3080,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"axios": "^1.13.6",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.13.3",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-vue-next": "^0.577.0",
|
||||
"marked": "^17.0.4",
|
||||
"monaco-editor": "^0.55.1",
|
||||
|
||||
@@ -197,6 +197,42 @@ body {
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-box {
|
||||
min-height: 340px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1f2937, #111827);
|
||||
border-radius: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-icon i {
|
||||
font-size: 40px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #d1d5db;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: var(--spacing-2xl);
|
||||
text-align: center;
|
||||
|
||||
10
web/src/composables/useApiBase.ts
Normal file
10
web/src/composables/useApiBase.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* API 基础地址配置
|
||||
* 从环境变量读取,默认为 http://localhost:8082
|
||||
*/
|
||||
|
||||
// 开发环境使用 vite 代理,生产环境需要配置实际地址
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8082'
|
||||
|
||||
export const apiBase = API_BASE
|
||||
export default API_BASE
|
||||
@@ -1,4 +1,4 @@
|
||||
const API_BASE = 'http://localhost:8082'
|
||||
import API_BASE from './useApiBase'
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
|
||||
@@ -1,332 +0,0 @@
|
||||
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
|
||||
// 类型定义
|
||||
export interface ChatModel {
|
||||
id: string
|
||||
name: string
|
||||
model_type: string
|
||||
provider: string
|
||||
model: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: number
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: Date
|
||||
isStreaming?: boolean
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
id: number
|
||||
name: string
|
||||
avatar: string
|
||||
description: string
|
||||
accentColor: string
|
||||
gradient: string
|
||||
status: 'online' | 'offline'
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
id: number
|
||||
title: string
|
||||
agentId: number
|
||||
lastMessage: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export interface GroupChat {
|
||||
id: number
|
||||
name: string
|
||||
members: string[]
|
||||
lastMessage: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
// 配置 marked
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true
|
||||
})
|
||||
|
||||
// 预处理内容:修复一些常见的 Markdown 问题
|
||||
const preprocessContent = (content: string): string => {
|
||||
if (!content) return ''
|
||||
|
||||
// 1. 标题:# 标题 -> # 标题(确保 # 后有空格)
|
||||
content = content.replace(/(^|\n)(#{1,6})([^\s#\n])/g, '$1$2 $3')
|
||||
|
||||
// 2. 无序列表:-项目 -> - 项目
|
||||
content = content.replace(/(\n)(\s*)([-*+])(\S)/g, '$1$2$3 $4')
|
||||
|
||||
// 3. 有序列表:1.项目 -> 1. 项目
|
||||
content = content.replace(/(\n)(\s*)(\d+\.)(\S)/g, '$1$2$3 $4')
|
||||
|
||||
// 4. 引用:>引用 -> > 引用
|
||||
content = content.replace(/(\n)(>+)([^\s>\n])/g, '$1$2 $3')
|
||||
|
||||
// 5. 修复 ##1. 这种情况(连续处理)
|
||||
content = content.replace(/(#{1,6})(\d+\.)/g, '$1 $2')
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
// 渲染 Markdown
|
||||
export const renderMarkdown = (content: string): string => {
|
||||
if (!content) return ''
|
||||
try {
|
||||
const processed = preprocessContent(content)
|
||||
return marked.parse(processed) as string
|
||||
} catch (e) {
|
||||
console.error('Markdown parse error:', e)
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
// 根据 provider 获取图标
|
||||
export const getModelIcon = (provider: string) => {
|
||||
const icons: Record<string, string> = {
|
||||
'OpenAI': '🤖',
|
||||
'Claude': '🧠',
|
||||
'Google': '✨',
|
||||
'Gemini': '✨',
|
||||
'Ollama': '🦙',
|
||||
'DeepSeek': '🔮',
|
||||
'Moonshot': '🌙',
|
||||
'Kimi': '🌙',
|
||||
'Baidu': '🐉',
|
||||
'文心一言': '🐉',
|
||||
'Aliyun': '☁️',
|
||||
'Ali': '☁️',
|
||||
'通义千问': '☁️',
|
||||
'Azure': '⬛',
|
||||
'Anthropic': '🧠',
|
||||
}
|
||||
return icons[provider] || '💬'
|
||||
}
|
||||
|
||||
// 创建 composable
|
||||
export function useChat() {
|
||||
// 模型相关状态
|
||||
const chatModels = ref<ChatModel[]>([])
|
||||
const selectedModel = ref<ChatModel | null>(null)
|
||||
const modelsLoading = ref(false)
|
||||
const showModelDropdown = ref(false)
|
||||
|
||||
// 助手相关状态
|
||||
const chatAgents = ref<Agent[]>([
|
||||
{ id: 1, name: 'Claude', avatar: '🧠', description: 'Anthropic AI', accentColor: '#f97316', gradient: 'from-orange-500/20 to-amber-500/20', status: 'online' },
|
||||
{ id: 2, name: 'Gemini', avatar: '✨', description: 'Google DeepMind', accentColor: '#8b5cf6', gradient: 'from-violet-500/20 to-purple-500/20', status: 'online' },
|
||||
{ id: 3, name: 'ChatGPT', avatar: '💬', description: 'OpenAI', accentColor: '#10b981', gradient: 'from-emerald-500/20 to-green-500/20', status: 'offline' },
|
||||
{ id: 4, name: 'DeepSeek', avatar: '🔮', description: 'DeepSeek AI', accentColor: '#3b82f6', gradient: 'from-blue-500/20 to-cyan-500/20', status: 'online' },
|
||||
{ id: 5, name: 'Kimi', avatar: '🌙', description: 'Moonshot AI', accentColor: '#ec4899', gradient: 'from-pink-500/20 to-rose-500/20', status: 'online' },
|
||||
{ id: 6, name: '文心一言', avatar: '🐉', description: 'Baidu', accentColor: '#ef4444', gradient: 'from-red-500/20 to-orange-500/20', status: 'offline' },
|
||||
{ id: 7, name: '通义千问', avatar: '☁️', description: 'Alibaba', accentColor: '#06b6d4', gradient: 'from-cyan-500/20 to-sky-500/20', status: 'online' },
|
||||
])
|
||||
const selectedAgent = ref<Agent | null>(chatAgents.value[0])
|
||||
|
||||
// 消息相关状态
|
||||
const messages = ref<ChatMessage[]>([
|
||||
{ id: 1, role: 'assistant', content: '你好!我是 Claude,你的 AI 助手。有什么我可以帮助你的吗?', timestamp: new Date() },
|
||||
])
|
||||
|
||||
// 历史对话
|
||||
const chatSessions = ref<ChatSession[]>([
|
||||
{ id: 1, title: '关于 Python 学习的讨论', agentId: 1, lastMessage: '谢谢你!', timestamp: new Date(Date.now() - 3600000) },
|
||||
{ id: 2, title: '代码调试帮助', agentId: 1, lastMessage: '让我看看这个问题...', timestamp: new Date(Date.now() - 7200000) },
|
||||
{ id: 3, title: '数据分析咨询', agentId: 4, lastMessage: 'DeepSeek: 好的', timestamp: new Date(Date.now() - 86400000) },
|
||||
])
|
||||
|
||||
// 群聊
|
||||
const groupChats = ref<GroupChat[]>([
|
||||
{ id: 1, name: 'AI 讨论组', members: ['Claude', 'GPT-4', 'Gemini'], lastMessage: '我们来讨论一下...', timestamp: new Date(Date.now() - 1800000) },
|
||||
{ id: 2, name: '编程助手', members: ['Claude', 'DeepSeek'], lastMessage: '这段代码有问题吗?', timestamp: new Date(Date.now() - 3600000) },
|
||||
{ id: 3, name: '创意头脑风暴', members: ['GPT-4', 'Claude', 'Kimi'], lastMessage: '有个新想法...', timestamp: new Date(Date.now() - 7200000) },
|
||||
])
|
||||
|
||||
// 智能体选择弹窗
|
||||
const showAgentSelector = ref(false)
|
||||
const selectMode = ref<'single' | 'group'>('single')
|
||||
const selectedAgents = ref<Agent[]>([])
|
||||
const groupChatName = ref('')
|
||||
|
||||
// 输入相关
|
||||
const inputMessage = ref('')
|
||||
const isLoading = ref(false)
|
||||
|
||||
// 侧边栏
|
||||
const sidebarCollapsed = ref(false)
|
||||
|
||||
// 获取模型列表
|
||||
const fetchModels = async () => {
|
||||
modelsLoading.value = true
|
||||
try {
|
||||
const response = await fetch(`/model/list`)
|
||||
const data = await response.json()
|
||||
if (data.list) {
|
||||
chatModels.value = data.list.filter((m: ChatModel) => m.model_type === 'chat' && m.status === 'active')
|
||||
if (chatModels.value.length > 0 && !selectedModel.value) {
|
||||
selectedModel.value = chatModels.value[0]
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch models:', error)
|
||||
} finally {
|
||||
modelsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开智能体选择器
|
||||
const openAgentSelector = (mode: 'single' | 'group') => {
|
||||
selectMode.value = mode
|
||||
selectedAgents.value = []
|
||||
groupChatName.value = ''
|
||||
showAgentSelector.value = true
|
||||
}
|
||||
|
||||
// 切换智能体选择
|
||||
const toggleAgentSelection = (agent: Agent) => {
|
||||
const index = selectedAgents.value.findIndex(a => a.id === agent.id)
|
||||
if (index > -1) {
|
||||
selectedAgents.value.splice(index, 1)
|
||||
} else {
|
||||
selectedAgents.value.push(agent)
|
||||
}
|
||||
}
|
||||
|
||||
// 确认选择
|
||||
const confirmAgentSelection = () => {
|
||||
if (selectMode.value === 'single') {
|
||||
if (selectedAgents.value.length > 0) {
|
||||
selectedAgent.value = selectedAgents.value[0]
|
||||
messages.value = [
|
||||
{ id: 1, role: 'assistant', content: `你好!我是 ${selectedAgent.value.name},你的 AI 助手。有什么我可以帮助你的吗?`, timestamp: new Date() }
|
||||
]
|
||||
}
|
||||
} else {
|
||||
const name = groupChatName.value.trim() || `群聊 (${selectedAgents.value.length}人)`
|
||||
groupChats.value.unshift({
|
||||
id: Date.now(),
|
||||
name: name,
|
||||
members: selectedAgents.value.map(a => a.name),
|
||||
lastMessage: 'New group created',
|
||||
timestamp: new Date()
|
||||
})
|
||||
}
|
||||
showAgentSelector.value = false
|
||||
}
|
||||
|
||||
// 取消选择
|
||||
const cancelAgentSelection = () => {
|
||||
showAgentSelector.value = false
|
||||
}
|
||||
|
||||
// 选择助手
|
||||
const selectAgent = (agent: Agent) => {
|
||||
selectedAgent.value = agent
|
||||
messages.value = [
|
||||
{ id: 1, role: 'assistant', content: `你好!我是 ${agent.name}。有什么我可以帮助你的吗?`, timestamp: new Date() }
|
||||
]
|
||||
}
|
||||
|
||||
// 选择历史对话
|
||||
const selectSession = (session: ChatSession) => {
|
||||
const agent = chatAgents.value.find(a => a.id === session.agentId)
|
||||
if (agent) {
|
||||
selectedAgent.value = agent
|
||||
}
|
||||
messages.value = [
|
||||
{ id: 1, role: 'assistant', content: `已加载会话:${session.title}`, timestamp: new Date() }
|
||||
]
|
||||
}
|
||||
|
||||
// 新建聊天
|
||||
const newChat = () => {
|
||||
messages.value = [
|
||||
{ id: 1, role: 'assistant', content: `你好!我是 ${selectedAgent.value?.name || 'Claude'}。有什么我可以帮助你的吗?`, timestamp: new Date() }
|
||||
]
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
// 格式化相对时间
|
||||
const formatRelativeTime = (date: Date) => {
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const days = Math.floor(diff / 86400000)
|
||||
|
||||
if (hours < 1) return '刚刚'
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
if (days < 7) return `${days}天前`
|
||||
return date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
// 切换侧边栏
|
||||
const toggleSidebar = () => {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
// 点击外部关闭下拉框
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('.model-dropdown')) {
|
||||
showModelDropdown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchModels()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
return {
|
||||
// 模型
|
||||
chatModels,
|
||||
selectedModel,
|
||||
modelsLoading,
|
||||
showModelDropdown,
|
||||
fetchModels,
|
||||
// 助手
|
||||
chatAgents,
|
||||
selectedAgent,
|
||||
selectAgent,
|
||||
// 消息
|
||||
messages,
|
||||
newChat,
|
||||
// 历史对话
|
||||
chatSessions,
|
||||
selectSession,
|
||||
// 群聊
|
||||
groupChats,
|
||||
// 智能体选择
|
||||
showAgentSelector,
|
||||
selectMode,
|
||||
selectedAgents,
|
||||
groupChatName,
|
||||
openAgentSelector,
|
||||
toggleAgentSelection,
|
||||
confirmAgentSelection,
|
||||
cancelAgentSelection,
|
||||
// 输入
|
||||
inputMessage,
|
||||
isLoading,
|
||||
// 侧边栏
|
||||
sidebarCollapsed,
|
||||
toggleSidebar,
|
||||
// 工具
|
||||
formatTime,
|
||||
formatRelativeTime,
|
||||
getModelIcon,
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ const {
|
||||
stats,
|
||||
isAllSelected,
|
||||
isIndeterminate,
|
||||
isAllSelectedEdit,
|
||||
isIndeterminateEdit,
|
||||
fetchAgents,
|
||||
fetchSkills,
|
||||
fetchModels,
|
||||
@@ -46,6 +48,8 @@ const {
|
||||
handleSkillsModeClickEdit,
|
||||
toggleSelectAll,
|
||||
clearSkills,
|
||||
toggleSelectAllEdit,
|
||||
clearSkillsEdit,
|
||||
toggleSkillsMode,
|
||||
selectSkillsMode,
|
||||
toggleSubSkillsDropdown,
|
||||
@@ -62,9 +66,21 @@ const {
|
||||
<div class="p-6 min-h-screen">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fa-solid fa-robot text-orange-500"></i>
|
||||
<span class="font-medium">Agents</span>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fa-solid fa-robot text-orange-500 text-xl"></i>
|
||||
<span class="text-xl font-semibold text-white">Agents</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="text-gray-400">Total:</span>
|
||||
<span class="text-white font-medium">{{ stats.total }}</span>
|
||||
<span class="w-1 h-1 rounded-full bg-gray-500"></span>
|
||||
<span class="text-gray-400">Active:</span>
|
||||
<span class="text-green-400 font-medium">{{ stats.active }}</span>
|
||||
<span class="w-1 h-1 rounded-full bg-gray-500"></span>
|
||||
<span class="text-gray-400">Inactive:</span>
|
||||
<span class="text-gray-400 font-medium">{{ stats.inactive }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="openCreateModal" class="btn-primary">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
@@ -75,7 +91,7 @@ const {
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="flex gap-4 mb-6">
|
||||
<div class="flex-1 relative">
|
||||
<i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
|
||||
<i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"></i>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
@@ -92,7 +108,14 @@ const {
|
||||
|
||||
<!-- Agents 列表 -->
|
||||
<div class="bg-dark-700 rounded-xl overflow-hidden">
|
||||
<table v-if="filteredAgents.length > 0" class="w-full">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-20">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<i class="fa-solid fa-circle-notch fa-spin text-3xl text-orange-500"></i>
|
||||
<span class="text-gray-400">Loading agents...</span>
|
||||
</div>
|
||||
</div>
|
||||
<table v-else-if="filteredAgents.length > 0" class="w-full">
|
||||
<thead class="bg-dark-600">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-gray-400">Agent Name</th>
|
||||
@@ -126,8 +149,8 @@ const {
|
||||
<span class="bg-dark-500 px-2 py-1 rounded text-sm text-gray-300">{{ agent.model }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs" :class="agent.status === 'active' ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="agent.status === 'active' ? 'bg-green-500' : 'bg-gray-400'"></span>
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs transition-all duration-200" :class="agent.status === 'active' ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'">
|
||||
<span class="w-1.5 h-1.5 rounded-full animate-pulse" :class="agent.status === 'active' ? 'bg-green-500' : 'bg-gray-400'"></span>
|
||||
<span class="capitalize">{{ agent.status }}</span>
|
||||
</span>
|
||||
</td>
|
||||
@@ -139,18 +162,18 @@ const {
|
||||
class="btn-icon"
|
||||
:title="agent.status === 'active' ? 'Deactivate' : 'Activate'"
|
||||
>
|
||||
<Pause v-if="agent.status === 'active'" class="w-4 h-4 text-gray-400 hover:text-yellow-400" />
|
||||
<Play v-else class="w-4 h-4 text-gray-400 hover:text-green-400" />
|
||||
<Pause v-if="agent.status === 'active'" class="w-4 h-4 text-gray-500 hover:text-yellow-400 transition-colors" />
|
||||
<Play v-else class="w-4 h-4 text-gray-500 hover:text-green-400 transition-colors" />
|
||||
</button>
|
||||
<button @click="openEdit(agent)" class="btn-icon" title="Edit">
|
||||
<Edit class="w-4 h-4 text-gray-400 hover:text-white" />
|
||||
<Edit class="w-4 h-4 text-gray-500 hover:text-white transition-colors" />
|
||||
</button>
|
||||
<button
|
||||
@click="deleteAgent(agent.id)"
|
||||
@click.stop="deleteAgent(agent.id)"
|
||||
class="btn-icon"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 class="w-4 h-4 text-gray-400 hover:text-red-400" />
|
||||
<Trash2 class="w-4 h-4 text-gray-500 hover:text-red-400 transition-colors" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -159,19 +182,19 @@ const {
|
||||
</table>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="filteredAgents.length === 0" class="empty-box">
|
||||
<div v-if="filteredAgents.length === 0 && !isLoading" class="empty-box">
|
||||
<div class="empty-icon">
|
||||
<i class="fa-solid fa-robot"></i>
|
||||
</div>
|
||||
<p class="empty-text">No agents found</p>
|
||||
<p class="empty-tip">Click "New Agent" to create one</p>
|
||||
<p class="empty-text">{{ searchQuery || filterStatus !== 'all' ? 'No matching agents found' : 'No agents found' }}</p>
|
||||
<p class="empty-tip">{{ searchQuery || filterStatus !== 'all' ? 'Try adjusting your search or filter' : 'Click "New Agent" to create one' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建智能体弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50" @click.self="showCreateModal = false">
|
||||
<div class="bg-dark-700 rounded-2xl w-full max-w-lg border border-dark-500 shadow-2xl">
|
||||
<div class="flex items-center justify-between p-5 border-b border-dark-500">
|
||||
<h3 class="text-lg font-semibold">Create New Agent</h3>
|
||||
@@ -409,7 +432,7 @@ const {
|
||||
|
||||
<!-- 编辑智能体弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showEditModal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div v-if="showEditModal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50" @click.self="showEditModal = false">
|
||||
<div class="bg-dark-700 rounded-2xl w-full max-w-lg border border-dark-500 shadow-2xl">
|
||||
<div class="flex items-center justify-between p-5 border-b border-dark-500">
|
||||
<h3 class="text-lg font-semibold">Edit Agent</h3>
|
||||
@@ -548,10 +571,10 @@ const {
|
||||
</div>
|
||||
<div class="action-bar">
|
||||
<label class="checkbox-label cursor-pointer">
|
||||
<input type="checkbox" :checked="isAllSelected" :indeterminate="isIndeterminate" @change="toggleSelectAll" class="checkbox">
|
||||
<input type="checkbox" :checked="isAllSelectedEdit" :indeterminate="isIndeterminateEdit" @change="toggleSelectAllEdit" class="checkbox">
|
||||
<span class="checkbox-text">Select All</span>
|
||||
</label>
|
||||
<button v-if="editingAgent.selectedSkills.length > 0" type="button" @click="clearSkills" class="clear-btn">Clear</button>
|
||||
<button v-if="editingAgent.selectedSkills.length > 0" type="button" @click="clearSkillsEdit" class="clear-btn">Clear</button>
|
||||
</div>
|
||||
<div class="options-list">
|
||||
<template v-if="filteredSkills.length > 0">
|
||||
|
||||
@@ -122,7 +122,7 @@ const sendMessage = async () => {
|
||||
|
||||
try {
|
||||
const requestBody: any = {
|
||||
agent_id: selectedAgent.value?.id || 1,
|
||||
agent_id: String(selectedAgent.value?.id || 1),
|
||||
message: userContent,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,109 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Play, Pause, Edit, Trash2 } from 'lucide-vue-next'
|
||||
import { useModelAPIs } from './modelapis/useModelAPIs'
|
||||
|
||||
interface ModelAPI {
|
||||
id: number
|
||||
name: string
|
||||
provider: string
|
||||
status: 'active' | 'inactive' | 'error'
|
||||
model: string
|
||||
requests: number
|
||||
latency: string
|
||||
createdAt: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const modelAPIs = ref<ModelAPI[]>([
|
||||
{ id: 1, name: 'OpenAI Primary', provider: 'OpenAI', status: 'active', model: 'gpt-4o', requests: 1250, latency: '45ms', createdAt: '2025-04-10', description: 'Primary OpenAI API endpoint' },
|
||||
{ id: 2, name: 'OpenAI Backup', provider: 'OpenAI', status: 'inactive', model: 'gpt-4o-mini', requests: 0, latency: '0ms', createdAt: '2025-04-08', description: 'Backup OpenAI API endpoint' },
|
||||
{ id: 3, name: 'Google Gemini', provider: 'Google', status: 'active', model: 'gemini-2.0-flash', requests: 890, latency: '32ms', createdAt: '2025-04-05', description: 'Google Gemini API integration' },
|
||||
{ id: 4, name: 'Cerebras Fast', provider: 'Cerebras', status: 'active', model: 'cerebras-sandbox', requests: 2100, latency: '12ms', createdAt: '2025-04-12', description: 'Cerebras high-speed inference' },
|
||||
{ id: 5, name: 'Anthropic Claude', provider: 'Anthropic', status: 'error', model: 'claude-3-5-sonnet', requests: 450, latency: '0ms', createdAt: '2025-04-11', description: 'Anthropic Claude API' },
|
||||
{ id: 6, name: 'Azure OpenAI', provider: 'Microsoft', status: 'active', model: 'gpt-4', requests: 680, latency: '55ms', createdAt: '2025-04-09', description: 'Azure-hosted OpenAI models' },
|
||||
])
|
||||
|
||||
const editingModel = ref<ModelAPI | null>(null)
|
||||
const isEditing = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const filterStatus = ref<string>('all')
|
||||
|
||||
const editForm = ref({
|
||||
name: '',
|
||||
provider: '',
|
||||
model: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const openEdit = (model: ModelAPI) => {
|
||||
editingModel.value = model
|
||||
editForm.value = {
|
||||
name: model.name,
|
||||
provider: model.provider,
|
||||
model: model.model,
|
||||
description: model.description,
|
||||
}
|
||||
isEditing.value = true
|
||||
}
|
||||
|
||||
const saveEdit = () => {
|
||||
if (editingModel.value) {
|
||||
const index = modelAPIs.value.findIndex(m => m.id === editingModel.value!.id)
|
||||
if (index !== -1) {
|
||||
modelAPIs.value[index] = {
|
||||
...modelAPIs.value[index],
|
||||
...editForm.value,
|
||||
}
|
||||
}
|
||||
}
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
isEditing.value = false
|
||||
editingModel.value = null
|
||||
}
|
||||
|
||||
const toggleStatus = (model: ModelAPI) => {
|
||||
if (model.status === 'active') {
|
||||
model.status = 'inactive'
|
||||
} else if (model.status === 'inactive') {
|
||||
model.status = 'active'
|
||||
}
|
||||
}
|
||||
|
||||
const deleteModel = (id: number) => {
|
||||
modelAPIs.value = modelAPIs.value.filter(m => m.id !== id)
|
||||
}
|
||||
|
||||
const filteredModels = () => {
|
||||
return modelAPIs.value.filter(model => {
|
||||
const matchSearch = model.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
model.provider.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
model.model.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
const matchStatus = filterStatus.value === 'all' || model.status === filterStatus.value
|
||||
return matchSearch && matchStatus
|
||||
})
|
||||
}
|
||||
|
||||
const statusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return 'bg-primary-success'
|
||||
case 'inactive': return 'bg-gray-500'
|
||||
case 'error': return 'bg-primary-danger'
|
||||
default: return 'bg-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
const providerIcon = (provider: string) => {
|
||||
switch (provider) {
|
||||
case 'OpenAI': return 'fa-openai'
|
||||
case 'Google': return 'fa-google'
|
||||
case 'Cerebras': return 'fa-microchip'
|
||||
case 'Anthropic': return 'fa-robot'
|
||||
case 'Microsoft': return 'fa-microsoft'
|
||||
default: return 'fa-cube'
|
||||
}
|
||||
}
|
||||
const {
|
||||
modelAPIs,
|
||||
isLoading,
|
||||
editingModel,
|
||||
isEditing,
|
||||
searchQuery,
|
||||
filterStatus,
|
||||
editForm,
|
||||
openEdit,
|
||||
saveEdit,
|
||||
cancelEdit,
|
||||
toggleStatus,
|
||||
deleteModel,
|
||||
filteredModels,
|
||||
statusClass,
|
||||
providerIcon,
|
||||
} = useModelAPIs()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -113,7 +28,7 @@ const providerIcon = (provider: string) => {
|
||||
<i class="fa-solid fa-cube text-orange-500"></i>
|
||||
<span class="font-medium">Model APIs</span>
|
||||
</div>
|
||||
<button class="bg-gradient-to-r from-primary-orange to-red-500 hover:from-orange-500 hover:to-red-600 text-white px-4 py-2 rounded-lg font-medium flex items-center gap-2 transition-all">
|
||||
<button class="btn-primary">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
New Model API
|
||||
</button>
|
||||
@@ -126,7 +41,7 @@ const providerIcon = (provider: string) => {
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search model APIs..."
|
||||
class="w-full bg-dark-600 border border-dark-500 rounded-lg py-2 pl-10 pr-4 text-white placeholder-gray-500 focus:outline-none focus:border-primary-orange"
|
||||
class="search-input w-full"
|
||||
>
|
||||
</div>
|
||||
<el-select v-model="filterStatus" placeholder="Select" class="w-40" size="large">
|
||||
@@ -138,16 +53,23 @@ const providerIcon = (provider: string) => {
|
||||
</div>
|
||||
|
||||
<div class="bg-dark-700 rounded-xl overflow-hidden">
|
||||
<table class="w-full">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-20">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<i class="fa-solid fa-circle-notch fa-spin text-3xl text-orange-500"></i>
|
||||
<span class="text-gray-400">Loading model APIs...</span>
|
||||
</div>
|
||||
</div>
|
||||
<table v-else-if="filteredModels().length > 0" class="w-full">
|
||||
<thead class="bg-dark-600">
|
||||
<tr>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">API Name</th>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">Provider</th>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">Model</th>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">Requests</th>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">Latency</th>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">Status</th>
|
||||
<th class="text-right px-5 py-3 text-sm font-medium text-gray-400">Actions</th>
|
||||
<th class="text-center px-5 py-3 text-sm font-medium text-gray-400">Provider</th>
|
||||
<th class="text-center px-5 py-3 text-sm font-medium text-gray-400">Model</th>
|
||||
<th class="text-center px-5 py-3 text-sm font-medium text-gray-400">Requests</th>
|
||||
<th class="text-center px-5 py-3 text-sm font-medium text-gray-400">Latency</th>
|
||||
<th class="text-center px-5 py-3 text-sm font-medium text-gray-400">Status</th>
|
||||
<th class="text-center px-5 py-3 text-sm font-medium text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -156,47 +78,44 @@ const providerIcon = (provider: string) => {
|
||||
<div class="font-medium">{{ model.name }}</div>
|
||||
<div class="text-sm text-gray-500">{{ model.description }}</div>
|
||||
</td>
|
||||
<td class="px-5 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<td class="px-5 py-4 text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<i :class="['fa-brands', providerIcon(model.provider), 'text-lg']"></i>
|
||||
<span>{{ model.provider }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-5 py-4">
|
||||
<td class="px-5 py-4 text-center">
|
||||
<span class="bg-dark-500 px-2 py-1 rounded text-sm">{{ model.model }}</span>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-gray-300">{{ model.requests.toLocaleString() }}</td>
|
||||
<td class="px-5 py-4">
|
||||
<td class="px-5 py-4 text-center text-gray-300">{{ model.requests.toLocaleString() }}</td>
|
||||
<td class="px-5 py-4 text-center">
|
||||
<span :class="model.latency === '0ms' ? 'text-gray-500' : 'text-primary-cyan'">{{ model.latency }}</span>
|
||||
</td>
|
||||
<td class="px-5 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="statusClass(model.status)"></span>
|
||||
<span class="capitalize text-sm">{{ model.status }}</span>
|
||||
</div>
|
||||
<td class="px-5 py-4 text-center">
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs" :class="model.status === 'active' ? 'bg-green-500/20 text-green-400' : model.status === 'error' ? 'bg-red-500/20 text-red-400' : 'bg-gray-500/20 text-gray-400'">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="model.status === 'active' ? 'bg-green-500' : model.status === 'error' ? 'bg-red-500' : 'bg-gray-400'"></span>
|
||||
<span class="capitalize">{{ model.status }}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-4">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<button
|
||||
@click="toggleStatus(model)"
|
||||
class="p-2 rounded-lg hover:bg-dark-500 transition-colors"
|
||||
class="btn-icon"
|
||||
:title="model.status === 'active' ? 'Deactivate' : 'Activate'"
|
||||
>
|
||||
<i :class="['fa-solid', model.status === 'active' ? 'fa-pause' : 'fa-play', 'text-gray-400 hover:text-white']"></i>
|
||||
<Pause v-if="model.status === 'active'" class="w-4 h-4 text-gray-500 hover:text-yellow-400 transition-colors" />
|
||||
<Play v-else class="w-4 h-4 text-gray-500 hover:text-green-400 transition-colors" />
|
||||
</button>
|
||||
<button
|
||||
@click="openEdit(model)"
|
||||
class="p-2 rounded-lg hover:bg-dark-500 transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<i class="fa-solid fa-pen text-gray-400 hover:text-white"></i>
|
||||
<button @click="openEdit(model)" class="btn-icon" title="Edit">
|
||||
<Edit class="w-4 h-4 text-gray-500 hover:text-white transition-colors" />
|
||||
</button>
|
||||
<button
|
||||
@click="deleteModel(model.id)"
|
||||
class="p-2 rounded-lg hover:bg-dark-500 transition-colors"
|
||||
class="btn-icon"
|
||||
title="Delete"
|
||||
>
|
||||
<i class="fa-solid fa-trash text-gray-400 hover:text-primary-danger"></i>
|
||||
<Trash2 class="w-4 h-4 text-gray-500 hover:text-red-400 transition-colors" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -204,9 +123,13 @@ const providerIcon = (provider: string) => {
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="filteredModels().length === 0" class="py-12 text-center text-gray-500">
|
||||
<i class="fa-solid fa-cube text-4xl mb-3"></i>
|
||||
<p>No model APIs found</p>
|
||||
<!-- 空状态 -->
|
||||
<div v-if="filteredModels().length === 0 && !isLoading" class="empty-box">
|
||||
<div class="empty-icon">
|
||||
<i class="fa-solid fa-cube"></i>
|
||||
</div>
|
||||
<p class="empty-text">{{ searchQuery || filterStatus !== 'all' ? 'No matching model APIs found' : 'No model APIs found' }}</p>
|
||||
<p class="empty-tip">{{ searchQuery || filterStatus !== 'all' ? 'Try adjusting your search or filter' : 'Click "New Model API" to create one' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -282,3 +205,43 @@ const providerIcon = (provider: string) => {
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 按钮样式 */
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #f97316 0%, #ef4444 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #fb923c 0%, #f97316 100%);
|
||||
}
|
||||
|
||||
/* 搜索框 */
|
||||
.search-input {
|
||||
background-color: #171922;
|
||||
border: 1px solid #374151;
|
||||
color: white;
|
||||
padding: 10px 12px 10px 36px;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #f97316;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Play, Pause, Edit, Trash2, Plus, Search, Clock } from 'lucide-vue-next'
|
||||
|
||||
// Mock scheduled tasks data
|
||||
const tasks = ref([
|
||||
@@ -64,14 +65,6 @@ const getTaskCount = (status: string) => {
|
||||
if (status === 'stopped') return tasks.value.filter(t => t.status === 'stopped').length
|
||||
return tasks.value.length
|
||||
}
|
||||
|
||||
const getStatusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running': return 'bg-green-500'
|
||||
case 'stopped': return 'bg-gray-500'
|
||||
default: return 'bg-gray-500'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -79,11 +72,11 @@ const getStatusClass = (status: string) => {
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fa-solid fa-clock text-orange-500"></i>
|
||||
<span class="font-medium text-white">Scheduled Tasks</span>
|
||||
<Clock class="w-5 h-5 text-orange-500" />
|
||||
<span class="font-medium">Scheduled Tasks</span>
|
||||
</div>
|
||||
<button class="btn-primary">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
<button class="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 text-white text-sm font-medium hover:from-orange-400 hover:to-amber-400 transition-all">
|
||||
<Plus class="w-4 h-4" />
|
||||
New Task
|
||||
</button>
|
||||
</div>
|
||||
@@ -91,7 +84,7 @@ const getStatusClass = (status: string) => {
|
||||
<!-- Search -->
|
||||
<div class="flex gap-4 mb-6">
|
||||
<div class="flex-1 relative">
|
||||
<i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
@@ -107,15 +100,18 @@ const getStatusClass = (status: string) => {
|
||||
</div>
|
||||
|
||||
<!-- Task List Table -->
|
||||
<div class="bg-dark-700 rounded-xl overflow-hidden">
|
||||
<div v-if="filteredTasks.length === 0" class="py-12 text-center text-gray-400">
|
||||
<i class="fa-solid fa-clock text-2xl mb-2"></i>
|
||||
<p>No tasks found</p>
|
||||
<div class="bg-dark-700 rounded-xl overflow-hidden shadow-lg">
|
||||
<div v-if="filteredTasks.length === 0" class="empty-box">
|
||||
<div class="empty-icon">
|
||||
<Clock class="w-12 h-12" />
|
||||
</div>
|
||||
<p class="empty-text">No tasks found</p>
|
||||
<p class="empty-tip">Click "New Task" to add a task</p>
|
||||
</div>
|
||||
<table v-else class="w-full">
|
||||
<thead class="bg-dark-600">
|
||||
<tr>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">Task Name</th>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400 w-1/3">Task Name</th>
|
||||
<th class="text-center px-5 py-3 text-sm font-medium text-gray-400">Status</th>
|
||||
<th class="text-center px-5 py-3 text-sm font-medium text-gray-400">Trigger</th>
|
||||
<th class="text-center px-5 py-3 text-sm font-medium text-gray-400">Next Run</th>
|
||||
@@ -126,20 +122,23 @@ const getStatusClass = (status: string) => {
|
||||
<tbody>
|
||||
<tr v-for="task in filteredTasks" :key="task.id" class="table-row">
|
||||
<td class="px-5 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="['w-2 h-2 rounded-full', getStatusClass(task.status)]"></span>
|
||||
<div>
|
||||
<div class="font-medium text-white">{{ task.name }}</div>
|
||||
<div class="text-sm text-gray-500">{{ task.description }}</div>
|
||||
<div class="flex gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center flex-shrink-0 self-center">
|
||||
<Clock class="w-5 h-5 text-orange-400" />
|
||||
</div>
|
||||
<div class="min-w-0 self-center">
|
||||
<div class="font-medium">{{ task.name }}</div>
|
||||
<div class="text-sm text-gray-500 line-clamp-2 mt-0.5">{{ task.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex gap-2 mt-2 ml-13">
|
||||
<span v-for="tag in task.tags" :key="tag" class="task-tag">{{ tag }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-center">
|
||||
<span :class="['status-badge', getStatusClass(task.status)]">
|
||||
{{ task.status === 'running' ? 'Running' : 'Stopped' }}
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs" :class="task.status === 'running' ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="task.status === 'running' ? 'bg-green-500' : 'bg-gray-400'"></span>
|
||||
<span class="capitalize">{{ task.status === 'running' ? 'Running' : 'Stopped' }}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-center text-gray-400 text-sm">
|
||||
@@ -154,13 +153,14 @@ const getStatusClass = (status: string) => {
|
||||
<td class="px-5 py-4">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<button class="btn-icon" title="Pause">
|
||||
<i class="fa-solid fa-pause text-gray-400 hover:text-white"></i>
|
||||
<Pause v-if="task.status === 'running'" class="w-4 h-4 text-gray-400 hover:text-yellow-400" />
|
||||
<Play v-else class="w-4 h-4 text-gray-400 hover:text-green-400" />
|
||||
</button>
|
||||
<button class="btn-icon" title="Edit">
|
||||
<i class="fa-solid fa-pen text-gray-400 hover:text-white"></i>
|
||||
<Edit class="w-4 h-4 text-gray-400 hover:text-white" />
|
||||
</button>
|
||||
<button class="btn-icon" title="Delete">
|
||||
<i class="fa-solid fa-trash text-gray-400 hover:text-red-500"></i>
|
||||
<Trash2 class="w-4 h-4 text-gray-400 hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -176,25 +176,6 @@ const getStatusClass = (status: string) => {
|
||||
background-color: #0f1419;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #f97316, #ef4444);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background-color: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
@@ -214,26 +195,6 @@ const getStatusClass = (status: string) => {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
padding: 8px 4px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
color: #f97316;
|
||||
border-bottom-color: #f97316;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
border-top: 1px solid #2a2a3a;
|
||||
transition: background-color 0.2s;
|
||||
@@ -251,11 +212,8 @@ const getStatusClass = (status: string) => {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
.ml-13 {
|
||||
margin-left: 3.25rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
@@ -270,4 +228,41 @@ const getStatusClass = (status: string) => {
|
||||
.btn-icon:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 空状态样式 */
|
||||
.empty-box {
|
||||
min-height: 340px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1f2937, #111827);
|
||||
border-radius: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-icon i {
|
||||
font-size: 40px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #d1d5db;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { formatDate } from '@/utils/format'
|
||||
import { Play, Pause, Edit, Trash2, FileCode, Plus, Search, X } from 'lucide-vue-next'
|
||||
import '@/views/database/database.css'
|
||||
import * as monaco from 'monaco-editor'
|
||||
|
||||
interface Script {
|
||||
@@ -128,15 +130,6 @@ const filteredScripts = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 状态样式
|
||||
const statusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running': return 'bg-primary-success'
|
||||
case 'stopped': return 'bg-gray-500'
|
||||
default: return 'bg-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
// 切换状态
|
||||
const toggleStatus = (script: Script) => {
|
||||
script.status = script.status === 'running' ? 'stopped' : 'running'
|
||||
@@ -248,51 +241,51 @@ const saveNewScript = () => {
|
||||
<table class="w-full">
|
||||
<thead class="bg-dark-600">
|
||||
<tr>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">Script Name</th>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">Type</th>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">Status</th>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">Created</th>
|
||||
<th class="text-right px-5 py-3 text-sm font-medium text-gray-400">Actions</th>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400 w-1/3">Script Name</th>
|
||||
<th class="text-center px-5 py-3 text-sm font-medium text-gray-400">Type</th>
|
||||
<th class="text-center px-5 py-3 text-sm font-medium text-gray-400">Status</th>
|
||||
<th class="text-center px-5 py-3 text-sm font-medium text-gray-400">Created</th>
|
||||
<th class="text-center px-5 py-3 text-sm font-medium text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="script in filteredScripts" :key="script.id" class="border-t border-dark-600 hover:bg-dark-600/50 transition-colors">
|
||||
<tr v-for="script in filteredScripts" :key="script.id" class="table-row">
|
||||
<td class="px-5 py-4">
|
||||
<div class="font-medium">{{ script.name }}</div>
|
||||
<div class="text-sm text-gray-500">{{ script.description }}</div>
|
||||
</td>
|
||||
<td class="px-5 py-4">
|
||||
<span class="bg-dark-500 px-2 py-1 rounded text-sm">{{ script.type }}</span>
|
||||
</td>
|
||||
<td class="px-5 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="statusClass(script.status)"></span>
|
||||
<span class="capitalize text-sm">{{ script.status }}</span>
|
||||
<div class="flex gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center flex-shrink-0 self-center">
|
||||
<FileCode class="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div class="min-w-0 self-center">
|
||||
<div class="font-medium">{{ script.name }}</div>
|
||||
<div class="text-sm text-gray-500 line-clamp-2 mt-0.5">{{ script.description || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-gray-400 text-sm">{{ formatDate(script.createdAt, 'YYYY/MM/DD HH:mm') }}</td>
|
||||
<td class="px-5 py-4 text-center">
|
||||
<span class="bg-dark-500 px-2 py-1 rounded text-sm">{{ script.type }}</span>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-center">
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs" :class="script.status === 'running' ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="script.status === 'running' ? 'bg-green-500' : 'bg-gray-400'"></span>
|
||||
<span class="capitalize">{{ script.status }}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-center text-gray-400 text-sm">{{ formatDate(script.createdAt, 'YYYY/MM/DD HH:mm') }}</td>
|
||||
<td class="px-5 py-4">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<button
|
||||
@click="toggleStatus(script)"
|
||||
class="p-2 rounded-lg hover:bg-dark-500 transition-colors"
|
||||
class="btn-icon"
|
||||
:title="script.status === 'running' ? 'Stop' : 'Start'"
|
||||
>
|
||||
<i :class="['fa-solid', script.status === 'running' ? 'fa-stop' : 'fa-play', 'text-gray-400 hover:text-white']"></i>
|
||||
<Pause v-if="script.status === 'running'" class="w-4 h-4 text-gray-400 hover:text-yellow-400" />
|
||||
<Play v-else class="w-4 h-4 text-gray-400 hover:text-green-400" />
|
||||
</button>
|
||||
<button
|
||||
@click="openEdit(script)"
|
||||
class="p-2 rounded-lg hover:bg-dark-500 transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<i class="fa-solid fa-pen text-gray-400 hover:text-white"></i>
|
||||
<button @click="openEdit(script)" class="btn-icon" title="Edit">
|
||||
<Edit class="w-4 h-4 text-gray-400 hover:text-white" />
|
||||
</button>
|
||||
<button
|
||||
@click="deleteScript(script.id)"
|
||||
class="p-2 rounded-lg hover:bg-dark-500 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<i class="fa-solid fa-trash text-gray-400 hover:text-red-400"></i>
|
||||
<button @click="deleteScript(script.id)" class="btn-icon" title="Delete">
|
||||
<Trash2 class="w-4 h-4 text-gray-400 hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -301,9 +294,12 @@ const saveNewScript = () => {
|
||||
</table>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="filteredScripts.length === 0" class="py-12 text-center text-gray-500">
|
||||
<i class="fa-solid fa-code text-4xl mb-3"></i>
|
||||
<p>No scripts found</p>
|
||||
<div v-if="filteredScripts.length === 0" class="empty-box">
|
||||
<div class="empty-icon">
|
||||
<FileCode class="w-12 h-12" />
|
||||
</div>
|
||||
<p class="empty-text">No scripts found</p>
|
||||
<p class="empty-tip">Click "New Script" to add a script</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
import { onMounted } from 'vue'
|
||||
import { useModelSettings } from './settings/useModelSettings'
|
||||
import FormDialog from '@/components/FormDialog.vue'
|
||||
import { Search, Close } from '@element-plus/icons-vue'
|
||||
|
||||
// 导入 Model Settings 逻辑
|
||||
const {
|
||||
models,
|
||||
modelsLoading,
|
||||
searchQuery,
|
||||
filteredModels,
|
||||
modelTypeOptions,
|
||||
providerOptions,
|
||||
showAddModelForm,
|
||||
@@ -36,79 +39,105 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="p-6 min-h-screen">
|
||||
<!-- 页面标题 -->
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
<i class="fa-solid fa-brain text-orange-500"></i>
|
||||
<span class="font-medium">Models</span>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="space-y-4">
|
||||
<!-- Models 内容 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400 mt-1">Configure AI models</p>
|
||||
</div>
|
||||
<button class="bg-gradient-to-r from-primary-orange to-red-500 hover:from-orange-500 hover:to-red-600 text-white px-4 py-2 rounded-lg font-medium flex items-center gap-2 transition-all" @click="showAddModelForm = true">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
Add New Model
|
||||
</button>
|
||||
<!-- 顶部导航 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<!-- 页面标题 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fa-solid fa-brain text-orange-500 text-xl"></i>
|
||||
<span class="text-xl font-semibold text-white">Models</span>
|
||||
</div>
|
||||
|
||||
<!-- 模型列表 -->
|
||||
<!-- 新建按钮 -->
|
||||
<button class="bg-gradient-to-r from-primary-orange to-red-500 hover:from-orange-500 hover:to-red-600 text-white px-4 py-2 rounded-lg font-medium flex items-center gap-2 transition-all" @click="showAddModelForm = true">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
Add New Model
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="flex gap-4 mb-6">
|
||||
<div class="flex-1 relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search models by name, provider or model..."
|
||||
class="search-input w-full pl-10 pr-10"
|
||||
>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
@click="searchQuery = ''"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<Close class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型列表 -->
|
||||
<div>
|
||||
<div v-if="modelsLoading" class="py-12 text-center text-gray-500">
|
||||
<i class="fa-solid fa-spinner fa-spin text-2xl"></i>
|
||||
</div>
|
||||
<div v-else class="bg-dark-700 rounded-xl overflow-hidden">
|
||||
<div v-else-if="filteredModels.length > 0" class="bg-dark-700 rounded-xl overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead class="bg-dark-600">
|
||||
<tr>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">Model Name</th>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">Provider</th>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">Model</th>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">Model Type</th>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">Base URL</th>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">Status</th>
|
||||
<th class="text-right px-5 py-3 text-sm font-medium text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="model in models" :key="model.id" class="border-t border-dark-600 hover:bg-dark-600/50">
|
||||
<td class="px-5 py-4">
|
||||
<span class="font-medium">{{ model.name }}</span>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-sm text-gray-300">
|
||||
{{ model.provider }}
|
||||
</td>
|
||||
<td class="px-5 py-4 text-sm text-gray-300">
|
||||
{{ model.model }}
|
||||
</td>
|
||||
<td class="px-5 py-4">
|
||||
<span class="px-2 py-1 rounded text-xs" :class="model.model_type === 'chat' ? 'bg-primary-cyan/20 text-primary-cyan' : 'bg-purple-500/20 text-purple-400'">{{ model.model_type }}</span>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-sm text-gray-300">
|
||||
{{ model.base_url }}
|
||||
</td>
|
||||
<td class="px-5 py-4">
|
||||
<span v-if="model.status === 'active'" class="px-2 py-1 rounded text-xs bg-primary-success/20 text-primary-success">Active</span>
|
||||
<span v-else class="px-2 py-1 rounded text-xs bg-gray-500/20 text-gray-400">Inactive</span>
|
||||
</td>
|
||||
<td class="px-5 py-4">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button class="p-2 rounded-lg hover:bg-dark-500 transition-colors" title="Edit" @click="openEditDialog(model)">
|
||||
<i class="fa-solid fa-pen text-gray-400 hover:text-white"></i>
|
||||
</button>
|
||||
<button class="p-2 rounded-lg hover:bg-dark-500 transition-colors" title="Delete" @click="deleteModel(model.id)">
|
||||
<i class="fa-solid fa-trash text-gray-400 hover:text-red-400"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">Model</th>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">Model Type</th>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">Base URL</th>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">Status</th>
|
||||
<th class="text-right px-5 py-3 text-sm font-medium text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="model in filteredModels" :key="model.id" class="border-t border-dark-600 hover:bg-dark-600/50">
|
||||
<td class="px-5 py-4">
|
||||
<span class="font-medium">{{ model.name }}</span>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-sm text-gray-300">
|
||||
{{ model.provider }}
|
||||
</td>
|
||||
<td class="px-5 py-4 text-sm text-gray-300">
|
||||
{{ model.model }}
|
||||
</td>
|
||||
<td class="px-5 py-4">
|
||||
<span class="px-2 py-1 rounded text-xs" :class="model.model_type === 'chat' ? 'bg-primary-cyan/20 text-primary-cyan' : 'bg-purple-500/20 text-purple-400'">{{ model.model_type }}</span>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-sm text-gray-300">
|
||||
{{ model.base_url }}
|
||||
</td>
|
||||
<td class="px-5 py-4">
|
||||
<span v-if="model.status === 1" class="px-2 py-1 rounded text-xs bg-primary-success/20 text-primary-success">Active</span>
|
||||
<span v-else class="px-2 py-1 rounded text-xs bg-gray-500/20 text-gray-400">Inactive</span>
|
||||
</td>
|
||||
<td class="px-5 py-4">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button class="p-2 rounded-lg hover:bg-dark-500 transition-colors" title="Edit" @click="openEditDialog(model)">
|
||||
<i class="fa-solid fa-pen text-gray-400 hover:text-white"></i>
|
||||
</button>
|
||||
<button class="p-2 rounded-lg hover:bg-dark-500 transition-colors" title="Delete" @click="deleteModel(model.id)">
|
||||
<i class="fa-solid fa-trash text-gray-400 hover:text-red-400"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="bg-dark-700 rounded-xl">
|
||||
<div class="empty-box">
|
||||
<i class="fa-solid fa-cube"></i>
|
||||
</div>
|
||||
<p class="empty-text">{{ searchQuery ? 'No matching models found' : 'No models found' }}</p>
|
||||
<p class="empty-tip">{{ searchQuery ? 'Try adjusting your search' : 'Click "Add New Model" to add your first model' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增模型弹窗 -->
|
||||
<!-- 新增模型弹窗 -->
|
||||
<FormDialog
|
||||
:model-value="showAddModelForm"
|
||||
@update:model-value="showAddModelForm = $event"
|
||||
@@ -306,6 +335,44 @@ onMounted(() => {
|
||||
</button>
|
||||
</template>
|
||||
</FormDialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 空状态 */
|
||||
.empty-box {
|
||||
min-height: 340px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1f2937, #111827);
|
||||
border-radius: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-icon i {
|
||||
font-size: 40px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #d1d5db;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -107,20 +107,33 @@ onMounted(() => {
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search skills by name, type or description..."
|
||||
class="search-input w-full"
|
||||
class="search-input w-full pr-10"
|
||||
>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
@click="searchQuery = ''"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<el-select v-model="filterStatus" placeholder="Select" class="w-40" size="large">
|
||||
<el-select v-model="filterStatus" placeholder="Select" class="status-select w-40" size="large">
|
||||
<el-option label="All Status" value="all" />
|
||||
<el-option label="Running" value="running" />
|
||||
<el-option label="Stopped" value="stopped" />
|
||||
<el-option label="Error" value="error" />
|
||||
<el-option label="Active" value="active" />
|
||||
<el-option label="Inactive" value="inactive" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- Skills 列表 -->
|
||||
<div class="bg-dark-700 rounded-xl overflow-hidden shadow-lg">
|
||||
<table v-if="filteredSkills.length > 0" class="w-full">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="skillsLoading" class="flex items-center justify-center py-20">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="w-10 h-10 border-4 border-orange-500/30 border-t-orange-500 rounded-full animate-spin"></div>
|
||||
<p class="text-gray-400 text-sm">Loading skills...</p>
|
||||
</div>
|
||||
</div>
|
||||
<table v-else-if="filteredSkills.length > 0" class="w-full">
|
||||
<thead class="bg-dark-600">
|
||||
<tr>
|
||||
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400 w-1/3">Skill Name</th>
|
||||
@@ -131,7 +144,7 @@ onMounted(() => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="skill in filteredSkills" :key="skill.id" class="table-row">
|
||||
<tr v-for="skill in filteredSkills" :key="skill.id" class="table-row hover:bg-dark-600/50 transition-colors cursor-pointer">
|
||||
<td class="px-5 py-4">
|
||||
<div class="flex gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center flex-shrink-0 self-center">
|
||||
@@ -147,9 +160,9 @@ onMounted(() => {
|
||||
<span class="text-gray-400 text-sm">{{ skill.skill_type === 'system' ? 'System' : 'User' }}</span>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-center">
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs" :class="skill.status === 1 ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="skill.status === 1 ? 'bg-green-500' : 'bg-gray-400'"></span>
|
||||
<span class="capitalize">{{ skill.status === 1 ? 'active' : 'inactive' }}</span>
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs" :class="skill.status === 'active' ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="skill.status === 'active' ? 'bg-green-500' : 'bg-gray-400'"></span>
|
||||
<span class="capitalize">{{ skill.status === 'active' ? 'active' : 'inactive' }}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-center text-gray-400 text-sm">{{ skill.created_at ? formatDate(skill.created_at, 'YYYY/MM/DD HH:mm') : '-' }}</td>
|
||||
@@ -158,9 +171,9 @@ onMounted(() => {
|
||||
<button
|
||||
@click="toggleStatus(skill)"
|
||||
class="btn-icon"
|
||||
:title="skill.status === 1 ? 'Pause' : 'Start'"
|
||||
:title="skill.status === 'active' ? 'Pause' : 'Start'"
|
||||
>
|
||||
<Pause v-if="skill.status === 1" class="w-4 h-4 text-gray-400 hover:text-yellow-400" />
|
||||
<Pause v-if="skill.status === 'active'" class="w-4 h-4 text-gray-400 hover:text-yellow-400" />
|
||||
<Play v-else class="w-4 h-4 text-gray-400 hover:text-green-400" />
|
||||
</button>
|
||||
<button @click="openEdit(skill)" class="btn-icon" title="Edit">
|
||||
@@ -382,10 +395,11 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<!-- 导入单个文件选项 -->
|
||||
<!-- 导入文件夹选项 -->
|
||||
<input
|
||||
type="file"
|
||||
accept=".md"
|
||||
webkitdirectory
|
||||
multiple
|
||||
style="display: none"
|
||||
@change="handleFolderSelect"
|
||||
id="folderInput"
|
||||
@@ -395,10 +409,10 @@ onMounted(() => {
|
||||
class="border-2 border-dashed border-dark-500 rounded-xl p-10 text-center cursor-pointer hover:border-green-500 transition-colors"
|
||||
>
|
||||
<svg class="w-14 h-14 mx-auto text-gray-400 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
|
||||
</svg>
|
||||
<p class="text-white text-lg mb-1">Import SKILL.md</p>
|
||||
<p class="text-sm text-gray-500">Select a SKILL.md file</p>
|
||||
<p class="text-white text-lg mb-1">Import Skill Folder</p>
|
||||
<p class="text-sm text-gray-500">Select a folder containing SKILL.md and other files</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@@ -431,6 +445,11 @@ onMounted(() => {
|
||||
<input v-model="importSkillName" type="text" class="input-field">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Description</label>
|
||||
<input v-model="importSkillDesc" type="text" class="input-field" placeholder="Enter skill description...">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">SKILL.md Content</label>
|
||||
<textarea v-model="importSkillContent" rows="20" class="input-field resize-none font-mono text-sm" style="min-height: 400px;"></textarea>
|
||||
@@ -451,5 +470,29 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 使用全局样式 */
|
||||
/* 下拉框样式优化 */
|
||||
.status-select :deep(.el-input__wrapper) {
|
||||
@apply bg-dark-700 border-dark-500 rounded-lg;
|
||||
}
|
||||
|
||||
.status-select :deep(.el-input__inner) {
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
.status-select :deep(.el-select-dropdown) {
|
||||
@apply bg-dark-700 border-dark-500;
|
||||
}
|
||||
|
||||
.status-select :deep(.el-select-dropdown__item) {
|
||||
@apply text-gray-300;
|
||||
}
|
||||
|
||||
.status-select :deep(.el-select-dropdown__item.hover),
|
||||
.status-select :deep(.el-select-dropdown__item:hover) {
|
||||
@apply bg-dark-600;
|
||||
}
|
||||
|
||||
.status-select :deep(.el-select-dropdown__item.selected) {
|
||||
@apply text-orange-400 bg-dark-600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// Agent API 调用和状态管理
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { formatDate } from '@/utils/format'
|
||||
|
||||
const API_BASE = 'http://localhost:8082'
|
||||
import API_BASE from '@/composables/useApiBase'
|
||||
|
||||
// 类型定义
|
||||
export interface Skill {
|
||||
@@ -151,6 +150,15 @@ const isIndeterminate = computed(() => {
|
||||
return newAgent.value.selectedSkills.length > 0 && newAgent.value.selectedSkills.length < skillsOptions.value.length
|
||||
})
|
||||
|
||||
// 编辑模式下的全选状态
|
||||
const isAllSelectedEdit = computed(() => {
|
||||
return skillsOptions.value.length > 0 && editingAgent.value.selectedSkills.length === skillsOptions.value.length
|
||||
})
|
||||
|
||||
const isIndeterminateEdit = computed(() => {
|
||||
return editingAgent.value.selectedSkills.length > 0 && editingAgent.value.selectedSkills.length < skillsOptions.value.length
|
||||
})
|
||||
|
||||
// 方法
|
||||
async function fetchAgents() {
|
||||
try {
|
||||
@@ -203,12 +211,14 @@ async function fetchModels() {
|
||||
}
|
||||
const result = await response.json()
|
||||
if (result.list) {
|
||||
modelsList.value = result.list.map((m: any) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
provider: m.provider,
|
||||
model: m.model
|
||||
}))
|
||||
modelsList.value = result.list
|
||||
.filter((m: any) => m.status !== 0)
|
||||
.map((m: any) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
provider: m.provider,
|
||||
model: m.model
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch models:', error)
|
||||
@@ -380,6 +390,16 @@ async function toggleStatus(agent: Agent) {
|
||||
|
||||
async function deleteAgent(id: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'This action will permanently delete the agent. Continue?',
|
||||
'Delete Agent',
|
||||
{
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/agent/${id}`, { method: 'DELETE' })
|
||||
if (response.ok) {
|
||||
agents.value = agents.value.filter(a => a.id !== id)
|
||||
@@ -388,8 +408,10 @@ async function deleteAgent(id: string) {
|
||||
ElMessage.error('Failed to delete agent')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete agent:', error)
|
||||
ElMessage.error('Failed to delete agent')
|
||||
if (error !== 'cancel') {
|
||||
console.error('Failed to delete agent:', error)
|
||||
ElMessage.error('Failed to delete agent')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,6 +465,20 @@ function clearSkills() {
|
||||
newAgent.value.selectedSkills = []
|
||||
}
|
||||
|
||||
// 切换编辑模式下的全选
|
||||
function toggleSelectAllEdit() {
|
||||
if (isAllSelectedEdit.value) {
|
||||
editingAgent.value.selectedSkills = []
|
||||
} else {
|
||||
editingAgent.value.selectedSkills = skillsOptions.value.map(s => s.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除编辑模式下的技能
|
||||
function clearSkillsEdit() {
|
||||
editingAgent.value.selectedSkills = []
|
||||
}
|
||||
|
||||
// 切换技能模式下拉框
|
||||
function toggleSkillsMode() {
|
||||
showSkillsDropdown.value = !showSkillsDropdown.value
|
||||
@@ -560,6 +596,8 @@ export function useAgents() {
|
||||
stats,
|
||||
isAllSelected,
|
||||
isIndeterminate,
|
||||
isAllSelectedEdit,
|
||||
isIndeterminateEdit,
|
||||
// 方法
|
||||
fetchAgents,
|
||||
fetchSkills,
|
||||
@@ -577,6 +615,8 @@ export function useAgents() {
|
||||
handleSkillsModeClickEdit,
|
||||
toggleSelectAll,
|
||||
clearSkills,
|
||||
toggleSelectAllEdit,
|
||||
clearSkillsEdit,
|
||||
handleClickOutside,
|
||||
toggleSkillsMode,
|
||||
selectSkillsMode,
|
||||
|
||||
@@ -288,7 +288,7 @@ export function useChat() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
user_id: userId,
|
||||
agent_id: selectedAgent.value?.id || 1,
|
||||
agent_id: String(selectedAgent.value?.id || 1),
|
||||
title: title,
|
||||
model_id: selectedModel.value?.id
|
||||
})
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { Database, TableInfo, ColumnInfo, DbForm } from './types'
|
||||
|
||||
// API 基础 URL
|
||||
const API_BASE = 'http://localhost:8082'
|
||||
import API_BASE from '@/composables/useApiBase'
|
||||
|
||||
// 解析 DDL 获取列信息
|
||||
function parseDDLColumns(ddl: string): ColumnInfo[] {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Knowledge Base API
|
||||
|
||||
const API_BASE = 'http://localhost:8082'
|
||||
import API_BASE from '@/composables/useApiBase'
|
||||
|
||||
export interface KnowledgeBase {
|
||||
id: string
|
||||
|
||||
76
web/src/views/modelapis/modelapis.css
Normal file
76
web/src/views/modelapis/modelapis.css
Normal file
@@ -0,0 +1,76 @@
|
||||
/* ModelAPIs 页面样式 */
|
||||
|
||||
/* 空状态样式 */
|
||||
.empty-box {
|
||||
min-height: 340px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1f2937, #111827);
|
||||
border-radius: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-icon i {
|
||||
font-size: 40px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #d1d5db;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #f97316 0%, #ef4444 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #fb923c 0%, #f97316 100%);
|
||||
}
|
||||
|
||||
/* 搜索框 */
|
||||
.search-input {
|
||||
background-color: #171922;
|
||||
border: 1px solid #374151;
|
||||
color: white;
|
||||
padding: 10px 12px 10px 36px;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #f97316;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #6b7280;
|
||||
}
|
||||
135
web/src/views/modelapis/useModelAPIs.ts
Normal file
135
web/src/views/modelapis/useModelAPIs.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
// ModelAPI 接口
|
||||
export interface ModelAPI {
|
||||
id: number
|
||||
name: string
|
||||
provider: string
|
||||
status: 'active' | 'inactive' | 'error'
|
||||
model: string
|
||||
requests: number
|
||||
latency: string
|
||||
createdAt: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function useModelAPIs() {
|
||||
const modelAPIs = ref<ModelAPI[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
// 模拟加载数据
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
modelAPIs.value = [
|
||||
{ id: 1, name: 'OpenAI Primary', provider: 'OpenAI', status: 'active', model: 'gpt-4o', requests: 1250, latency: '45ms', createdAt: '2025-04-10', description: 'Primary OpenAI API endpoint' },
|
||||
{ id: 2, name: 'OpenAI Backup', provider: 'OpenAI', status: 'inactive', model: 'gpt-4o-mini', requests: 0, latency: '0ms', createdAt: '2025-04-08', description: 'Backup OpenAI API endpoint' },
|
||||
{ id: 3, name: 'Google Gemini', provider: 'Google', status: 'active', model: 'gemini-2.0-flash', requests: 890, latency: '32ms', createdAt: '2025-04-05', description: 'Google Gemini API integration' },
|
||||
{ id: 4, name: 'Cerebras Fast', provider: 'Cerebras', status: 'active', model: 'cerebras-sandbox', requests: 2100, latency: '12ms', createdAt: '2025-04-12', description: 'Cerebras high-speed inference' },
|
||||
{ id: 5, name: 'Anthropic Claude', provider: 'Anthropic', status: 'error', model: 'claude-3-5-sonnet', requests: 450, latency: '0ms', createdAt: '2025-04-11', description: 'Anthropic Claude API' },
|
||||
{ id: 6, name: 'Azure OpenAI', provider: 'Microsoft', status: 'active', model: 'gpt-4', requests: 680, latency: '55ms', createdAt: '2025-04-09', description: 'Azure-hosted OpenAI models' },
|
||||
]
|
||||
isLoading.value = false
|
||||
}, 500)
|
||||
})
|
||||
|
||||
const editingModel = ref<ModelAPI | null>(null)
|
||||
const isEditing = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const filterStatus = ref<string>('all')
|
||||
|
||||
const editForm = ref({
|
||||
name: '',
|
||||
provider: '',
|
||||
model: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const openEdit = (model: ModelAPI) => {
|
||||
editingModel.value = model
|
||||
editForm.value = {
|
||||
name: model.name,
|
||||
provider: model.provider,
|
||||
model: model.model,
|
||||
description: model.description,
|
||||
}
|
||||
isEditing.value = true
|
||||
}
|
||||
|
||||
const saveEdit = () => {
|
||||
if (editingModel.value) {
|
||||
const index = modelAPIs.value.findIndex(m => m.id === editingModel.value!.id)
|
||||
if (index !== -1) {
|
||||
modelAPIs.value[index] = {
|
||||
...modelAPIs.value[index],
|
||||
...editForm.value,
|
||||
}
|
||||
}
|
||||
}
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
isEditing.value = false
|
||||
editingModel.value = null
|
||||
}
|
||||
|
||||
const toggleStatus = (model: ModelAPI) => {
|
||||
if (model.status === 'active') {
|
||||
model.status = 'inactive'
|
||||
} else if (model.status === 'inactive') {
|
||||
model.status = 'active'
|
||||
}
|
||||
}
|
||||
|
||||
const deleteModel = (id: number) => {
|
||||
modelAPIs.value = modelAPIs.value.filter(m => m.id !== id)
|
||||
}
|
||||
|
||||
const filteredModels = () => {
|
||||
return modelAPIs.value.filter(model => {
|
||||
const matchSearch = model.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
model.provider.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
model.model.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
const matchStatus = filterStatus.value === 'all' || model.status === filterStatus.value
|
||||
return matchSearch && matchStatus
|
||||
})
|
||||
}
|
||||
|
||||
const statusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return 'bg-primary-success'
|
||||
case 'inactive': return 'bg-gray-500'
|
||||
case 'error': return 'bg-primary-danger'
|
||||
default: return 'bg-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
const providerIcon = (provider: string) => {
|
||||
switch (provider) {
|
||||
case 'OpenAI': return 'fa-openai'
|
||||
case 'Google': return 'fa-google'
|
||||
case 'Cerebras': return 'fa-microchip'
|
||||
case 'Anthropic': return 'fa-robot'
|
||||
case 'Microsoft': return 'fa-microsoft'
|
||||
default: return 'fa-cube'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
modelAPIs,
|
||||
isLoading,
|
||||
editingModel,
|
||||
isEditing,
|
||||
searchQuery,
|
||||
filterStatus,
|
||||
editForm,
|
||||
openEdit,
|
||||
saveEdit,
|
||||
cancelEdit,
|
||||
toggleStatus,
|
||||
deleteModel,
|
||||
filteredModels,
|
||||
statusClass,
|
||||
providerIcon,
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,27 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { ModelInfo, ModelForm, ModelTypeOption, ProviderOption } from './types'
|
||||
|
||||
// API 基础 URL
|
||||
const API_BASE = 'http://localhost:8082'
|
||||
import API_BASE from '@/composables/useApiBase'
|
||||
|
||||
export function useModelSettings() {
|
||||
// Model 列表
|
||||
const models = ref<ModelInfo[]>([])
|
||||
const modelsLoading = ref(false)
|
||||
|
||||
// 搜索关键词
|
||||
const searchQuery = ref('')
|
||||
|
||||
// 过滤后的模型列表
|
||||
const filteredModels = computed(() => {
|
||||
if (!searchQuery.value) return models.value
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return models.value.filter(model =>
|
||||
model.name.toLowerCase().includes(query) ||
|
||||
model.provider.toLowerCase().includes(query) ||
|
||||
model.model.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
// Model Type 选项
|
||||
const modelTypeOptions: ModelTypeOption[] = [
|
||||
{ value: 'chat', label: 'Chat' },
|
||||
@@ -86,7 +98,7 @@ export function useModelSettings() {
|
||||
const editConnectionStatus = ref<'idle' | 'success' | 'error'>('idle')
|
||||
|
||||
// 编辑前模型的状态
|
||||
const originalStatus = ref<string>('')
|
||||
const originalStatus = ref<number>(0)
|
||||
|
||||
// 获取模型列表
|
||||
const fetchModels = async () => {
|
||||
@@ -210,7 +222,7 @@ export function useModelSettings() {
|
||||
api_key: newModelForm.value.apiKey,
|
||||
base_url: newModelForm.value.baseUrl,
|
||||
api_endpoint: newModelForm.value.apiEndpoint,
|
||||
status: connectionStatus.value === 'success' ? 'active' : 'inactive',
|
||||
status: connectionStatus.value === 'success' ? 1 : 0,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -297,14 +309,12 @@ export function useModelSettings() {
|
||||
}
|
||||
|
||||
// 根据测试连接结果设置状态
|
||||
// 用户测试通过 -> active
|
||||
// 用户测试失败 -> error
|
||||
// 用户没有测试 -> inactive
|
||||
let newStatus = 'inactive'
|
||||
// 用户测试通过 -> 1 (active)
|
||||
// 用户测试失败 -> 0 (inactive)
|
||||
// 用户没有测试 -> 0 (inactive)
|
||||
let newStatus = 0 // inactive
|
||||
if (editConnectionStatus.value === 'success') {
|
||||
newStatus = 'active'
|
||||
} else if (editConnectionStatus.value === 'error') {
|
||||
newStatus = 'error'
|
||||
newStatus = 1 // active
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -361,6 +371,8 @@ export function useModelSettings() {
|
||||
// State
|
||||
models,
|
||||
modelsLoading,
|
||||
searchQuery,
|
||||
filteredModels,
|
||||
modelTypeOptions,
|
||||
providerOptions,
|
||||
showAddModelForm,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||
|
||||
const API_BASE = 'http://localhost:8082'
|
||||
import JSZip from 'jszip'
|
||||
import API_BASE from '@/composables/useApiBase'
|
||||
|
||||
export interface Skill {
|
||||
id: string
|
||||
@@ -9,7 +9,7 @@ export interface Skill {
|
||||
skill_type: string
|
||||
skill_desc: string
|
||||
path: string
|
||||
status: number
|
||||
status: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export function useSkills() {
|
||||
const importSkillDesc = ref('')
|
||||
const importSkillContent = ref('')
|
||||
const isImportStep2 = ref(false)
|
||||
const importFolderFiles = ref<Map<string, string>>(new Map()) // 存储文件夹中的所有文件
|
||||
|
||||
// 下拉菜单状态
|
||||
const showDropdown = ref(false)
|
||||
@@ -364,7 +365,7 @@ Example 1:
|
||||
|
||||
// 切换状态
|
||||
const toggleStatus = async (skill: Skill) => {
|
||||
const newStatus = skill.status === 1 ? 0 : 1
|
||||
const newStatus = skill.status === 'active' ? 'inactive' : 'active'
|
||||
try {
|
||||
await updateSkill(skill.id, { status: newStatus })
|
||||
skill.status = newStatus
|
||||
@@ -411,6 +412,7 @@ Example 1:
|
||||
isImportingDialog.value = false
|
||||
importFile.value = null
|
||||
importFileName.value = ''
|
||||
importFolderFiles.value = new Map()
|
||||
isImportStep2.value = false
|
||||
}
|
||||
|
||||
@@ -482,13 +484,37 @@ Example 1:
|
||||
|
||||
isImporting.value = true
|
||||
try {
|
||||
const blob = new Blob([importSkillContent.value], { type: 'text/markdown' })
|
||||
const file = new File([blob], 'SKILL.md', { type: 'text/markdown' })
|
||||
let fileToUpload: File
|
||||
|
||||
// 如果有文件夹中的文件,打包成 zip
|
||||
if (importFolderFiles.value.size > 0) {
|
||||
const zip = new JSZip()
|
||||
|
||||
// 添加所有文件到 zip
|
||||
for (const [relativePath, content] of importFolderFiles.value) {
|
||||
// 去除文件夹前缀,保留相对路径
|
||||
const pathParts = relativePath.split('/')
|
||||
if (pathParts.length > 1) {
|
||||
pathParts.shift() // 移除文件夹名称
|
||||
}
|
||||
const filePath = pathParts.join('/')
|
||||
zip.file(filePath, content)
|
||||
}
|
||||
|
||||
// 生成 zip 文件
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' })
|
||||
fileToUpload = new File([zipBlob], `${importSkillName.value}.zip`, { type: 'application/zip' })
|
||||
} else {
|
||||
// 单文件模式
|
||||
const blob = new Blob([importSkillContent.value], { type: 'text/markdown' })
|
||||
fileToUpload = new File([blob], 'SKILL.md', { type: 'text/markdown' })
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('skill_name', importSkillName.value)
|
||||
formData.append('skill_desc', importSkillDesc.value)
|
||||
formData.append('file', file)
|
||||
formData.append('skill_type', 'user')
|
||||
formData.append('file', fileToUpload)
|
||||
|
||||
const response = await fetch(`${API_BASE}/skill/add`, {
|
||||
method: 'POST',
|
||||
@@ -511,7 +537,7 @@ Example 1:
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文件选择(单个 SKILL.md 文件)
|
||||
// 处理文件夹选择(导入整个文件夹)
|
||||
const handleFolderSelect = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files
|
||||
@@ -521,18 +547,93 @@ Example 1:
|
||||
isImporting.value = true
|
||||
|
||||
try {
|
||||
const file = files[0]
|
||||
const folderFiles = new Map<string, string>()
|
||||
let skillMdContent = ''
|
||||
let folderName = ''
|
||||
|
||||
// 读取文件内容
|
||||
const content = await file.text()
|
||||
const fileName = file.name.replace('.md', '')
|
||||
// 使用 Promise.all 并行读取所有文件
|
||||
const fileReadPromises: Promise<void>[] = []
|
||||
|
||||
const { skillName, skillDesc } = parseSkillContent(content, fileName)
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
// 获取相对路径
|
||||
const relativePath = file.webkitRelativePath || file.name
|
||||
|
||||
// 跳过目录类型的空文件
|
||||
if (!file.name && !relativePath) continue
|
||||
|
||||
// 获取文件夹名称(从第一个文件的路径中提取)
|
||||
if (!folderName) {
|
||||
const pathParts = relativePath.split('/')
|
||||
if (pathParts.length > 1) {
|
||||
folderName = pathParts[0]
|
||||
}
|
||||
}
|
||||
|
||||
// 创建读取文件的 Promise
|
||||
const readPromise = new Promise<void>((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string
|
||||
folderFiles.set(relativePath, content)
|
||||
|
||||
// 找到根目录的 SKILL.md 文件(第一级目录下)
|
||||
const pathParts = relativePath.split('/')
|
||||
if (pathParts.length === 2 && (relativePath.endsWith('SKILL.md') || file.name === 'SKILL.md')) {
|
||||
skillMdContent = content
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
reader.onerror = () => resolve()
|
||||
reader.readAsText(file)
|
||||
})
|
||||
fileReadPromises.push(readPromise)
|
||||
}
|
||||
|
||||
// 等待所有文件读取完成
|
||||
await Promise.all(fileReadPromises)
|
||||
|
||||
// 检查根目录是否有 SKILL.md
|
||||
let hasRootSkillMd = false
|
||||
for (const [path] of folderFiles) {
|
||||
const pathParts = path.split('/')
|
||||
// 根目录下的 SKILL.md(路径只有一级,如 "SKILL.md" 或 "folderName/SKILL.md")
|
||||
if (pathParts.length === 2 && path.endsWith('SKILL.md')) {
|
||||
hasRootSkillMd = true
|
||||
// 获取 skillMdContent
|
||||
skillMdContent = folderFiles.get(path) || ''
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasRootSkillMd) {
|
||||
ElMessage.error('导入失败:文件夹根目录必须包含 SKILL.md 文件')
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 如果 folderName 为空,从路径中推断
|
||||
if (!folderName) {
|
||||
for (const [path] of folderFiles) {
|
||||
const pathParts = path.split('/')
|
||||
if (pathParts.length >= 2) {
|
||||
folderName = pathParts[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 SKILL.md 内容
|
||||
const { skillName, skillDesc } = parseSkillContent(skillMdContent, folderName)
|
||||
|
||||
// 保存文件夹中的所有文件
|
||||
importFolderFiles.value = folderFiles
|
||||
importFileName.value = folderName
|
||||
|
||||
// 使用导入弹窗显示内容
|
||||
importSkillName.value = skillName
|
||||
importSkillDesc.value = skillDesc
|
||||
importSkillContent.value = content
|
||||
importSkillContent.value = skillMdContent
|
||||
isImportingDialog.value = true
|
||||
isImportStep2.value = true
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const API_BASE = 'http://localhost:8082'
|
||||
import API_BASE from '@/composables/useApiBase'
|
||||
|
||||
// Tool 接口
|
||||
export interface Tool {
|
||||
|
||||
@@ -1,32 +1,39 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
import monacoEditorPlugin from 'vite-plugin-monaco-editor'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
(monacoEditorPlugin as any).default({})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
fs: {
|
||||
allow: ['..']
|
||||
export default defineConfig(({ mode }) => {
|
||||
// 加载根目录的 .env 文件
|
||||
const env = loadEnv(mode, resolve(__dirname, '..'), '')
|
||||
|
||||
const apiBase = env.VITE_API_BASE || 'http://localhost:8082'
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
vue(),
|
||||
(monacoEditorPlugin as any).default({})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8082',
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
server: {
|
||||
fs: {
|
||||
allow: ['..']
|
||||
},
|
||||
'/model': {
|
||||
target: 'http://localhost:8082',
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: apiBase,
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
},
|
||||
'/model': {
|
||||
target: apiBase,
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user