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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
34
frontend/src/app/navigation/nav.ts
Normal file
34
frontend/src/app/navigation/nav.ts
Normal 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 },
|
||||
]
|
||||
21
frontend/src/app/router/index.ts
Normal file
21
frontend/src/app/router/index.ts
Normal 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
|
||||
80
frontend/src/app/router/routes.ts
Normal file
80
frontend/src/app/router/routes.ts
Normal 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 }
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
210
frontend/src/pages/chat/composables/useChatView.ts
Normal file
210
frontend/src/pages/chat/composables/useChatView.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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() })
|
||||
@@ -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[]>([])
|
||||
344
frontend/src/pages/knowledge/composables/useKnowledgeView.ts
Normal file
344
frontend/src/pages/knowledge/composables/useKnowledgeView.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
1336
frontend/src/pages/knowledge/index.vue
Normal file
1336
frontend/src/pages/knowledge/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
314
frontend/src/pages/settings/composables/useSettingsView.ts
Normal file
314
frontend/src/pages/settings/composables/useSettingsView.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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">
|
||||
@@ -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
@@ -12,7 +12,7 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
target: 'http://localhost:9527',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user