refactor(frontend): move views into app and pages structure

Reorganize the frontend around app-level routing and page modules so the runtime and feature screens share a clearer navigation and composition layout for future work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 22:13:12 +08:00
parent a27736a832
commit b024a2bcb5
25 changed files with 2628 additions and 1656 deletions

View File

@@ -1,5 +1,12 @@
import api from './index'
export interface MessageAttachment {
id: string
name: string
type: string
size: number
}
export interface Message {
id: string
role: 'user' | 'assistant'
@@ -7,6 +14,7 @@ export interface Message {
model?: string
tokens_used?: number
created_at: string
attachments?: MessageAttachment[]
}
export interface Conversation {

View File

@@ -1,27 +1,81 @@
import axios from 'axios'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000',
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:9527',
timeout: 30000,
})
function createRequestId() {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID()
}
return `req-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function isDev() {
return Boolean(import.meta.env.DEV)
}
function debugLog(stage: string, payload: Record<string, unknown>) {
if (!isDev()) return
console.debug(`[api:${stage}]`, payload)
}
// 请求拦截器:添加 Token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
const requestId = createRequestId()
config.headers = config.headers || {}
config.headers['X-Request-ID'] = requestId
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
;(config as typeof config & { metadata?: { startedAt: number; requestId: string } }).metadata = {
startedAt: Date.now(),
requestId,
}
debugLog('request', {
requestId,
method: config.method,
url: config.url,
params: config.params,
data: config.data,
})
return config
})
// 响应拦截器:处理错误
api.interceptors.response.use(
(response) => response,
(response) => {
const metadata = (response.config as typeof response.config & { metadata?: { startedAt: number; requestId: string } }).metadata
debugLog('response', {
requestId: response.headers['x-request-id'] || metadata?.requestId,
method: response.config.method,
url: response.config.url,
status: response.status,
durationMs: metadata ? Date.now() - metadata.startedAt : undefined,
data: response.data,
})
return response
},
async (error) => {
const metadata = (error.config as typeof error.config & { metadata?: { startedAt: number; requestId: string } })?.metadata
const requestId = error.response?.headers?.['x-request-id'] || metadata?.requestId
if (error.response?.status === 401) {
localStorage.removeItem('access_token')
window.location.href = '/login'
}
debugLog('error', {
requestId,
method: error.config?.method,
url: error.config?.url,
status: error.response?.status,
durationMs: metadata ? Date.now() - metadata.startedAt : undefined,
detail: error.response?.data,
})
if (requestId) {
error.requestId = requestId
}
return Promise.reject(error)
}
)

View File

@@ -0,0 +1,34 @@
import {
Activity,
BookOpen,
Bot,
CheckSquare,
LayoutGrid,
MessageCircle,
MessageSquare,
Network,
Settings,
Star,
Terminal,
type LucideIcon,
} from 'lucide-vue-next'
export interface NavItem {
name: string
path: string
icon: LucideIcon
}
export const navItems: NavItem[] = [
{ name: '沟通系统', path: '/chat', icon: MessageCircle },
{ name: '智能链路', path: '/agents', icon: Bot },
{ name: '技能中心', path: '/skills', icon: Star },
{ name: '资料中枢', path: '/knowledge', icon: BookOpen },
{ name: '知识大脑', path: '/graph', icon: Network },
{ name: '任务矩阵', path: '/kanban', icon: LayoutGrid },
{ name: '任务调度', path: '/todo', icon: CheckSquare },
{ name: '信息交易所', path: '/forum', icon: MessageSquare },
{ name: '运行状态', path: '/stats', icon: Activity },
{ name: '运行日志', path: '/logs', icon: Terminal },
{ name: '系统设置', path: '/settings', icon: Settings },
]

View File

@@ -0,0 +1,21 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { routes } from './routes'
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, _from, next) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isAuthenticated) {
next('/login')
} else if (to.meta.guest && auth.isAuthenticated) {
next('/chat')
} else {
next()
}
})
export default router

View File

@@ -0,0 +1,80 @@
import type { RouteRecordRaw } from 'vue-router'
const appChildren: RouteRecordRaw[] = [
{
path: '',
redirect: '/chat',
},
{
path: 'chat',
name: 'chat',
component: () => import('@/pages/chat/index.vue'),
},
{
path: 'knowledge',
name: 'knowledge',
component: () => import('@/pages/knowledge/index.vue'),
},
{
path: 'graph',
name: 'graph',
component: () => import('@/pages/graph/index.vue'),
},
{
path: 'kanban',
name: 'kanban',
component: () => import('@/pages/kanban/index.vue'),
},
{
path: 'forum',
name: 'forum',
component: () => import('@/pages/forum/index.vue'),
},
{
path: 'agents',
name: 'agents',
component: () => import('@/pages/agents/index.vue'),
},
{
path: 'stats',
name: 'stats',
component: () => import('@/pages/stats/index.vue'),
},
{
path: 'skills',
name: 'skills',
component: () => import('@/pages/skills/index.vue'),
},
{
path: 'todo',
name: 'todo',
component: () => import('@/pages/todo/index.vue'),
},
{
path: 'settings',
name: 'settings',
component: () => import('@/pages/settings/index.vue'),
},
{
path: 'logs',
name: 'logs',
component: () => import('@/pages/logs/index.vue'),
},
]
export const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'login',
component: () => import('@/pages/login/index.vue'),
meta: { guest: true },
},
{
path: '/',
component: () => import('@/pages/app/layout.vue'),
meta: { requiresAuth: true },
children: appChildren,
},
]
export { appChildren }

View File

@@ -1,26 +1,13 @@
<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { MessageCircle, BookOpen, Network, LayoutGrid, MessageSquare, LogOut, Cpu, Bot, Activity, CheckSquare, Settings, Star, Terminal } from 'lucide-vue-next'
import { LogOut, Cpu } from 'lucide-vue-next'
import { navItems } from '@/app/navigation/nav'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const navItems = [
{ name: '沟通系统', path: '/chat', icon: MessageCircle },
{ name: '智能链路', path: '/agents', icon: Bot },
{ name: '技能中心', path: '/skills', icon: Star },
{ name: '资料中枢', path: '/knowledge', icon: BookOpen },
{ name: '知识大脑', path: '/graph', icon: Network },
{ name: '任务矩阵', path: '/kanban', icon: LayoutGrid },
{ name: '任务调度', path: '/todo', icon: CheckSquare },
{ name: '信息交易所', path: '/forum', icon: MessageSquare },
{ name: '运行状态', path: '/stats', icon: Activity },
{ name: '运行日志', path: '/logs', icon: Terminal },
{ name: '系统设置', path: '/settings', icon: Settings },
]
function isActive(path: string) {
return route.path === path
}

View File

@@ -2,7 +2,7 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import router from './router'
import router from './app/router'
import App from './App.vue'
import './style.css'

View File

@@ -95,7 +95,7 @@
:key="sub.id"
:ref="el => setSubRef(sub.id, el as HTMLElement)"
class="node-card node-sub"
:class="{ selected: selectedAgentId === sub.id, disabled: !sub.enabled }"
:class="{ selected: selectedAgentId === sub.id, disabled: !localAgents[sub.id]?.enabled }"
:style="getSubNodeStyle(sub)"
@click="selectAgent(sub.id)"
>
@@ -217,7 +217,6 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { animate, type AnimationControls } from 'motion'
import { RefreshCw, X, Plus } from 'lucide-vue-next'
import { DEFAULT_AGENTS, RELATION_LABELS } from '@/data/agents'
import type { Agent } from '@/data/agents'
@@ -232,22 +231,31 @@ const SUB_TOP = 350 // px from top
const SUB_XS = [12.5, 37.5, 62.5, 87.5]
// Sub-agent static data
const subAgents = [
{ id: 'planner', name: 'PLANNER', role: '规划者', description: '制定任务计划,拆解复杂目标为可执行步骤', relLabel: RELATION_LABELS['master-planner'] },
{ id: 'executor', name: 'EXECUTOR', role: '执行者', description: '调用工具执行具体操作,创建/更新/删除系统资源', relLabel: RELATION_LABELS['master-executor'] },
interface SubAgentCard {
id: string
name: string
role: string
description: string
relLabel: string
}
const subAgents: SubAgentCard[] = [
{ id: 'planner', name: 'PLANNER', role: '规划者', description: '制定任务计划,拆解复杂目标为可执行步骤', relLabel: RELATION_LABELS['master-planner'] },
{ id: 'executor', name: 'EXECUTOR', role: '执行者', description: '调用工具执行具体操作,创建/更新/删除系统资源', relLabel: RELATION_LABELS['master-executor'] },
{ id: 'librarian', name: 'LIBRARIAN', role: '知识官', description: '管理知识库和知识图谱,检索相关信息,更新记忆', relLabel: RELATION_LABELS['master-librarian'] },
{ id: 'analyst', name: 'ANALYST', role: '分析师', description: '分析工作数据,生成统计报告,提供洞察建议', relLabel: RELATION_LABELS['master-analyst'] },
{ id: 'analyst', name: 'ANALYST', role: '分析师', description: '分析工作数据,生成统计报告,提供洞察建议', relLabel: RELATION_LABELS['master-analyst'] },
]
type PlaybackHandle = ReturnType<typeof window.setTimeout>
// Refs
const canvasRef = ref<HTMLElement>()
const svgRef = ref<SVGElement>()
const masterCardRef = ref<HTMLElement>()
const canvasRef = ref<HTMLElement | null>(null)
const svgRef = ref<SVGElement | null>(null)
const masterCardRef = ref<HTMLElement | null>(null)
const subRefs: Record<string, HTMLElement> = {}
const masterAnim = ref<AnimationControls | null>(null)
const subAnims: Record<string, AnimationControls> = {}
const hoverAnims: Record<string, AnimationControls | null> = {}
const cleanupFns: Array<() => void> = []
const hoverResetTimers: Record<string, PlaybackHandle | null> = {}
// Background particles
const bgParticles = Array.from({ length: 60 }, (_, i) => {
const d = 3 + Math.random() * 5
@@ -311,7 +319,7 @@ const masterNodeStyle = computed(() => {
}
})
function getSubNodeStyle(sub: (typeof subAgents)[0]) {
function getSubNodeStyle(sub: SubAgentCard) {
const idx = subAgents.findIndex(s => s.id === sub.id)
const pct = SUB_XS[idx] ?? 50
const { x } = pxToSvg(pct, SUB_TOP)
@@ -419,30 +427,109 @@ async function refreshStats() {
function onPulseEnd() { firingLine.value = null; activeLine.value = null }
function stopTimer(timer: PlaybackHandle | null) {
if (timer) window.clearTimeout(timer)
}
function runTransition(
el: Element,
keyframes: Keyframe[],
options: KeyframeAnimationOptions,
done?: () => void,
) {
const animation = (el as HTMLElement).animate(keyframes, {
fill: 'forwards',
...options,
})
const finish = () => done?.()
animation.addEventListener('finish', finish, { once: true })
cleanupFns.push(() => animation.cancel())
return animation
}
// Motion helpers
function animateIn(el: Element) { animate(el, { opacity: [0, 1], x: [80, 0] }, { duration: 0.35, easing: [0.4, 0, 0.2, 1] }).play() }
function animateOut(el: Element) { animate(el, { opacity: [1, 0], x: [0, 80] }, { duration: 0.25, easing: [0.4, 0, 1, 1] }).play() }
function fadeIn(el: Element) { animate(el, { opacity: [0, 1] }, { duration: 0.25 }).play() }
function fadeOut(el: Element) { animate(el, { opacity: [1, 0] }, { duration: 0.2 }).play() }
function animateIn(el: Element, done: () => void) {
runTransition(
el,
[
{ opacity: 0, transform: 'translateX(80px)' },
{ opacity: 1, transform: 'translateX(0)' },
],
{ duration: 350, easing: 'cubic-bezier(0.4, 0, 0.2, 1)' },
done,
)
}
function animateOut(el: Element, done: () => void) {
runTransition(
el,
[
{ opacity: 1, transform: 'translateX(0)' },
{ opacity: 0, transform: 'translateX(80px)' },
],
{ duration: 250, easing: 'cubic-bezier(0.4, 0, 1, 1)' },
done,
)
}
function fadeIn(el: Element, done: () => void) {
runTransition(el, [{ opacity: 0 }, { opacity: 1 }], { duration: 250 }, done)
}
function fadeOut(el: Element, done: () => void) {
runTransition(el, [{ opacity: 1 }, { opacity: 0 }], { duration: 200 }, done)
}
function playEntranceAnimations() {
if (masterCardRef.value) {
masterAnim.value = animate(masterCardRef.value, { opacity: [0, 1], y: [20, 0] }, { duration: 0.6, easing: [0.16, 1, 0.3, 1] })
runTransition(
masterCardRef.value,
[
{ opacity: 0, transform: 'translateY(20px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
{ duration: 600, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' },
)
}
subAgents.forEach((sub, idx) => {
const el = subRefs[sub.id]
if (!el) return
const anim = animate(el, { opacity: [0, 1], y: [20, 0] }, { duration: 0.5, delay: 0.15 + idx * 0.1, easing: [0.16, 1, 0.3, 1] })
subAnims[sub.id] = anim
const hoverAnim = animate(el, { y: [0, -4] }, { duration: 0.2, easing: [0.34, 1.56, 0.64, 1] })
hoverAnim.pause()
hoverAnims[sub.id] = hoverAnim
el.addEventListener('mouseenter', () => {
runTransition(
el,
[
{ opacity: 0, transform: 'translateY(20px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
{
duration: 500,
delay: 150 + idx * 100,
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
},
)
const handleMouseEnter = () => {
if (!localAgents[sub.id]?.enabled) return
hoverAnim.direction = 'forward'; hoverAnim.play()
})
el.addEventListener('mouseleave', () => {
hoverAnim.direction = 'reverse'; hoverAnim.play()
stopTimer(hoverResetTimers[sub.id] ?? null)
el.style.transform = 'translateY(-4px)'
}
const handleMouseLeave = () => {
stopTimer(hoverResetTimers[sub.id] ?? null)
hoverResetTimers[sub.id] = window.setTimeout(() => {
el.style.transform = ''
hoverResetTimers[sub.id] = null
}, 200)
}
el.addEventListener('mouseenter', handleMouseEnter)
el.addEventListener('mouseleave', handleMouseLeave)
cleanupFns.push(() => {
stopTimer(hoverResetTimers[sub.id] ?? null)
el.removeEventListener('mouseenter', handleMouseEnter)
el.removeEventListener('mouseleave', handleMouseLeave)
})
})
}
@@ -466,9 +553,7 @@ onMounted(async () => {
onUnmounted(() => {
if (pollInterval) clearInterval(pollInterval)
resizeObserver?.disconnect()
masterAnim.value?.stop()
Object.values(subAnims).forEach(a => a.stop())
Object.values(hoverAnims).forEach(a => a?.stop())
cleanupFns.forEach(cleanup => cleanup())
})
</script>

View File

@@ -0,0 +1,210 @@
import { nextTick, onMounted, ref } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import { conversationApi, type Message } from '@/api/conversation'
import { documentApi } from '@/api/document'
export interface SelectedFile {
id: string
name: string
type: string
size: number
}
interface MessageWithAttachments extends Message {
attachments?: SelectedFile[]
}
export function useChatView() {
const store = useConversationStore()
const inputMessage = ref('')
const isSending = ref(false)
const chatContainer = ref<HTMLElement>()
const inputRef = ref<HTMLTextAreaElement>()
const isTyping = ref(false)
const fileInputRef = ref<HTMLInputElement>()
const showEmojiPicker = ref(false)
const selectedFiles = ref<SelectedFile[]>([])
async function sendMessage() {
if (!inputMessage.value.trim() || isSending.value) return
isSending.value = true
isTyping.value = true
const text = inputMessage.value.trim()
const attachments = [...selectedFiles.value]
inputMessage.value = ''
store.addMessage({
id: `temp-${Date.now()}`,
role: 'user',
content: text,
created_at: new Date().toISOString(),
attachments,
} as MessageWithAttachments)
await nextTick()
scrollToBottom()
try {
const response = await conversationApi.chat(text, store.currentConversationId || undefined, attachments.map((file) => file.id))
selectedFiles.value = []
isTyping.value = false
store.addMessage({
id: response.data.message_id,
role: 'assistant',
content: response.data.content,
model: response.data.agent_name,
created_at: new Date().toISOString(),
})
if (!store.currentConversationId) {
store.setCurrentConversation(response.data.conversation_id)
await loadConversations()
}
} catch (error) {
isTyping.value = false
console.error('发送失败:', error)
store.addMessage({
id: `err-${Date.now()}`,
role: 'assistant',
content: '抱歉,连接失败。请检查服务状态。',
created_at: new Date().toISOString(),
})
}
isSending.value = false
await nextTick()
scrollToBottom()
}
async function loadConversations() {
try {
const response = await conversationApi.list()
store.setConversations(response.data)
} catch (error) {
console.error('加载对话列表失败:', error)
}
}
async function selectConversation(id: string) {
store.setCurrentConversation(id)
store.setMessages([])
try {
const response = await conversationApi.getMessages(id)
store.setMessages(response.data)
await nextTick()
scrollToBottom()
} catch (error) {
console.error('加载消息失败:', error)
}
}
function newConversation() {
store.setCurrentConversation('')
store.setMessages([])
inputRef.value?.focus()
}
async function deleteConversation(id: string, event: Event) {
event.stopPropagation()
try {
await conversationApi.delete(id)
store.removeConversation(id)
if (store.currentConversationId === id) {
store.setCurrentConversation('')
store.setMessages([])
}
} catch (error) {
console.error('删除失败:', error)
}
}
function scrollToBottom() {
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
}
}
function formatTime(dateStr: string) {
const date = new Date(dateStr)
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
function formatConvDate(dateStr: string) {
const date = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000) return '今天'
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
}
function autoResize(event: Event) {
const element = event.target as HTMLTextAreaElement
element.style.height = 'auto'
element.style.height = `${Math.min(element.scrollHeight, 120)}px`
}
async function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement
if (!input.files?.length) return
for (const file of input.files) {
if (file.size > 10 * 1024 * 1024) {
alert(`文件 ${file.name} 超过10MB限制`)
continue
}
try {
const response = await documentApi.upload(file)
selectedFiles.value.push({
id: response.data.id,
name: file.name,
type: file.type,
size: file.size,
})
} catch (error) {
console.error('上传失败:', error)
alert(`文件 ${file.name} 上传失败`)
}
}
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
function insertEmoji(emoji: string) {
inputMessage.value += emoji
showEmojiPicker.value = false
}
function openFilePicker() {
fileInputRef.value?.click()
}
onMounted(() => {
loadConversations()
inputRef.value?.focus()
})
return {
store,
inputMessage,
isSending,
chatContainer,
inputRef,
isTyping,
fileInputRef,
showEmojiPicker,
selectedFiles,
sendMessage,
selectConversation,
newConversation,
deleteConversation,
formatTime,
formatConvDate,
autoResize,
handleFileSelect,
insertEmoji,
openFilePicker,
}
}

View File

@@ -1,180 +1,29 @@
<script setup lang="ts">
import { ref, onMounted, nextTick, watch } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import { conversationApi } from '@/api/conversation'
import { documentApi } from '@/api/document'
import { MessageCircle, Trash2, Send, Sparkles, CornerDownLeft, Paperclip, Smile } from 'lucide-vue-next'
import EmojiPicker from '@/components/chat/EmojiPicker.vue'
import FileMessage from '@/components/chat/FileMessage.vue'
import { useChatView } from '@/pages/chat/composables/useChatView'
const store = useConversationStore()
const inputMessage = ref('')
const isSending = ref(false)
const chatContainer = ref<HTMLElement>()
const inputRef = ref<HTMLTextAreaElement>()
const isTyping = ref(false)
const fileInputRef = ref<HTMLInputElement>()
const showEmojiPicker = ref(false)
const selectedFiles = ref<{ id: string; name: string; type: string; size: number }[]>([])
async function sendMessage() {
if (!inputMessage.value.trim() || isSending.value) return
isSending.value = true
isTyping.value = true
const text = inputMessage.value.trim()
inputMessage.value = ''
store.addMessage({
id: `temp-${Date.now()}`,
role: 'user',
content: text,
created_at: new Date().toISOString(),
})
await nextTick()
scrollToBottom()
try {
const response = await conversationApi.chat(text, store.currentConversationId || undefined, selectedFiles.value.map(f => f.id))
selectedFiles.value = []
isTyping.value = false
store.addMessage({
id: response.data.message_id,
role: 'assistant',
content: response.data.content,
model: response.data.agent_name,
created_at: new Date().toISOString(),
})
if (!store.currentConversationId) {
store.setCurrentConversation(response.data.conversation_id)
await loadConversations()
}
} catch (e) {
isTyping.value = false
console.error('发送失败:', e)
store.addMessage({
id: `err-${Date.now()}`,
role: 'assistant',
content: '抱歉,连接失败。请检查服务状态。',
created_at: new Date().toISOString(),
})
}
isSending.value = false
await nextTick()
scrollToBottom()
}
async function loadConversations() {
try {
const response = await conversationApi.list()
store.setConversations(response.data)
} catch (e) {
console.error('加载对话列表失败:', e)
}
}
async function selectConversation(id: string) {
store.setCurrentConversation(id)
store.setMessages([])
try {
const response = await conversationApi.getMessages(id)
store.setMessages(response.data)
await nextTick()
scrollToBottom()
} catch (e) {
console.error('加载消息失败:', e)
}
}
async function newConversation() {
store.setCurrentConversation('')
store.setMessages([])
inputRef.value?.focus()
}
async function deleteConversation(id: string, e: Event) {
e.stopPropagation()
try {
await conversationApi.delete(id)
store.removeConversation(id)
if (store.currentConversationId === id) {
store.setCurrentConversation('')
store.setMessages([])
}
} catch (e) {
console.error('删除失败:', e)
}
}
function scrollToBottom() {
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
}
}
function formatTime(dateStr: string) {
const d = new Date(dateStr)
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
function formatConvDate(dateStr: string) {
const d = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - d.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000) return '今天'
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
}
function autoResize(e: Event) {
const el = e.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
if (!input.files?.length) return
for (const file of input.files) {
if (file.size > 10 * 1024 * 1024) {
alert(`文件 ${file.name} 超过10MB限制`)
continue
}
try {
const response = await documentApi.upload(file)
selectedFiles.value.push({
id: response.data.id,
name: file.name,
type: file.type,
size: file.size,
})
} catch (e) {
console.error('上传失败:', e)
alert(`文件 ${file.name} 上传失败`)
}
}
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
function insertEmoji(emoji: string) {
inputMessage.value += emoji
showEmojiPicker.value = false
}
function openFilePicker() {
fileInputRef.value?.click()
}
onMounted(() => {
loadConversations()
inputRef.value?.focus()
})
const {
store,
inputMessage,
isSending,
chatContainer,
inputRef,
isTyping,
fileInputRef,
showEmojiPicker,
sendMessage,
selectConversation,
newConversation,
deleteConversation,
formatTime,
formatConvDate,
autoResize,
handleFileSelect,
insertEmoji,
openFilePicker,
} = useChatView()
</script>
<template>
@@ -272,7 +121,9 @@ onMounted(() => {
<FileMessage
v-for="att in msg.attachments"
:key="att.id"
:file="att"
:filename="att.name"
:file-type="att.type"
:file-size="att.size"
/>
</div>
</div>
@@ -818,7 +669,9 @@ onMounted(() => {
line-height: 1.6;
resize: none;
max-height: 120px;
padding: 0;
padding: 8px 0;
vertical-align: middle;
overflow: hidden;
}
.input-frame textarea::placeholder { color: var(--text-dim); }

View File

@@ -39,13 +39,13 @@ function formatDate(dateStr: string) {
return d.toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
function getCategoryLabel(cat: string) {
function getCategoryLabel(cat?: string) {
const map: Record<string, { label: string; color: string }> = {
discussion: { label: 'DISCUSSION', color: 'var(--accent-cyan)' },
instruction: { label: 'INSTRUCTION', color: 'var(--accent-amber)' },
question: { label: 'QUESTION', color: 'var(--accent-green)' },
}
return map[cat] || map.discussion
return map[cat ?? 'discussion'] || map.discussion
}
onMounted(() => { loadPosts() })

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { graphApi } from '@/api/graph'
import { Network, RefreshCw, Info, Maximize2, Hexagon } from 'lucide-vue-next'
import { Network, RefreshCw, Info, Hexagon } from 'lucide-vue-next'
import type { KGNode, KGEdge } from '@/api/graph'
const nodes = ref<KGNode[]>([])

View File

@@ -0,0 +1,344 @@
import { computed, onMounted, ref } from 'vue'
import { documentApi, type Document } from '@/api/document'
import { folderApi, type FolderTree } from '@/api/folder'
export function useKnowledgeView() {
const folders = ref<FolderTree[]>([])
const documents = ref<Document[]>([])
const currentFolderId = ref<string | null>(null)
const isUploading = ref(false)
const isLoadingDocuments = ref(false)
const uploadError = ref('')
const uploadInput = ref<HTMLInputElement | null>(null)
const showNewFolderDialog = ref(false)
const newFolderName = ref('')
const newFolderParentId = ref<string | null>(null)
const showRenameDialog = ref(false)
const renameFolderName = ref('')
const renamingFolder = ref<FolderTree | null>(null)
const showDeleteDialog = ref(false)
const deletingFolder = ref<FolderTree | null>(null)
const showDocumentDialog = ref(false)
const activeDocument = ref<Document | null>(null)
const activeDocumentContent = ref('')
const isLoadingDocumentContent = ref(false)
const folderMap = computed(() => {
const map = new Map<string, FolderTree>()
function walk(nodes: FolderTree[]) {
for (const node of nodes) {
map.set(node.id, node)
if (node.children?.length) {
walk(node.children)
}
}
}
walk(folders.value)
return map
})
const currentFolder = computed(() => {
if (!currentFolderId.value) return null
return folderMap.value.get(currentFolderId.value) ?? null
})
const isRoot = computed(() => currentFolderId.value === null)
const visibleFolders = computed(() => {
if (isRoot.value) return folders.value
return currentFolder.value?.children ?? []
})
const breadcrumbs = computed(() => {
const items: Array<{ id: string | null; name: string }> = [{ id: null, name: '根目录' }]
if (!currentFolder.value) {
return items
}
const chain: FolderTree[] = []
let cursor: FolderTree | null = currentFolder.value
while (cursor) {
chain.unshift(cursor)
cursor = cursor.parent_id ? folderMap.value.get(cursor.parent_id) ?? null : null
}
for (const folder of chain) {
items.push({ id: folder.id, name: folder.name })
}
return items
})
const explorerTitle = computed(() => {
if (isRoot.value) {
return `${visibleFolders.value.length} 个文件夹`
}
return `${visibleFolders.value.length} 个文件夹 · ${documents.value.length} 个文件`
})
async function loadFolders() {
try {
const response = await folderApi.getTree()
folders.value = response.data
} catch (error) {
console.error('加载文件夹失败:', error)
}
}
async function loadDocumentsByFolder(folderId: string | null) {
if (!folderId) {
documents.value = []
return
}
isLoadingDocuments.value = true
try {
const response = await documentApi.list(folderId)
documents.value = response.data
} catch (error) {
console.error('加载文档失败:', error)
} finally {
isLoadingDocuments.value = false
}
}
async function enterFolder(folder: FolderTree) {
currentFolderId.value = folder.id
await loadDocumentsByFolder(folder.id)
}
async function goToFolder(folderId: string | null) {
currentFolderId.value = folderId
await loadDocumentsByFolder(folderId)
}
async function goBack() {
if (!currentFolder.value) return
await goToFolder(currentFolder.value.parent_id)
}
function triggerUpload() {
if (isRoot.value) return
uploadInput.value?.click()
}
async function handleUpload(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
if (!currentFolderId.value) {
uploadError.value = '请先进入目标文件夹后再上传文件'
window.setTimeout(() => {
uploadError.value = ''
}, 3000)
target.value = ''
return
}
isUploading.value = true
try {
await documentApi.upload(file, currentFolderId.value)
await loadDocumentsByFolder(currentFolderId.value)
} catch (error) {
console.error('上传失败:', error)
} finally {
isUploading.value = false
target.value = ''
}
}
async function handleDeleteDocument(id: string) {
try {
await documentApi.delete(id)
documents.value = documents.value.filter((doc) => doc.id !== id)
if (activeDocument.value?.id === id) {
closeDocumentDialog()
}
} catch (error) {
console.error('删除失败:', error)
}
}
function openNewFolderDialog(parentId: string | null = null) {
newFolderParentId.value = parentId
newFolderName.value = ''
showNewFolderDialog.value = true
}
async function createFolder() {
if (!newFolderName.value.trim()) return
try {
await folderApi.create({
name: newFolderName.value.trim(),
parent_id: newFolderParentId.value,
})
await loadFolders()
showNewFolderDialog.value = false
} catch (error) {
console.error('创建文件夹失败:', error)
}
}
function openRenameDialog(folder: FolderTree) {
renamingFolder.value = folder
renameFolderName.value = folder.name
showRenameDialog.value = true
}
async function renameFolder() {
if (!renamingFolder.value || !renameFolderName.value.trim()) return
try {
await folderApi.rename(renamingFolder.value.id, { name: renameFolderName.value.trim() })
await loadFolders()
showRenameDialog.value = false
renamingFolder.value = null
} catch (error) {
console.error('重命名文件夹失败:', error)
}
}
function openDeleteDialog(folder: FolderTree) {
deletingFolder.value = folder
showDeleteDialog.value = true
}
function isFolderInTree(nodes: FolderTree[], targetId: string) {
for (const node of nodes) {
if (node.id === targetId) return true
if (node.children?.length && isFolderInTree(node.children, targetId)) return true
}
return false
}
async function deleteFolder() {
if (!deletingFolder.value) return
const deletingId = deletingFolder.value.id
const fallbackParentId = deletingFolder.value.parent_id
try {
await folderApi.delete(deletingId)
await loadFolders()
if (currentFolderId.value && !isFolderInTree(folders.value, currentFolderId.value)) {
currentFolderId.value = fallbackParentId
}
await loadDocumentsByFolder(currentFolderId.value)
showDeleteDialog.value = false
deletingFolder.value = null
} catch (error) {
console.error('删除文件夹失败:', error)
}
}
async function openDocument(doc: Document) {
activeDocument.value = doc
activeDocumentContent.value = ''
showDocumentDialog.value = true
isLoadingDocumentContent.value = true
try {
const response = await documentApi.getContent(doc.id)
const content = response.data as string | { content?: string }
activeDocumentContent.value = typeof content === 'string' ? content : content.content ?? ''
} catch (error) {
console.error('加载文档内容失败:', error)
activeDocumentContent.value = '暂时无法加载文档内容。'
} finally {
isLoadingDocumentContent.value = false
}
}
function closeDocumentDialog() {
showDocumentDialog.value = false
activeDocument.value = null
activeDocumentContent.value = ''
}
function getFileTypeColor(type: string) {
const colors: Record<string, string> = {
pdf: '#f87171',
md: '#60a5fa',
txt: '#34d399',
docx: '#a78bfa',
}
return colors[type] || '#9ca3af'
}
function formatFileSize(fileSize: number) {
if (fileSize < 1024) return `${fileSize} B`
if (fileSize < 1024 * 1024) return `${(fileSize / 1024).toFixed(1)} KB`
return `${(fileSize / (1024 * 1024)).toFixed(1)} MB`
}
function formatDate(date: string) {
return new Date(date).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
onMounted(async () => {
await loadFolders()
await loadDocumentsByFolder(null)
})
return {
folders,
documents,
currentFolderId,
isUploading,
isLoadingDocuments,
uploadError,
uploadInput,
showNewFolderDialog,
newFolderName,
newFolderParentId,
showRenameDialog,
renameFolderName,
renamingFolder,
showDeleteDialog,
deletingFolder,
showDocumentDialog,
activeDocument,
activeDocumentContent,
isLoadingDocumentContent,
currentFolder,
isRoot,
visibleFolders,
breadcrumbs,
explorerTitle,
enterFolder,
goToFolder,
goBack,
triggerUpload,
handleUpload,
handleDeleteDocument,
openNewFolderDialog,
createFolder,
openRenameDialog,
renameFolder,
openDeleteDialog,
deleteFolder,
openDocument,
closeDocumentDialog,
getFileTypeColor,
formatFileSize,
formatDate,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,314 @@
import { computed, onMounted, ref } from 'vue'
import { settingsApi, type LLMConfig, type LLMModelConfig, type LLMType, type SchedulerConfig } from '@/api/settings'
type ToastState = {
show: boolean
message: string
type: 'success' | 'error'
}
type EditingSnapshot = {
type: string
index: number
data: LLMModelConfig
}
type ProfileState = {
email: string
full_name: string
created_at: string
}
function cloneLLMConfig(config: LLMConfig): LLMConfig {
return JSON.parse(JSON.stringify(config)) as LLMConfig
}
function cloneSchedulerConfig(config: SchedulerConfig): SchedulerConfig {
return JSON.parse(JSON.stringify(config)) as SchedulerConfig
}
function getErrorMessage(error: unknown, fallback: string) {
return (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail || fallback
}
export function useSettingsView() {
const loading = ref(false)
const saving = ref(false)
const savingModel = ref<string | null>(null)
const toast = ref<ToastState>({
show: false,
message: '',
type: 'success',
})
const expandedRow = ref<string | null>(null)
const editingSnapshot = ref<EditingSnapshot | null>(null)
const profile = ref<ProfileState>({
email: '',
full_name: '',
created_at: '',
})
const originalProfile = ref({ email: '', full_name: '' })
const newPassword = ref('')
const llmConfig = ref<LLMConfig>({
chat: [],
vlm: [],
embedding: [],
rerank: [],
})
const originalLlmConfig = ref<LLMConfig>({
chat: [],
vlm: [],
embedding: [],
rerank: [],
})
const schedulerConfig = ref<SchedulerConfig>({
daily_plan_time: '08:00',
forum_scan_interval_minutes: 30,
todo_ai_generate_time: '08:00',
enabled: true,
})
const originalSchedulerConfig = ref<SchedulerConfig>({})
const showRequiredWarning = computed(() => {
return (llmConfig.value.chat?.length || 0) === 0 ||
(llmConfig.value.embedding?.length || 0) === 0 ||
(llmConfig.value.rerank?.length || 0) === 0
})
const isProfileDirty = computed(() => {
return profile.value.full_name !== originalProfile.value.full_name || newPassword.value !== ''
})
const isSchedulerDirty = computed(() => {
return JSON.stringify(schedulerConfig.value) !== JSON.stringify(originalSchedulerConfig.value)
})
function showToast(message: string, type: 'success' | 'error' = 'success') {
toast.value = { show: true, message, type }
window.setTimeout(() => {
toast.value.show = false
}, 3000)
}
function createEmptyModel(type: string): LLMModelConfig {
return {
name: `${type.toUpperCase()}-${Date.now()}`,
provider: 'openai',
model: type === 'chat'
? 'gpt-4o'
: type === 'vlm'
? 'gpt-4o'
: type === 'embedding'
? 'text-embedding-3-small'
: 'bge-reranker-v2',
base_url: '',
api_key: '',
enabled: true,
}
}
function getRowKey(type: string, index: number): string {
return `${type}-${index}`
}
function addModel(type: string) {
if (!llmConfig.value[type as keyof LLMConfig]) {
llmConfig.value[type as keyof LLMConfig] = []
}
if ((type === 'embedding' || type === 'rerank') &&
llmConfig.value[type as keyof LLMConfig]!.length >= 1) {
showToast(`${type === 'embedding' ? 'Embedding' : 'Rerank'} 最多配置 1 个`, 'error')
return
}
const newModel = createEmptyModel(type)
llmConfig.value[type as keyof LLMConfig]!.push(newModel)
const newIndex = llmConfig.value[type as keyof LLMConfig]!.length - 1
expandedRow.value = getRowKey(type, newIndex)
editingSnapshot.value = { type, index: newIndex, data: JSON.parse(JSON.stringify(newModel)) as LLMModelConfig }
}
async function removeModel(type: string, index: number) {
if ((type === 'embedding' || type === 'rerank') &&
llmConfig.value[type as keyof LLMConfig]!.length <= 1) {
showToast(`${type === 'embedding' ? 'Embedding' : 'Rerank'} 为知识库必填,至少保留 1 个`, 'error')
return
}
llmConfig.value[type as keyof LLMConfig]!.splice(index, 1)
expandedRow.value = null
editingSnapshot.value = null
try {
await settingsApi.updateLLM(llmConfig.value)
originalLlmConfig.value = cloneLLMConfig(llmConfig.value)
showToast('删除成功')
} catch (error: unknown) {
showToast(getErrorMessage(error, '删除失败'), 'error')
}
}
function toggleRow(type: string, index: number, model: LLMModelConfig) {
const key = getRowKey(type, index)
if (expandedRow.value === key) {
expandedRow.value = null
editingSnapshot.value = null
} else {
editingSnapshot.value = { type, index, data: JSON.parse(JSON.stringify(model)) as LLMModelConfig }
expandedRow.value = key
}
}
function updateModel(type: string, index: number, model: LLMModelConfig) {
llmConfig.value[type as keyof LLMConfig]![index] = model
}
async function loadSettings() {
loading.value = true
try {
const response = await settingsApi.get()
profile.value = {
email: response.data.profile.email,
full_name: response.data.profile.full_name || '',
created_at: response.data.profile.created_at,
}
originalProfile.value = { email: profile.value.email, full_name: profile.value.full_name }
if (response.data.llm_config) {
llmConfig.value = {
chat: response.data.llm_config.chat || [],
vlm: response.data.llm_config.vlm || [],
embedding: response.data.llm_config.embedding || [],
rerank: response.data.llm_config.rerank || [],
}
} else {
llmConfig.value = { chat: [], vlm: [], embedding: [], rerank: [] }
}
originalLlmConfig.value = cloneLLMConfig(llmConfig.value)
if (response.data.scheduler_config && Object.keys(response.data.scheduler_config).length > 0) {
schedulerConfig.value = response.data.scheduler_config as SchedulerConfig
}
originalSchedulerConfig.value = cloneSchedulerConfig(schedulerConfig.value)
} catch (error) {
console.error('加载设置失败', error)
showToast('加载设置失败', 'error')
} finally {
loading.value = false
}
}
async function saveProfile() {
saving.value = true
try {
await settingsApi.updateProfile({
full_name: profile.value.full_name,
password: newPassword.value || undefined,
})
originalProfile.value = { email: profile.value.email, full_name: profile.value.full_name }
newPassword.value = ''
showToast('资料保存成功')
} catch (error: unknown) {
showToast(getErrorMessage(error, '保存失败'), 'error')
} finally {
saving.value = false
}
}
async function saveModel(type: string, index: number, model: LLMModelConfig) {
const key = getRowKey(type, index)
llmConfig.value[type as keyof LLMConfig]![index] = JSON.parse(JSON.stringify(model)) as LLMModelConfig
savingModel.value = key
try {
await settingsApi.updateLLM(llmConfig.value)
originalLlmConfig.value = cloneLLMConfig(llmConfig.value)
expandedRow.value = null
editingSnapshot.value = null
showToast('保存成功')
} catch (error: unknown) {
showToast(getErrorMessage(error, '保存失败'), 'error')
} finally {
savingModel.value = null
}
}
async function testModel(type: string, index: number, model: LLMModelConfig) {
try {
const response = await settingsApi.testLLM({
type: type as LLMType,
provider: model.provider,
model: model.model,
base_url: model.base_url,
api_key: model.api_key,
})
if (response.data.success) {
llmConfig.value[type as keyof LLMConfig]![index].enabled = true
showToast('连接成功')
} else {
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
showToast(`连接失败: ${response.data.error}`, 'error')
}
} catch (error) {
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
showToast('测试连接失败', 'error')
}
}
async function saveScheduler() {
saving.value = true
try {
await settingsApi.updateScheduler(schedulerConfig.value)
originalSchedulerConfig.value = cloneSchedulerConfig(schedulerConfig.value)
showToast('定时任务配置保存成功')
} catch (error: unknown) {
showToast(getErrorMessage(error, '保存失败'), 'error')
} finally {
saving.value = false
}
}
function resetProfile() {
profile.value.full_name = originalProfile.value.full_name
newPassword.value = ''
}
function resetScheduler() {
schedulerConfig.value = cloneSchedulerConfig(originalSchedulerConfig.value)
}
onMounted(loadSettings)
return {
loading,
saving,
savingModel,
toast,
expandedRow,
editingSnapshot,
showRequiredWarning,
profile,
newPassword,
llmConfig,
schedulerConfig,
isProfileDirty,
isSchedulerDirty,
addModel,
removeModel,
getRowKey,
toggleRow,
updateModel,
saveProfile,
saveModel,
testModel,
saveScheduler,
resetProfile,
resetScheduler,
}
}

View File

@@ -1,321 +1,32 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { settingsApi, type LLMConfig, type SchedulerConfig, type LLMModelConfig, type LLMProvider } from '@/api/settings'
import LLMTableRow from '@/components/settings/LLMTableRow.vue'
import { Save, RotateCcw, Plus } from 'lucide-vue-next'
import { useSettingsView } from '@/pages/settings/composables/useSettingsView'
//
const loading = ref(false)
const saving = ref(false)
const savingModel = ref<string | null>(null) // key
const toast = ref<{ show: boolean; message: string; type: 'success' | 'error' }>({
show: false,
message: '',
type: 'success'
})
//
const expandedRow = ref<string | null>(null) // 'chat-0', 'vlm-0'
//
const editingSnapshot = ref<{ type: string; index: number; data: LLMModelConfig } | null>(null)
//
const showRequiredWarning = computed(() => {
return llmConfig.value.chat.length === 0 ||
llmConfig.value.embedding.length === 0 ||
llmConfig.value.rerank.length === 0
})
//
const profile = ref({
email: '',
full_name: '',
created_at: ''
})
const originalProfile = ref({ email: '', full_name: '' })
const newPassword = ref('')
// LLM -
const llmConfig = ref<LLMConfig>({
chat: [],
vlm: [],
embedding: [],
rerank: []
})
const originalLlmConfig = ref<LLMConfig>({
chat: [],
vlm: [],
embedding: [],
rerank: []
})
//
const schedulerConfig = ref<SchedulerConfig>({
daily_plan_time: '08:00',
forum_scan_interval_minutes: 30,
todo_ai_generate_time: '08:00',
enabled: true
})
const originalSchedulerConfig = ref<SchedulerConfig>({})
//
const isProfileDirty = computed(() => {
return profile.value.full_name !== originalProfile.value.full_name || newPassword.value !== ''
})
const isLlmDirty = computed(() => {
return JSON.stringify(llmConfig.value) !== JSON.stringify(originalLlmConfig.value)
})
const isSchedulerDirty = computed(() => {
return JSON.stringify(schedulerConfig.value) !== JSON.stringify(originalSchedulerConfig.value)
})
//
function isModelDirty(type: string, index: number): boolean {
const original = originalLlmConfig.value[type as keyof LLMConfig]?.[index]
const current = llmConfig.value[type as keyof LLMConfig]?.[index]
if (!original || !current) return false
return JSON.stringify(original) !== JSON.stringify(current)
}
//
function createEmptyModel(type: string): LLMModelConfig {
return {
name: `${type.toUpperCase()}-${Date.now()}`,
provider: 'openai',
model: type === 'chat' ? 'gpt-4o' : type === 'vlm' ? 'gpt-4o' : type === 'embedding' ? 'text-embedding-3-small' : 'bge-reranker-v2',
base_url: '',
api_key: '',
enabled: true
}
}
//
function addModel(type: string) {
if (!llmConfig.value[type as keyof LLMConfig]) {
llmConfig.value[type as keyof LLMConfig] = []
}
// embedding/rerank 1
if ((type === 'embedding' || type === 'rerank') &&
llmConfig.value[type as keyof LLMConfig]!.length >= 1) {
showToast(`${type === 'embedding' ? 'Embedding' : 'Rerank'} 最多配置 1 个`, 'error')
return
}
const newModel = createEmptyModel(type)
llmConfig.value[type as keyof LLMConfig]!.push(newModel)
//
const newIndex = llmConfig.value[type as keyof LLMConfig]!.length - 1
expandedRow.value = getRowKey(type, newIndex)
editingSnapshot.value = { type, index: newIndex, data: JSON.parse(JSON.stringify(newModel)) }
}
//
async function removeModel(type: string, index: number) {
// embedding/rerank 1
if ((type === 'embedding' || type === 'rerank') &&
llmConfig.value[type as keyof LLMConfig]!.length <= 1) {
showToast(`${type === 'embedding' ? 'Embedding' : 'Rerank'} 为知识库必填,至少保留 1 个`, 'error')
return
}
llmConfig.value[type as keyof LLMConfig]!.splice(index, 1)
expandedRow.value = null
editingSnapshot.value = null
//
try {
await settingsApi.updateLLM(llmConfig.value)
originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value))
showToast('删除成功')
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '删除失败'
showToast(msg, 'error')
}
}
//
function getRowKey(type: string, index: number): string {
return `${type}-${index}`
}
//
function toggleRow(type: string, index: number, model: LLMModelConfig) {
const key = getRowKey(type, index)
if (expandedRow.value === key) {
expandedRow.value = null
editingSnapshot.value = null
} else {
//
editingSnapshot.value = { type, index, data: JSON.parse(JSON.stringify(model)) }
expandedRow.value = key
}
}
//
function cancelEdit(type: string, index: number) {
if (editingSnapshot.value && editingSnapshot.value.type === type && editingSnapshot.value.index === index) {
//
llmConfig.value[type as keyof LLMConfig]![index] = editingSnapshot.value.data
}
expandedRow.value = null
editingSnapshot.value = null
}
//
function updateModel(type: string, index: number, model: LLMModelConfig) {
llmConfig.value[type as keyof LLMConfig]![index] = model
}
//
async function loadSettings() {
loading.value = true
try {
const res = await settingsApi.get()
profile.value = {
email: res.data.profile.email,
full_name: res.data.profile.full_name || '',
created_at: res.data.profile.created_at
}
originalProfile.value = { ...profile.value }
// LLM
if (res.data.llm_config) {
llmConfig.value = {
chat: res.data.llm_config.chat || [],
vlm: res.data.llm_config.vlm || [],
embedding: res.data.llm_config.embedding || [],
rerank: res.data.llm_config.rerank || []
}
} else {
llmConfig.value = { chat: [], vlm: [], embedding: [], rerank: [] }
}
originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value))
if (res.data.scheduler_config && Object.keys(res.data.scheduler_config).length > 0) {
schedulerConfig.value = res.data.scheduler_config as SchedulerConfig
}
originalSchedulerConfig.value = JSON.parse(JSON.stringify(schedulerConfig.value))
} catch (e) {
console.error('加载设置失败', e)
showToast('加载设置失败', 'error')
} finally {
loading.value = false
}
}
//
async function saveProfile() {
saving.value = true
try {
await settingsApi.updateProfile({
full_name: profile.value.full_name,
password: newPassword.value || undefined
})
originalProfile.value = { email: profile.value.email, full_name: profile.value.full_name }
newPassword.value = ''
showToast('资料保存成功')
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败'
showToast(msg, 'error')
} finally {
saving.value = false
}
}
// LLM
async function saveLLM() {
saving.value = true
try {
await settingsApi.updateLLM(llmConfig.value)
originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value))
showToast('LLM 配置保存成功')
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败'
showToast(msg, 'error')
} finally {
saving.value = false
}
}
//
async function saveModel(type: string, index: number) {
const key = getRowKey(type, index)
savingModel.value = key
try {
//
await settingsApi.updateLLM(llmConfig.value)
//
originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value))
//
expandedRow.value = null
editingSnapshot.value = null
showToast('保存成功')
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败'
showToast(msg, 'error')
} finally {
savingModel.value = null
}
}
// LLM
async function testModel(type: string, index: number, model: LLMModelConfig) {
try {
const res = await settingsApi.testLLM({ type: type as any, ...model })
if (res.data.success) {
//
llmConfig.value[type as keyof LLMConfig]![index].enabled = true
showToast('连接成功')
} else {
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
showToast(`连接失败: ${res.data.error}`, 'error')
}
} catch (e) {
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
showToast('测试连接失败', 'error')
}
}
//
async function saveScheduler() {
saving.value = true
try {
await settingsApi.updateScheduler(schedulerConfig.value)
originalSchedulerConfig.value = JSON.parse(JSON.stringify(schedulerConfig.value))
showToast('定时任务配置保存成功')
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败'
showToast(msg, 'error')
} finally {
saving.value = false
}
}
//
function resetProfile() {
profile.value.full_name = originalProfile.value.full_name
newPassword.value = ''
}
function resetLLM() {
llmConfig.value = JSON.parse(JSON.stringify(originalLlmConfig.value))
}
function resetScheduler() {
schedulerConfig.value = JSON.parse(JSON.stringify(originalSchedulerConfig.value))
}
// Toast
function showToast(message: string, type: 'success' | 'error' = 'success') {
toast.value = { show: true, message, type }
setTimeout(() => {
toast.value.show = false
}, 3000)
}
onMounted(loadSettings)
const {
loading,
saving,
toast,
expandedRow,
showRequiredWarning,
profile,
newPassword,
llmConfig,
schedulerConfig,
isProfileDirty,
isSchedulerDirty,
addModel,
removeModel,
getRowKey,
toggleRow,
updateModel,
saveProfile,
saveModel,
testModel,
saveScheduler,
resetProfile,
resetScheduler,
} = useSettingsView()
</script>
<template>
@@ -403,7 +114,7 @@ onMounted(loadSettings)
@update="(m) => updateModel('chat', index, m)"
@delete="removeModel('chat', index)"
@test="(m) => testModel('chat', index, m)"
@save="(m) => saveModel('chat', index)"
@save="(m) => saveModel('chat', index, m)"
/>
</div>
</div>
@@ -427,7 +138,7 @@ onMounted(loadSettings)
@update="(m) => updateModel('vlm', index, m)"
@delete="removeModel('vlm', index)"
@test="(m) => testModel('vlm', index, m)"
@save="(m) => saveModel('vlm', index)"
@save="(m) => saveModel('vlm', index, m)"
/>
</div>
</div>
@@ -451,7 +162,7 @@ onMounted(loadSettings)
@update="(m) => updateModel('embedding', index, m)"
@delete="removeModel('embedding', index)"
@test="(m) => testModel('embedding', index, m)"
@save="(m) => saveModel('embedding', index)"
@save="(m) => saveModel('embedding', index, m)"
/>
</div>
</div>
@@ -475,7 +186,7 @@ onMounted(loadSettings)
@update="(m) => updateModel('rerank', index, m)"
@delete="removeModel('rerank', index)"
@test="(m) => testModel('rerank', index, m)"
@save="(m) => saveModel('rerank', index)"
@save="(m) => saveModel('rerank', index, m)"
/>
</div>
</div>

View File

@@ -1,5 +1,27 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
function animateIn(el: Element, done: () => void) {
const target = el as HTMLElement
target.animate(
[
{ opacity: '0', transform: 'translateY(12px)' },
{ opacity: '1', transform: 'translateY(0)' },
],
{ duration: 220, easing: 'ease-out', fill: 'forwards' },
).finished.finally(done)
}
function animateOut(el: Element, done: () => void) {
const target = el as HTMLElement
target.animate(
[
{ opacity: '1', transform: 'translateY(0)' },
{ opacity: '0', transform: 'translateY(12px)' },
],
{ duration: 180, easing: 'ease-in', fill: 'forwards' },
).finished.finally(done)
}
import { skillApi, type Skill, type SkillCreate } from '@/api/skill'
import { Bot, Plus, Edit2, Trash2, Eye, EyeOff, Copy, X } from 'lucide-vue-next'

View File

@@ -2,12 +2,16 @@
import { ref, onMounted, computed } from 'vue'
import * as statsApi from '@/api/stats'
import { Cpu, HardDrive, MemoryStick, Clock, TrendingUp, Tag } from 'lucide-vue-next'
const reloadPage = () => globalThis.location.reload()
import SectionHeader from '@/components/stats/SectionHeader.vue'
import MetricCard from '@/components/stats/MetricCard.vue'
import SummaryRow from '@/components/stats/SummaryRow.vue'
import MiniLineChart from '@/components/stats/MiniLineChart.vue'
import MiniBarChart from '@/components/stats/MiniBarChart.vue'
type DailyPoint = { date: string; count: number }
type HourlyPoint = { hour: number; count: number }
const isLoading = ref(true)
const hasError = ref(false)
@@ -63,27 +67,31 @@ onMounted(async () => {
//
const convChartData = computed(() =>
conversationStats.value?.daily_conversations?.map((d: any) => ({ date: d.date, value: d.count })) || []
conversationStats.value?.daily_conversations?.map((d: DailyPoint) => ({ date: d.date, value: d.count })) || []
)
const knowChartData = computed(() =>
knowledgeStats.value?.daily_new_tags?.map((d: any) => ({ date: d.date, value: d.count })) || []
knowledgeStats.value?.daily_new_tags?.map((d: DailyPoint) => ({ date: d.date, value: d.count })) || []
)
const kanbanNewData = computed(() =>
kanbanStats.value?.daily_new_tasks?.map((d: any) => d.count) || []
kanbanStats.value?.daily_new_tasks?.map((d: DailyPoint) => d.count) || []
)
const kanbanDoneData = computed(() =>
kanbanStats.value?.daily_completed_tasks?.map((d: any) => d.count) || []
kanbanStats.value?.daily_completed_tasks?.map((d: DailyPoint) => d.count) || []
)
const communityChartData = computed(() =>
communityStats.value?.daily_posts?.map((d: any) => ({ date: d.date, value: d.count })) || []
communityStats.value?.daily_posts?.map((d: DailyPoint) => ({ date: d.date, value: d.count })) || []
)
const hourlyActivityData = computed(() =>
personalInsights.value?.hourly_activity?.map((h: any) => h.count) || []
personalInsights.value?.hourly_activity?.map((h: HourlyPoint) => h.count) || []
)
const convBarValues = computed(() => convChartData.value.map((d: { date: string; value: number }) => d.value))
const knowBarValues = computed(() => knowChartData.value.map((d: { date: string; value: number }) => d.value))
const communityBarValues = computed(() => communityChartData.value.map((d: { date: string; value: number }) => d.value))
</script>
<template>
@@ -99,7 +107,7 @@ const hourlyActivityData = computed(() =>
<div v-else-if="hasError" class="error-state">
<span>Failed to load stats</span>
<button @click="() => window.location.reload()">Refresh</button>
<button @click="reloadPage">Refresh</button>
</div>
<div v-else class="stats-content">
@@ -142,14 +150,14 @@ const hourlyActivityData = computed(() =>
<div class="stat-bar-label">对话数</div>
<div class="stat-bar-value">{{ formatNumber(conversationStats?.totals?.conversations || 0) }}</div>
<div class="stat-bar-chart">
<MiniBarChart v-if="convChartData.length > 0" :data="convChartData.map(d => d.value)" color="var(--accent-cyan)" :height="30" />
<MiniBarChart v-if="convChartData.length > 0" :data="convBarValues" color="var(--accent-cyan)" :height="30" />
</div>
</div>
<div class="stat-bar-item">
<div class="stat-bar-label">消息数</div>
<div class="stat-bar-value">{{ formatNumber(conversationStats?.totals?.messages || 0) }}</div>
<div class="stat-bar-chart">
<MiniBarChart v-if="convChartData.length > 0" :data="convChartData.map(d => d.value)" color="var(--accent-purple)" :height="30" />
<MiniBarChart v-if="convChartData.length > 0" :data="convBarValues" color="var(--accent-purple)" :height="30" />
</div>
</div>
<div class="stat-bar-item">
@@ -177,14 +185,14 @@ const hourlyActivityData = computed(() =>
<div class="stat-bar-label">新标签</div>
<div class="stat-bar-value">{{ formatNumber(knowledgeStats?.totals?.new_tags || 0) }}</div>
<div class="stat-bar-chart">
<MiniBarChart v-if="knowChartData.length > 0" :data="knowChartData.map(d => d.value)" color="var(--accent-purple)" :height="30" />
<MiniBarChart v-if="knowChartData.length > 0" :data="knowBarValues" color="var(--accent-purple)" :height="30" />
</div>
</div>
<div class="stat-bar-item">
<div class="stat-bar-label">文档数</div>
<div class="stat-bar-value">{{ formatNumber(knowledgeStats?.totals?.documents || 0) }}</div>
<div class="stat-bar-chart">
<MiniBarChart v-if="knowChartData.length > 0" :data="knowChartData.map(d => d.value)" color="var(--accent-cyan)" :height="30" />
<MiniBarChart v-if="knowChartData.length > 0" :data="knowBarValues" color="var(--accent-cyan)" :height="30" />
</div>
</div>
<div class="stat-bar-item">
@@ -233,14 +241,14 @@ const hourlyActivityData = computed(() =>
<div class="stat-bar-label">帖子数</div>
<div class="stat-bar-value">{{ formatNumber(communityStats?.totals?.posts || 0) }}</div>
<div class="stat-bar-chart">
<MiniBarChart v-if="communityChartData.length > 0" :data="communityChartData.map(d => d.value)" color="var(--accent-amber)" :height="30" />
<MiniBarChart v-if="communityChartData.length > 0" :data="communityBarValues" color="var(--accent-amber)" :height="30" />
</div>
</div>
<div class="stat-bar-item">
<div class="stat-bar-label">回复数</div>
<div class="stat-bar-value">{{ formatNumber(communityStats?.totals?.replies || 0) }}</div>
<div class="stat-bar-chart">
<MiniBarChart v-if="communityChartData.length > 0" :data="communityChartData.map(d => d.value)" color="var(--accent-purple)" :height="30" />
<MiniBarChart v-if="communityChartData.length > 0" :data="communityBarValues" color="var(--accent-purple)" :height="30" />
</div>
</div>
<div class="stat-bar-item">

View File

@@ -1,83 +1,10 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { Terminal } from 'lucide-vue-next'
import { routes } from '@/app/router/routes'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { guest: true },
},
{
path: '/',
component: () => import('@/views/LayoutView.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
redirect: '/chat',
},
{
path: 'chat',
name: 'chat',
component: () => import('@/views/ChatView.vue'),
},
{
path: 'knowledge',
name: 'knowledge',
component: () => import('@/views/KnowledgeView.vue'),
},
{
path: 'graph',
name: 'graph',
component: () => import('@/views/GraphView.vue'),
},
{
path: 'kanban',
name: 'kanban',
component: () => import('@/views/KanbanView.vue'),
},
{
path: 'forum',
name: 'forum',
component: () => import('@/views/ForumView.vue'),
},
{
path: 'agents',
name: 'agents',
component: () => import('@/views/AgentView.vue'),
},
{
path: 'stats',
name: 'stats',
component: () => import('@/views/StatsView.vue'),
},
{
path: 'skills',
name: 'skills',
component: () => import('@/views/SkillView.vue'),
},
{
path: 'todo',
name: 'todo',
component: () => import('@/views/TodoView.vue'),
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/SettingsView.vue'),
},
{
path: 'logs',
name: 'logs',
component: () => import('@/views/LogView.vue'),
},
],
},
],
routes,
})
router.beforeEach((to, _from, next) => {

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
target: 'http://localhost:9527',
changeOrigin: true,
},
},