feat: 更新 Web 前端页面

- 更新 Agents、Chat、Settings 等页面
- 新增 ModelAPIs 页面
- 更新各个模块的 composables
- 更新 vite 配置和依赖版本

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 21:29:01 +08:00
parent 71e8cc59d5
commit ecb6be6463
24 changed files with 1031 additions and 757 deletions

11
web/.env.example Normal file
View 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
View File

@@ -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": {

View File

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

View File

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

View 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

View File

@@ -1,4 +1,4 @@
const API_BASE = 'http://localhost:8082'
import API_BASE from './useApiBase'
export interface LoginRequest {
username: string

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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[] {

View File

@@ -1,6 +1,6 @@
// Knowledge Base API
const API_BASE = 'http://localhost:8082'
import API_BASE from '@/composables/useApiBase'
export interface KnowledgeBase {
id: string

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

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

View File

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

View File

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

View File

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

View File

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