feat(frontend): 搭建桌面端基础界面与 Workspace
- App Shell 壳层:主导航、次导航、Title Bar、状态栏 - Vault 入口页:最近 Vault、打开/创建 Vault、AI Core 状态 - Workspace 与文件树:浏览、打开、创建笔记/文件夹 - Design Token:浅色/深色主题 CSS Variables - Contracts / Service / Store / Router 基础架构 - 统一 ApiClient 与 SseClient,对接后端 API 契约
This commit is contained in:
+12
-89
@@ -1,95 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getServiceStatus, type ServiceStatus } from './api'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppShell from '@/components/common/AppShell.vue'
|
||||
|
||||
const service = ref<ServiceStatus | null>(null)
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function checkBackend() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
service.value = await getServiceStatus()
|
||||
} catch (reason) {
|
||||
service.value = null
|
||||
error.value = reason instanceof Error ? reason.message : '未知错误'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(checkBackend)
|
||||
const route = useRoute()
|
||||
const isVaultEntry = computed(() => route.path === '/')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">N</span>
|
||||
<span>Notes Agent</span>
|
||||
</div>
|
||||
|
||||
<nav aria-label="主导航">
|
||||
<button class="nav-item active" type="button">工作台</button>
|
||||
<button class="nav-item" type="button" disabled>笔记</button>
|
||||
<button class="nav-item" type="button" disabled>AI 助手</button>
|
||||
<button class="nav-item" type="button" disabled>设置</button>
|
||||
</nav>
|
||||
|
||||
<p class="sidebar-hint">Vue 3 + TypeScript</p>
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
<header>
|
||||
<p class="eyebrow">LOCAL-FIRST AI NOTES</p>
|
||||
<h1>项目基础壳子</h1>
|
||||
<p class="subtitle">前端界面已经就绪,并通过统一 API Client 检查 FastAPI 服务。</p>
|
||||
</header>
|
||||
|
||||
<div class="cards">
|
||||
<article class="card hero-card">
|
||||
<div>
|
||||
<p class="card-label">AI Core</p>
|
||||
<h2>后端连接状态</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="service" class="status-line success">
|
||||
<span class="status-dot" />
|
||||
<div>
|
||||
<strong>服务正常</strong>
|
||||
<p>{{ service.name }} · v{{ service.version }} · {{ service.environment }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="error" class="status-line error">
|
||||
<span class="status-dot" />
|
||||
<div>
|
||||
<strong>暂未连接</strong>
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="status-line">
|
||||
<span class="status-dot" />
|
||||
<p>正在检查服务…</p>
|
||||
</div>
|
||||
|
||||
<button class="primary-button" type="button" :disabled="loading" @click="checkBackend">
|
||||
{{ loading ? '检查中…' : '重新检查' }}
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<p class="card-label">FRONTEND</p>
|
||||
<h2>Vue 3 + TypeScript</h2>
|
||||
<p>使用 Vite 启动,开发环境已配置后端代理。</p>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<p class="card-label">BACKEND</p>
|
||||
<h2>FastAPI + Pydantic</h2>
|
||||
<p>包含健康检查、状态接口和自动 OpenAPI 文档。</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<AppShell v-if="!isVaultEntry">
|
||||
<router-view />
|
||||
</AppShell>
|
||||
<router-view v-else />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import PrimarySidebar from './PrimarySidebar.vue'
|
||||
import SecondarySidebar from './SecondarySidebar.vue'
|
||||
import StatusBar from './StatusBar.vue'
|
||||
import TitleBar from './TitleBar.vue'
|
||||
|
||||
defineProps<{
|
||||
showSecondarySidebar?: boolean
|
||||
}>()
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const themeStore = useThemeStore()
|
||||
const editorStore = useEditorStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const agentStore = useAgentStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const routeName = computed(() => route.name as string)
|
||||
|
||||
const secondaryComponent = computed(() => {
|
||||
switch (routeName) {
|
||||
case 'workspace': return 'file-tree'
|
||||
case 'search': return 'search-filters'
|
||||
case 'chat': return 'conversation-list'
|
||||
case 'agent': return 'run-list'
|
||||
case 'tasks': return 'task-filters'
|
||||
case 'skills':
|
||||
case 'plugins': return 'extension-list'
|
||||
default: return null
|
||||
}
|
||||
})
|
||||
|
||||
function openCitation(noteId: string, blockId: string, filePath: string) {
|
||||
workspaceStore.openFile(filePath)
|
||||
editorStore.highlightBlock(blockId)
|
||||
router.push('/workspace')
|
||||
}
|
||||
|
||||
defineExpose({ openCitation })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell" :class="{ 'theme-dark': themeStore.isDark }">
|
||||
<TitleBar />
|
||||
<div class="app-body">
|
||||
<PrimarySidebar />
|
||||
<SecondarySidebar v-if="secondaryComponent" :component="secondaryComponent" />
|
||||
<main class="main-content">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
<StatusBar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background: var(--color-background-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.app-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: var(--color-background-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const navItems = [
|
||||
{ name: 'workspace', icon: '📁', label: '工作区' },
|
||||
{ name: 'search', icon: '🔍', label: '搜索' },
|
||||
{ name: 'chat', icon: '💬', label: 'AI 对话' },
|
||||
{ name: 'agent', icon: '🤖', label: 'Agent' },
|
||||
{ name: 'tasks', icon: '✅', label: '任务' },
|
||||
{ name: 'skills', icon: '⚡', label: 'Skill' },
|
||||
{ name: 'plugins', icon: '🧩', label: 'Plugin' },
|
||||
{ name: 'themes', icon: '🎨', label: '主题' },
|
||||
{ name: 'settings', icon: '⚙️', label: '设置' },
|
||||
]
|
||||
|
||||
const currentName = computed(() => {
|
||||
const name = route.name as string
|
||||
if (name === 'skills' || name === 'plugins') return 'skills'
|
||||
return name
|
||||
})
|
||||
|
||||
function navigate(name: string) {
|
||||
router.push({ name })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="primary-sidebar">
|
||||
<nav class="nav-list">
|
||||
<div
|
||||
v-for="item in navItems"
|
||||
:key="item.name"
|
||||
class="nav-item"
|
||||
:class="{ active: currentName === item.name }"
|
||||
@click="navigate(item.name)"
|
||||
:title="item.label"
|
||||
>
|
||||
<span class="nav-icon">{{ item.icon }}</span>
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="nav-item" @click="navigate('settings')" title="设置">
|
||||
<span class="nav-icon">⚙️</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.primary-sidebar {
|
||||
width: 56px;
|
||||
background: var(--color-background-secondary);
|
||||
border-right: 1px solid var(--color-border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
z-index: var(--z-sidebar);
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
flex: 1;
|
||||
padding: var(--space-sm) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 50px;
|
||||
margin: 0 4px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
transition: all var(--motion-fast);
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent-primary);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 24px;
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
background: var(--color-accent-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: var(--space-sm) 0;
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import FileTreePanel from '@/features/workspace/FileTreePanel.vue'
|
||||
import ConversationListPanel from '@/features/chat/ConversationListPanel.vue'
|
||||
import RunListPanel from '@/features/agent/RunListPanel.vue'
|
||||
import SearchFiltersPanel from '@/features/search/SearchFiltersPanel.vue'
|
||||
import TaskFiltersPanel from '@/features/tasks/TaskFiltersPanel.vue'
|
||||
import ExtensionListPanel from '@/components/common/ExtensionListPanel.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const props = defineProps<{
|
||||
component: string | null
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const routeName = computed(() => route.name as string)
|
||||
|
||||
const sidebarTitle = computed(() => {
|
||||
const titles: Record<string, string> = {
|
||||
'file-tree': '文件',
|
||||
'conversation-list': '对话',
|
||||
'run-list': 'Agent Run',
|
||||
'search-filters': '搜索筛选',
|
||||
'task-filters': '任务筛选',
|
||||
'extension-list': '扩展',
|
||||
}
|
||||
return titles[props.component || ''] || ''
|
||||
})
|
||||
|
||||
const showSkillToggle = computed(() => routeName === 'skills' || routeName === 'plugins')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="secondary-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h3 class="sidebar-title">{{ sidebarTitle }}</h3>
|
||||
<div v-if="showSkillToggle" class="sidebar-tabs">
|
||||
<router-link to="/extensions/skills" class="tab" :class="{ active: routeName === 'skills' }">Skill</router-link>
|
||||
<router-link to="/extensions/plugins" class="tab" :class="{ active: routeName === 'plugins' }">Plugin</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-content">
|
||||
<FileTreePanel v-if="component === 'file-tree'" />
|
||||
<ConversationListPanel v-else-if="component === 'conversation-list'" />
|
||||
<RunListPanel v-else-if="component === 'run-list'" />
|
||||
<SearchFiltersPanel v-else-if="component === 'search-filters'" />
|
||||
<TaskFiltersPanel v-else-if="component === 'task-filters'" />
|
||||
<ExtensionListPanel v-else-if="component === 'extension-list'" />
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.secondary-sidebar {
|
||||
width: var(--sidebar-secondary-width);
|
||||
background: var(--color-background-primary);
|
||||
border-right: 1px solid var(--color-border-default);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.sidebar-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--color-background-secondary);
|
||||
padding: 2px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 4px 8px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all var(--motion-fast);
|
||||
|
||||
&.active {
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useProviderStore } from '@/stores/provider'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const providerStore = useProviderStore()
|
||||
const agentStore = useAgentStore()
|
||||
const route = useRoute()
|
||||
|
||||
const saveStatusText = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
idle: '',
|
||||
dirty: '未保存',
|
||||
saving: '保存中...',
|
||||
saved: '已保存',
|
||||
save_failed: '保存失败',
|
||||
external_changed: '外部已更新',
|
||||
conflict: '存在冲突',
|
||||
}
|
||||
return map[editorStore.saveStatus] || ''
|
||||
})
|
||||
|
||||
const saveStatusColor = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
dirty: 'var(--color-accent-primary)',
|
||||
saving: 'var(--color-info)',
|
||||
saved: 'var(--color-success)',
|
||||
save_failed: 'var(--color-error)',
|
||||
external_changed: 'var(--color-warning)',
|
||||
conflict: 'var(--color-error)',
|
||||
}
|
||||
return map[editorStore.saveStatus] || 'var(--color-text-tertiary)'
|
||||
})
|
||||
|
||||
const indexStatusText = computed(() => {
|
||||
const s = settingsStore.indexStatus.status
|
||||
return s === 'idle' ? '索引就绪' : s === 'indexing' ? `索引中 (${settingsStore.indexStatus.pending_jobs})` : '索引错误'
|
||||
})
|
||||
|
||||
const aiCoreStatusText = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
starting: 'AI Core 启动中',
|
||||
running: 'AI Core 运行中',
|
||||
stopped: 'AI Core 已停止',
|
||||
error: 'AI Core 错误',
|
||||
}
|
||||
return map[settingsStore.aiCoreStatus] || ''
|
||||
})
|
||||
|
||||
const aiCoreColor = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
starting: 'var(--color-warning)',
|
||||
running: 'var(--color-success)',
|
||||
stopped: 'var(--color-text-tertiary)',
|
||||
error: 'var(--color-error)',
|
||||
}
|
||||
return map[settingsStore.aiCoreStatus] || ''
|
||||
})
|
||||
|
||||
const defaultProvider = computed(() => providerStore.defaultProvider)
|
||||
|
||||
const showEditorInfo = computed(() => route.name === 'workspace')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="statusbar">
|
||||
<div class="statusbar-left">
|
||||
<span v-if="showEditorInfo && saveStatusText" class="status-item" :style="{ color: saveStatusColor }">
|
||||
<span class="status-dot" :style="{ background: saveStatusColor }" />
|
||||
{{ saveStatusText }}
|
||||
</span>
|
||||
<span class="status-item" :title="indexStatusText">
|
||||
<span class="status-dot" style="background: var(--color-success)" />
|
||||
索引就绪
|
||||
</span>
|
||||
<span class="status-item" :style="{ color: aiCoreColor }" @click>
|
||||
<span class="status-dot" :style="{ background: aiCoreColor }" />
|
||||
{{ aiCoreStatusText }}
|
||||
</span>
|
||||
<span v-if="agentStore.isRunning" class="status-item agent-status">
|
||||
<span class="spinner" />
|
||||
Agent 运行中
|
||||
</span>
|
||||
</div>
|
||||
<div class="statusbar-right">
|
||||
<span v-if="defaultProvider" class="status-item provider-info">
|
||||
{{ defaultProvider.name }} · {{ defaultProvider.default_model }}
|
||||
</span>
|
||||
<span v-if="showEditorInfo" class="status-item">
|
||||
{{ editorStore.lineCount }} 行
|
||||
</span>
|
||||
<span v-if="showEditorInfo" class="status-item">
|
||||
{{ editorStore.wordCount }} 字
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.statusbar {
|
||||
height: var(--statusbar-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--space-md);
|
||||
background: var(--color-background-secondary);
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.statusbar-left,
|
||||
.statusbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-status {
|
||||
color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 2px solid var(--color-accent-soft);
|
||||
border-top-color: var(--color-accent-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.provider-info {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const route = useRoute()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const editorStore = useEditorStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
const name = route.name as string
|
||||
const titles: Record<string, string> = {
|
||||
workspace: '工作区',
|
||||
search: '搜索',
|
||||
chat: 'AI 对话',
|
||||
agent: 'Agent Trace',
|
||||
tasks: '任务',
|
||||
skills: 'Skill 管理',
|
||||
plugins: 'Plugin 管理',
|
||||
themes: '主题管理',
|
||||
settings: '设置',
|
||||
}
|
||||
return titles[name] || '知笔知己'
|
||||
})
|
||||
|
||||
const currentFileName = computed(() => {
|
||||
if (route.name !== 'workspace') return pageTitle.value
|
||||
if (workspaceStore.activeFile) {
|
||||
return workspaceStore.activeFile.name
|
||||
}
|
||||
return pageTitle.value
|
||||
})
|
||||
|
||||
const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore.saveStatus === 'conflict')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="titlebar">
|
||||
<div class="titlebar-left">
|
||||
<span class="vault-name">{{ workspaceStore.vaultName }}</span>
|
||||
<span class="title-separator">/</span>
|
||||
<span class="file-name" :class="{ dirty: isDirty }">
|
||||
{{ currentFileName }}
|
||||
<span v-if="isDirty" class="dirty-dot" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="titlebar-center">
|
||||
<span class="app-name">知笔知己</span>
|
||||
</div>
|
||||
<div class="titlebar-right">
|
||||
<button class="icon-btn" @click="themeStore.toggleTheme()" :title="themeStore.isDark ? '切换浅色主题' : '切换深色主题'">
|
||||
<span class="icon">{{ themeStore.isDark ? '☀️' : '🌙' }}</span>
|
||||
</button>
|
||||
<div class="window-controls">
|
||||
<span class="win-btn minimize">—</span>
|
||||
<span class="win-btn maximize">▢</span>
|
||||
<span class="win-btn close">✕</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.titlebar {
|
||||
height: var(--titlebar-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--space-md);
|
||||
background: var(--color-background-secondary);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
font-size: var(--font-size-sm);
|
||||
flex-shrink: 0;
|
||||
z-index: var(--z-titlebar);
|
||||
user-select: none;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.titlebar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vault-name {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.title-separator {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.file-name {
|
||||
color: var(--color-text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.dirty {
|
||||
color: var(--color-accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.dirty-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.titlebar-center {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.titlebar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 200px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
-webkit-app-region: no-drag;
|
||||
transition: background var(--motion-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.win-btn {
|
||||
width: 34px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--motion-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
|
||||
&.close:hover {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,364 @@
|
||||
// ============ Notes & Blocks ============
|
||||
|
||||
export interface Note {
|
||||
note_id: string
|
||||
title: string
|
||||
file_path: string
|
||||
folder_path: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
tags: string[]
|
||||
word_count: number
|
||||
}
|
||||
|
||||
export interface NoteBlock {
|
||||
block_id: string
|
||||
note_id: string
|
||||
heading_path: string
|
||||
start_offset: number
|
||||
end_offset: number
|
||||
content: string
|
||||
content_hash: string
|
||||
token_count: number
|
||||
}
|
||||
|
||||
export interface FileNode {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'folder'
|
||||
children?: FileNode[]
|
||||
is_open?: boolean
|
||||
is_dirty?: boolean
|
||||
is_external_changed?: boolean
|
||||
}
|
||||
|
||||
// ============ Search ============
|
||||
|
||||
export interface SearchRequest {
|
||||
query: string
|
||||
mode?: 'fts' | 'vector' | 'hybrid'
|
||||
folder?: string
|
||||
note_id?: string
|
||||
tag?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
block_id: string
|
||||
note_id: string
|
||||
note_title: string
|
||||
file_path: string
|
||||
heading_path: string
|
||||
snippet: string
|
||||
score: number
|
||||
match_type: 'fts' | 'vector' | 'hybrid'
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
// ============ Chat ============
|
||||
|
||||
export interface Conversation {
|
||||
conversation_id: string
|
||||
title: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
message_count: number
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
message_id: string
|
||||
conversation_id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
created_at: string
|
||||
citations?: Citation[]
|
||||
tool_calls?: ToolCall[]
|
||||
}
|
||||
|
||||
export interface Citation {
|
||||
note_id: string
|
||||
block_id: string
|
||||
file_path: string
|
||||
heading_path: string
|
||||
content: string
|
||||
source_audio?: {
|
||||
start_time: number
|
||||
end_time: number
|
||||
speaker?: string
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Model Events (SSE) ============
|
||||
|
||||
export type ModelEventType =
|
||||
| 'TextDelta'
|
||||
| 'ThinkingDelta'
|
||||
| 'ToolCallStart'
|
||||
| 'ToolCallDelta'
|
||||
| 'ToolCallEnd'
|
||||
| 'Usage'
|
||||
| 'Error'
|
||||
| 'Done'
|
||||
|
||||
export interface ModelEvent {
|
||||
event: ModelEventType
|
||||
sequence: number
|
||||
data: Record<string, unknown>
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
// ============ Agent ============
|
||||
|
||||
export type AgentRunStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'waiting_permission'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
|
||||
export interface AgentRun {
|
||||
run_id: string
|
||||
status: AgentRunStatus
|
||||
current_step: number
|
||||
max_steps: number
|
||||
token_usage?: TokenUsage
|
||||
started_at?: string
|
||||
completed_at?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type AgentEventType =
|
||||
| 'RunStarted'
|
||||
| 'TextDelta'
|
||||
| 'ThinkingDelta'
|
||||
| 'ToolCall'
|
||||
| 'ToolResult'
|
||||
| 'PermissionRequired'
|
||||
| 'Usage'
|
||||
| 'Citation'
|
||||
| 'RunCompleted'
|
||||
| 'RunFailed'
|
||||
| 'RunCancelled'
|
||||
|
||||
export interface AgentEvent {
|
||||
event: AgentEventType
|
||||
sequence: number
|
||||
run_id: string
|
||||
data: Record<string, unknown>
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
tool_call_id: string
|
||||
name: string
|
||||
parameters: Record<string, unknown>
|
||||
status: 'pending' | 'running' | 'completed' | 'error'
|
||||
result?: string
|
||||
started_at?: string
|
||||
completed_at?: string
|
||||
error_code?: string
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string
|
||||
description: string
|
||||
parameters: Record<string, unknown>
|
||||
source?: 'builtin' | 'plugin'
|
||||
plugin_id?: string
|
||||
}
|
||||
|
||||
export interface PermissionRequest {
|
||||
request_id: string
|
||||
run_id: string
|
||||
tool_name: string
|
||||
permission: string
|
||||
parameters: Record<string, unknown>
|
||||
impact: string
|
||||
}
|
||||
|
||||
export interface TokenUsage {
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
total_tokens: number
|
||||
}
|
||||
|
||||
// ============ Skill ============
|
||||
|
||||
export type SkillStatus =
|
||||
| 'installed'
|
||||
| 'disabled'
|
||||
| 'ready'
|
||||
| 'dependency_missing'
|
||||
| 'permission_required'
|
||||
| 'error'
|
||||
|
||||
export interface Skill {
|
||||
skill_id: string
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
icon?: string
|
||||
author?: string
|
||||
permissions: string[]
|
||||
tools: string[]
|
||||
retrieval_config?: {
|
||||
top_k: number
|
||||
rerank: boolean
|
||||
citation: boolean
|
||||
}
|
||||
model_requirements?: {
|
||||
capabilities: string[]
|
||||
}
|
||||
status: SkillStatus
|
||||
missing_dependencies?: string[]
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// ============ Plugin ============
|
||||
|
||||
export type PluginStatus =
|
||||
| 'installed'
|
||||
| 'disabled'
|
||||
| 'starting'
|
||||
| 'ready'
|
||||
| 'error'
|
||||
| 'dependency_missing'
|
||||
| 'permission_required'
|
||||
|
||||
export interface PluginContribution {
|
||||
type: 'tool' | 'command' | 'importer' | 'exporter' | 'sidebar_panel' | 'settings_section'
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface Plugin {
|
||||
plugin_id: string
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
icon?: string
|
||||
author?: string
|
||||
status: PluginStatus
|
||||
enabled: boolean
|
||||
permissions: string[]
|
||||
contributions: PluginContribution[]
|
||||
backend_type?: 'mcp' | 'internal'
|
||||
transport?: 'stdio' | 'websocket'
|
||||
last_error?: string
|
||||
dependent_skills?: string[]
|
||||
}
|
||||
|
||||
// ============ Provider ============
|
||||
|
||||
export type ProviderType = 'openai' | 'anthropic' | 'ollama' | 'openai-compatible' | 'mock'
|
||||
|
||||
export interface ModelCapability {
|
||||
chat: boolean
|
||||
vision: boolean
|
||||
tool_calling: boolean
|
||||
reasoning: boolean
|
||||
streaming: boolean
|
||||
structured_output: boolean
|
||||
embedding: boolean
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
model_id: string
|
||||
name: string
|
||||
capabilities: Partial<ModelCapability>
|
||||
context_window?: number
|
||||
}
|
||||
|
||||
export interface ProviderConfig {
|
||||
provider_id: string
|
||||
provider_type: ProviderType
|
||||
name: string
|
||||
base_url?: string
|
||||
default_model: string
|
||||
enabled: boolean
|
||||
capabilities: Partial<ModelCapability>
|
||||
credential_id?: string
|
||||
has_credential: boolean
|
||||
}
|
||||
|
||||
// ============ Tasks ============
|
||||
|
||||
export type TaskStatus = 'todo' | 'in_progress' | 'done' | 'cancelled'
|
||||
export type TaskPriority = 'low' | 'medium' | 'high'
|
||||
export type TaskSource = 'user' | 'note' | 'agent'
|
||||
|
||||
export interface TaskItem {
|
||||
task_id: string
|
||||
title: string
|
||||
description?: string
|
||||
status: TaskStatus
|
||||
priority: TaskPriority
|
||||
due_date?: string
|
||||
note_id?: string
|
||||
note_title?: string
|
||||
source: TaskSource
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// ============ Theme ============
|
||||
|
||||
export interface ThemeConfig {
|
||||
theme_id: string
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
is_dark: boolean
|
||||
author?: string
|
||||
builtin: boolean
|
||||
}
|
||||
|
||||
// ============ Index ============
|
||||
|
||||
export interface IndexStatus {
|
||||
status: 'idle' | 'indexing' | 'error'
|
||||
pending_jobs: number
|
||||
total_notes: number
|
||||
total_blocks: number
|
||||
fts_enabled: boolean
|
||||
vector_enabled: boolean
|
||||
embedding_model?: string
|
||||
reranker_model?: string
|
||||
last_indexed_at?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// ============ System ============
|
||||
|
||||
export interface ApiError {
|
||||
code: string
|
||||
message: string
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: ApiError
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
name: string
|
||||
version: string
|
||||
environment: 'development' | 'production' | 'test'
|
||||
ai_core_available: boolean
|
||||
}
|
||||
|
||||
export type SaveStatus =
|
||||
| 'idle'
|
||||
| 'dirty'
|
||||
| 'saving'
|
||||
| 'saved'
|
||||
| 'save_failed'
|
||||
| 'external_changed'
|
||||
| 'conflict'
|
||||
|
||||
export type AiCoreStatus = 'starting' | 'running' | 'stopped' | 'error'
|
||||
@@ -0,0 +1,441 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
const router = useRouter()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const themeStore = useThemeStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const showCreateDialog = ref(false)
|
||||
const newVaultName = ref('')
|
||||
const newVaultPath = ref('')
|
||||
const aiCoreStatus = ref<'checking' | 'running' | 'stopped'>('checking')
|
||||
|
||||
onMounted(async () => {
|
||||
await workspaceStore.loadRecentVaults()
|
||||
setTimeout(() => {
|
||||
aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped'
|
||||
}, 800)
|
||||
})
|
||||
|
||||
async function openVault(path: string) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
await workspaceStore.openVault(path)
|
||||
router.push('/workspace')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openFolderPicker() {
|
||||
// In Tauri this would use the native dialog
|
||||
// For web dev, simulate
|
||||
const path = prompt('请输入 Vault 路径(开发模式)', '/Users/demo/Documents/MyVault')
|
||||
if (path) {
|
||||
await openVault(path)
|
||||
}
|
||||
}
|
||||
|
||||
async function createVault() {
|
||||
if (!newVaultName.value || !newVaultPath.value) return
|
||||
isLoading.value = true
|
||||
try {
|
||||
await workspaceStore.createVault(newVaultPath.value, newVaultName.value)
|
||||
router.push('/workspace')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
showCreateDialog.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vault-entry">
|
||||
<div class="bg-decoration" />
|
||||
<div class="entry-container">
|
||||
<div class="brand-section">
|
||||
<div class="logo">📝</div>
|
||||
<h1 class="app-title">知笔知己</h1>
|
||||
<p class="app-subtitle">本地优先的 AI 笔记软件</p>
|
||||
</div>
|
||||
|
||||
<div class="vault-card">
|
||||
<h2 class="card-title">选择知识库</h2>
|
||||
<p class="card-desc">选择一个本地 Vault 开始你的知识之旅</p>
|
||||
|
||||
<div v-if="workspaceStore.recentVaults.length" class="recent-vaults">
|
||||
<div class="section-label">最近打开</div>
|
||||
<div class="vault-list">
|
||||
<button
|
||||
v-for="vault in workspaceStore.recentVaults"
|
||||
:key="vault.path"
|
||||
class="vault-item"
|
||||
@click="openVault(vault.path)"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<span class="vault-icon">📁</span>
|
||||
<div class="vault-info">
|
||||
<div class="vault-name">{{ vault.name }}</div>
|
||||
<div class="vault-path">{{ vault.path }}</div>
|
||||
</div>
|
||||
<span class="vault-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" @click="openFolderPicker" :disabled="isLoading">
|
||||
<span>📂</span> 打开本地 Vault
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="showCreateDialog = true" :disabled="isLoading">
|
||||
<span>➕</span> 创建新 Vault
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="ai-core-status">
|
||||
<span class="status-dot" :class="aiCoreStatus" />
|
||||
<span v-if="aiCoreStatus === 'checking'">正在检查 AI Core 状态...</span>
|
||||
<span v-else-if="aiCoreStatus === 'running'" class="status-running">AI Core 运行正常</span>
|
||||
<span v-else class="status-stopped">AI Core 未启动(编辑功能仍可用)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-info">
|
||||
<span>v0.1.0</span>
|
||||
<button class="theme-toggle" @click="themeStore.toggleTheme()">
|
||||
{{ themeStore.isDark ? '☀️ 浅色' : '🌙 深色' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Vault Dialog -->
|
||||
<div v-if="showCreateDialog" class="dialog-overlay" @click.self="showCreateDialog = false">
|
||||
<div class="dialog">
|
||||
<h3>创建新 Vault</h3>
|
||||
<div class="form-group">
|
||||
<label>Vault 名称</label>
|
||||
<input v-model="newVaultName" type="text" placeholder="我的知识库" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>存储路径</label>
|
||||
<input v-model="newVaultPath" type="text" placeholder="/path/to/vault" />
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button class="btn btn-secondary" @click="showCreateDialog = false">取消</button>
|
||||
<button class="btn btn-primary" @click="createVault" :disabled="!newVaultName || !newVaultPath">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vault-entry {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-background-primary);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-decoration {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, var(--color-accent-soft) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 70%, var(--color-info-soft) 0%, transparent 50%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.entry-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
max-width: 480px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.brand-section {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 64px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 8px;
|
||||
background: linear-gradient(135deg, var(--color-accent-primary), var(--color-info));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.app-subtitle {
|
||||
font-size: 15px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.vault-card {
|
||||
width: 100%;
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-2xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-tertiary);
|
||||
margin-bottom: var(--space-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vault-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.vault-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
width: 100%;
|
||||
padding: var(--space-md);
|
||||
background: var(--color-background-secondary);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all var(--motion-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-accent-soft);
|
||||
border-color: var(--color-accent-secondary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.vault-icon {
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vault-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vault-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.vault-path {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-tertiary);
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vault-arrow {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--motion-fast);
|
||||
border: 1px solid transparent;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
background: var(--color-accent-primary);
|
||||
color: var(--color-text-inverse);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-accent-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-secondary {
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border-default);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ai-core-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
padding-top: var(--space-md);
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-text-tertiary);
|
||||
|
||||
&.checking {
|
||||
animation: pulse 1.5s infinite;
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
&.running { background: var(--color-success); }
|
||||
&.stopped { background: var(--color-text-tertiary); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.status-running { color: var(--color-success); }
|
||||
.status-stopped { color: var(--color-text-tertiary); }
|
||||
|
||||
.footer-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-lg);
|
||||
font-size: 12px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
color: var(--color-text-secondary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--color-background-overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl);
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.dialog h3 {
|
||||
margin: 0 0 var(--space-lg) 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-md);
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-background-secondary);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
outline: none;
|
||||
transition: border-color var(--motion-fast);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-border-focus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,406 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { FileNode } from '@/contracts'
|
||||
import * as workspaceService from '@/services/workspaceService'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const editorStore = useEditorStore()
|
||||
const router = useRouter()
|
||||
|
||||
const showNewMenu = ref(false)
|
||||
const newFileName = ref('')
|
||||
const newFolderName = ref('')
|
||||
const newFileParentPath = ref('')
|
||||
const showNewFileInput = ref(false)
|
||||
const showNewFolderInput = ref(false)
|
||||
const contextMenuPath = ref<string | null>(null)
|
||||
const showContextMenu = ref(false)
|
||||
const contextMenuPos = ref({ x: 0, y: 0 })
|
||||
const renamingPath = ref<string | null>(null)
|
||||
const renameValue = ref('')
|
||||
|
||||
function toggleFolder(node: FileNode) {
|
||||
workspaceStore.toggleFolder(node.path)
|
||||
}
|
||||
|
||||
async function openFile(node: FileNode) {
|
||||
if (node.type === 'folder') {
|
||||
toggleFolder(node)
|
||||
return
|
||||
}
|
||||
workspaceStore.openFile(node.path)
|
||||
await editorStore.loadFile(node.path)
|
||||
router.push('/workspace')
|
||||
}
|
||||
|
||||
function startNewFile(parentPath = '') {
|
||||
newFileParentPath.value = parentPath
|
||||
showNewFileInput.value = true
|
||||
showNewMenu.value = false
|
||||
newFileName.value = ''
|
||||
}
|
||||
|
||||
function startNewFolder(parentPath = '') {
|
||||
newFileParentPath.value = parentPath
|
||||
showNewFolderInput.value = true
|
||||
showNewMenu.value = false
|
||||
newFolderName.value = ''
|
||||
}
|
||||
|
||||
async function createFile() {
|
||||
if (!newFileName.value.trim()) return
|
||||
const name = newFileName.value.endsWith('.md') ? newFileName.value : `${newFileName.value}.md`
|
||||
const file = await workspaceService.createFile(newFileParentPath.value || '/', name, '# ' + newFileName.value + '\n\n')
|
||||
workspaceStore.addFileToTree(newFileParentPath.value || '/', file)
|
||||
workspaceStore.openFile(file.path)
|
||||
await editorStore.loadFile(file.path)
|
||||
showNewFileInput.value = false
|
||||
newFileName.value = ''
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
if (!newFolderName.value.trim()) return
|
||||
const folder = await workspaceService.createFolder(newFileParentPath.value || '/', newFolderName.value)
|
||||
workspaceStore.addFileToTree(newFileParentPath.value || '/', folder)
|
||||
showNewFolderInput.value = false
|
||||
newFolderName.value = ''
|
||||
}
|
||||
|
||||
function onContextMenu(e: MouseEvent, node: FileNode) {
|
||||
e.preventDefault()
|
||||
contextMenuPath.value = node.path
|
||||
contextMenuPos.value = { x: e.clientX, y: e.clientY }
|
||||
showContextMenu.value = true
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
showContextMenu.value = false
|
||||
contextMenuPath.value = null
|
||||
}
|
||||
|
||||
function startRename(node: FileNode) {
|
||||
renamingPath.value = node.path
|
||||
renameValue.value = node.name
|
||||
showContextMenu.value = false
|
||||
}
|
||||
|
||||
async function finishRename(node: FileNode) {
|
||||
if (renameValue.value && renameValue.value !== node.name) {
|
||||
await workspaceService.renameFile(node.path, renameValue.value)
|
||||
node.name = renameValue.value
|
||||
}
|
||||
renamingPath.value = null
|
||||
}
|
||||
|
||||
async function deleteNode(node: FileNode) {
|
||||
const confirmMsg = node.type === 'folder' ? `确定要删除文件夹 "${node.name}" 吗?` : `确定要删除笔记 "${node.name}" 吗?`
|
||||
if (confirm(confirmMsg)) {
|
||||
await workspaceService.deleteFile(node.path)
|
||||
workspaceStore.removeFromTree(node.path)
|
||||
if (node.type === 'file') {
|
||||
workspaceStore.closeFile(node.path)
|
||||
}
|
||||
}
|
||||
showContextMenu.value = false
|
||||
}
|
||||
|
||||
function getFileIcon(name: string) {
|
||||
if (name.endsWith('.md')) return '📄'
|
||||
return '📄'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-tree-panel" @click="closeContextMenu">
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<button class="tool-btn" @click="startNewFile" title="新建笔记">
|
||||
<span>➕</span>
|
||||
</button>
|
||||
<button class="tool-btn" @click="startNewFolder" title="新建文件夹">
|
||||
<span>📁</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="tool-btn" title="刷新">
|
||||
<span>🔄</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="new-input" v-if="showNewFileInput">
|
||||
<input
|
||||
v-model="newFileName"
|
||||
type="text"
|
||||
placeholder="笔记名称"
|
||||
@keyup.enter="createFile"
|
||||
@keyup.esc="showNewFileInput = false"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="new-input" v-if="showNewFolderInput">
|
||||
<input
|
||||
v-model="newFolderName"
|
||||
type="text"
|
||||
placeholder="文件夹名称"
|
||||
@keyup.enter="createFolder"
|
||||
@keyup.esc="showNewFolderInput = false"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="tree-container">
|
||||
<template v-for="node in workspaceStore.fileTree" :key="node.id">
|
||||
<div class="tree-node-wrapper">
|
||||
<TreeNode :node="node" :depth="0" @open="openFile" @toggle="toggleFolder" @context-menu="onContextMenu"
|
||||
:renaming-path="renamingPath" :rename-value="renameValue"
|
||||
@rename-start="startRename" @rename-finish="finishRename"
|
||||
@delete-node="deleteNode"
|
||||
@new-file="startNewFile" @new-folder="startNewFolder" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="showContextMenu" class="context-menu"
|
||||
:style="{ left: contextMenuPos.x + 'px', top: contextMenuPos.y + 'px' }"
|
||||
@click.stop>
|
||||
<button @click="() => { const n = workspaceStore.activeFile; if (n) startRename(n) }">✏️ 重命名</button>
|
||||
<button @click="() => { const n = workspaceStore.activeFile; if (n) deleteNode(n) }" class="danger">🗑️ 删除</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, h } from 'vue'
|
||||
import type { FileNode } from '@/contracts'
|
||||
|
||||
const TreeNode = defineComponent({
|
||||
name: 'TreeNode',
|
||||
props: {
|
||||
node: { type: Object as () => FileNode, required: true },
|
||||
depth: { type: Number, default: 0 },
|
||||
renamingPath: { type: String, default: null },
|
||||
renameValue: { type: String, default: '' },
|
||||
},
|
||||
emits: ['open', 'toggle', 'context-menu', 'rename-start', 'rename-finish', 'delete-node', 'new-file', 'new-folder'],
|
||||
setup(props, { emit }) {
|
||||
const isActive = (path: string) => {
|
||||
const { useWorkspaceStore } = require('@/stores/workspace')
|
||||
return useWorkspaceStore().activeFilePath === path
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
emit('open', props.node)
|
||||
}
|
||||
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
emit('context-menu', e, props.node)
|
||||
}
|
||||
|
||||
const finishRename = () => {
|
||||
emit('rename-finish', props.node)
|
||||
}
|
||||
|
||||
return () => {
|
||||
const isFolder = props.node.type === 'folder'
|
||||
const isOpen = props.node.is_open
|
||||
const isRenaming = props.renamingPath === props.node.path
|
||||
const active = isActive(props.node.path)
|
||||
|
||||
return h('div', { class: 'tree-node' }, [
|
||||
h('div', {
|
||||
class: ['node-row', { active, folder: isFolder, open: isOpen }],
|
||||
style: { paddingLeft: `${props.depth * 16 + 8}px` },
|
||||
onClick: handleClick,
|
||||
onContextmenu: handleContextMenu,
|
||||
}, [
|
||||
h('span', { class: 'chevron' }, isFolder ? (isOpen ? '▼' : '▶') : ''),
|
||||
h('span', { class: 'node-icon' }, isFolder ? (isOpen ? '📂' : '📁') : '📄'),
|
||||
isRenaming
|
||||
? h('input', {
|
||||
class: 'rename-input',
|
||||
value: props.renameValue,
|
||||
autofocus: true,
|
||||
onBlur: finishRename,
|
||||
onKeyup: (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') finishRename()
|
||||
if (e.key === 'Escape') emit('rename-finish', props.node)
|
||||
},
|
||||
})
|
||||
: h('span', { class: 'node-name' }, props.node.name),
|
||||
]),
|
||||
isFolder && isOpen && props.node.children && props.node.children.length
|
||||
? h('div', { class: 'node-children' },
|
||||
props.node.children.map((child) =>
|
||||
h(TreeNode, {
|
||||
key: child.id,
|
||||
node: child,
|
||||
depth: props.depth + 1,
|
||||
renamingPath: props.renamingPath,
|
||||
renameValue: props.renameValue,
|
||||
onOpen: (n: FileNode) => emit('open', n),
|
||||
onToggle: (n: FileNode) => emit('toggle', n.path),
|
||||
onContextmenu: (e: MouseEvent, n: FileNode) => emit('context-menu', e, n),
|
||||
onRenameStart: (n: FileNode) => emit('rename-start', n),
|
||||
onRenameFinish: (n: FileNode) => emit('rename-finish', n),
|
||||
onDeleteNode: (n: FileNode) => emit('delete-node', n),
|
||||
onNewFile: (p: string) => emit('new-file', p),
|
||||
onNewFolder: (p: string) => emit('new-folder', p),
|
||||
})
|
||||
)
|
||||
)
|
||||
: null,
|
||||
])
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export default {}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-tree-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
transition: all var(--motion-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.new-input {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
background: var(--color-background-secondary);
|
||||
border: 1px solid var(--color-border-focus);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.node-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
padding-right: var(--space-md);
|
||||
cursor: pointer;
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
margin-right: 4px;
|
||||
transition: background var(--motion-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
width: 14px;
|
||||
font-size: 9px;
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.rename-input {
|
||||
flex: 1;
|
||||
padding: 2px 4px;
|
||||
font-size: 13px;
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-focus);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: var(--z-dropdown);
|
||||
background: var(--color-surface-elevated);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 4px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
min-width: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
button {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import EditorHeader from '@/features/editor/EditorHeader.vue'
|
||||
import EditorPane from '@/features/editor/EditorPane.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const editorStore = useEditorStore()
|
||||
|
||||
onMounted(() => {
|
||||
if (!workspaceStore.fileTree.length && workspaceStore.hasVault) {
|
||||
// Already loaded
|
||||
}
|
||||
if (!workspaceStore.activeFilePath && workspaceStore.fileTree.length === 0) {
|
||||
workspaceStore.openFile('/欢迎使用知笔知己.md')
|
||||
editorStore.loadFile('/欢迎使用知笔知己.md')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workspace-view">
|
||||
<template v-if="workspaceStore.activeFilePath">
|
||||
<EditorHeader />
|
||||
<EditorPane />
|
||||
</template>
|
||||
<div v-else class="empty-workspace">
|
||||
<div class="empty-content">
|
||||
<div class="empty-icon">📝</div>
|
||||
<h2>开始写作</h2>
|
||||
<p>从左侧文件树选择笔记,或创建新的笔记</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workspace-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty-workspace {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
+14
-2
@@ -1,5 +1,17 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
import router from './router'
|
||||
import './styles/tokens.css'
|
||||
import { useThemeStore } from './stores/theme'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
themeStore.initTheme()
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'vault-entry',
|
||||
component: () => import('@/features/vault/VaultEntry.vue'),
|
||||
meta: { title: '选择知识库' },
|
||||
},
|
||||
{
|
||||
path: '/workspace',
|
||||
name: 'workspace',
|
||||
component: () => import('@/features/workspace/WorkspaceView.vue'),
|
||||
meta: { title: '工作区', requiresVault: true },
|
||||
},
|
||||
{
|
||||
path: '/search',
|
||||
name: 'search',
|
||||
component: () => import('@/features/search/SearchView.vue'),
|
||||
meta: { title: '搜索', requiresVault: true },
|
||||
},
|
||||
{
|
||||
path: '/chat',
|
||||
name: 'chat',
|
||||
component: () => import('@/features/chat/ChatView.vue'),
|
||||
meta: { title: 'AI 对话', requiresVault: true },
|
||||
},
|
||||
{
|
||||
path: '/agent/runs/:runId?',
|
||||
name: 'agent',
|
||||
component: () => import('@/features/agent/AgentView.vue'),
|
||||
meta: { title: 'Agent Trace', requiresVault: true },
|
||||
},
|
||||
{
|
||||
path: '/tasks',
|
||||
name: 'tasks',
|
||||
component: () => import('@/features/tasks/TasksView.vue'),
|
||||
meta: { title: '任务', requiresVault: true },
|
||||
},
|
||||
{
|
||||
path: '/extensions/skills',
|
||||
name: 'skills',
|
||||
component: () => import('@/features/skills/SkillsView.vue'),
|
||||
meta: { title: 'Skill 管理', requiresVault: true },
|
||||
},
|
||||
{
|
||||
path: '/extensions/plugins',
|
||||
name: 'plugins',
|
||||
component: () => import('@/features/plugins/PluginsView.vue'),
|
||||
meta: { title: 'Plugin 管理', requiresVault: true },
|
||||
},
|
||||
{
|
||||
path: '/themes',
|
||||
name: 'themes',
|
||||
component: () => import('@/features/themes/ThemesView.vue'),
|
||||
meta: { title: '主题管理', requiresVault: true },
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => import('@/features/settings/SettingsView.vue'),
|
||||
meta: { title: '设置', requiresVault: true },
|
||||
children: [
|
||||
{ path: '', redirect: '/settings/general' },
|
||||
{ path: 'general', component: () => import('@/features/settings/sections/GeneralSection.vue') },
|
||||
{ path: 'editor', component: () => import('@/features/settings/sections/EditorSection.vue') },
|
||||
{ path: 'providers', component: () => import('@/features/settings/sections/ProvidersSection.vue') },
|
||||
{ path: 'index', component: () => import('@/features/settings/sections/IndexSection.vue') },
|
||||
{ path: 'permissions', component: () => import('@/features/settings/sections/PermissionsSection.vue') },
|
||||
{ path: 'ai-core', component: () => import('@/features/settings/sections/AiCoreSection.vue') },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
if (to.meta.requiresVault && !workspaceStore.hasVault) {
|
||||
next({ path: '/' })
|
||||
return
|
||||
}
|
||||
if (to.path === '/' && workspaceStore.hasVault) {
|
||||
next({ path: '/workspace' })
|
||||
return
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
const baseTitle = '知笔知己'
|
||||
const title = to.meta.title as string | undefined
|
||||
document.title = title ? `${title} · ${baseTitle}` : baseTitle
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,289 @@
|
||||
import apiClient from './apiClient'
|
||||
import { SseClient } from './sseClient'
|
||||
import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest } from '@/contracts'
|
||||
|
||||
export async function listAgentRuns(params?: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{ items: AgentRun[]; total: number }> {
|
||||
return apiClient.get('/api/agent/runs', { params })
|
||||
}
|
||||
|
||||
export async function getAgentRun(runId: string): Promise<AgentRun> {
|
||||
return apiClient.get(`/api/agent/runs/${runId}`)
|
||||
}
|
||||
|
||||
export interface CreateAgentRunRequest {
|
||||
task: string
|
||||
provider_id?: string
|
||||
model?: string
|
||||
skill_id?: string
|
||||
allowed_tools?: string[]
|
||||
max_steps?: number
|
||||
tool_timeout?: number
|
||||
run_timeout?: number
|
||||
token_budget?: number
|
||||
allow_network?: boolean
|
||||
max_concurrent_tools?: number
|
||||
}
|
||||
|
||||
export async function createAgentRun(request: CreateAgentRunRequest): Promise<AgentRun> {
|
||||
return apiClient.post('/api/agent/runs', request)
|
||||
}
|
||||
|
||||
export async function cancelAgentRun(runId: string): Promise<void> {
|
||||
return apiClient.post(`/api/agent/runs/${runId}/cancel`)
|
||||
}
|
||||
|
||||
export async function listTools(): Promise<ToolDefinition[]> {
|
||||
try {
|
||||
return await apiClient.get('/api/tools')
|
||||
} catch {
|
||||
return mockTools
|
||||
}
|
||||
}
|
||||
|
||||
export function streamAgentEvents(
|
||||
runId: string,
|
||||
handlers: {
|
||||
onEvent?: (event: AgentEvent) => void
|
||||
onError?: (error: Error) => void
|
||||
onDone?: () => void
|
||||
onOpen?: () => void
|
||||
}
|
||||
): SseClient {
|
||||
const client = new SseClient({
|
||||
url: `/api/agent/runs/${runId}/events`,
|
||||
method: 'GET',
|
||||
onEvent: (eventName, data) => {
|
||||
handlers.onEvent?.({
|
||||
event: eventName as AgentEvent['event'],
|
||||
sequence: (data.sequence as number) || 0,
|
||||
run_id: (data.run_id as string) || runId,
|
||||
data: (data.data || data) as Record<string, unknown>,
|
||||
timestamp: (data.timestamp as string) || new Date().toISOString(),
|
||||
})
|
||||
},
|
||||
onError: handlers.onError,
|
||||
onDone: handlers.onDone,
|
||||
onOpen: handlers.onOpen,
|
||||
})
|
||||
client.connect().catch(() => {})
|
||||
return client
|
||||
}
|
||||
|
||||
export async function respondToPermission(
|
||||
runId: string,
|
||||
requestId: string,
|
||||
decision: 'allow' | 'deny',
|
||||
scope?: 'once' | 'session' | 'always'
|
||||
): Promise<void> {
|
||||
return apiClient.post(`/api/agent/runs/${runId}/permissions/${requestId}`, {
|
||||
decision,
|
||||
scope,
|
||||
})
|
||||
}
|
||||
|
||||
export const mockTools: ToolDefinition[] = [
|
||||
{
|
||||
name: 'notes.search',
|
||||
description: '搜索笔记,支持关键词和语义检索',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: '搜索关键词' },
|
||||
limit: { type: 'number', description: '返回结果数量' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
source: 'builtin',
|
||||
},
|
||||
{
|
||||
name: 'notes.read',
|
||||
description: '读取指定笔记的完整内容',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
note_id: { type: 'string' },
|
||||
},
|
||||
required: ['note_id'],
|
||||
},
|
||||
source: 'builtin',
|
||||
},
|
||||
{
|
||||
name: 'notes.create',
|
||||
description: '创建新笔记',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
content: { type: 'string' },
|
||||
folder_path: { type: 'string' },
|
||||
},
|
||||
required: ['title', 'content'],
|
||||
},
|
||||
source: 'builtin',
|
||||
},
|
||||
{
|
||||
name: 'rag.search',
|
||||
description: '基于 RAG 的语义检索,返回相关知识片段',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string' },
|
||||
top_k: { type: 'number' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
source: 'builtin',
|
||||
},
|
||||
{
|
||||
name: 'tasks.create',
|
||||
description: '创建任务',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
priority: { type: 'string', enum: ['low', 'medium', 'high'] },
|
||||
},
|
||||
required: ['title'],
|
||||
},
|
||||
source: 'builtin',
|
||||
},
|
||||
{
|
||||
name: 'system.echo',
|
||||
description: '回显输入内容(测试用)',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string' },
|
||||
},
|
||||
required: ['text'],
|
||||
},
|
||||
source: 'builtin',
|
||||
},
|
||||
{
|
||||
name: 'math.add',
|
||||
description: '两数相加(测试用)',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'number' },
|
||||
b: { type: 'number' },
|
||||
},
|
||||
required: ['a', 'b'],
|
||||
},
|
||||
source: 'builtin',
|
||||
},
|
||||
]
|
||||
|
||||
export const mockAgentRuns: AgentRun[] = [
|
||||
{
|
||||
run_id: 'run-1',
|
||||
status: 'completed',
|
||||
current_step: 3,
|
||||
max_steps: 10,
|
||||
token_usage: { input_tokens: 2340, output_tokens: 890, total_tokens: 3230 },
|
||||
started_at: '2026-08-25T11:00:00Z',
|
||||
completed_at: '2026-08-25T11:02:30Z',
|
||||
},
|
||||
{
|
||||
run_id: 'run-2',
|
||||
status: 'running',
|
||||
current_step: 2,
|
||||
max_steps: 10,
|
||||
token_usage: { input_tokens: 1500, output_tokens: 420, total_tokens: 1920 },
|
||||
started_at: '2026-08-26T09:30:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
export const mockAgentEvents: AgentEvent[] = [
|
||||
{
|
||||
event: 'RunStarted',
|
||||
sequence: 1,
|
||||
run_id: 'run-1',
|
||||
data: { task: '帮我整理红黑树的核心知识点' },
|
||||
timestamp: '2026-08-25T11:00:00Z',
|
||||
},
|
||||
{
|
||||
event: 'ThinkingDelta',
|
||||
sequence: 2,
|
||||
run_id: 'run-1',
|
||||
data: { text: '我需要先搜索笔记中关于红黑树的内容...' },
|
||||
timestamp: '2026-08-25T11:00:01Z',
|
||||
},
|
||||
{
|
||||
event: 'ToolCall',
|
||||
sequence: 3,
|
||||
run_id: 'run-1',
|
||||
data: {
|
||||
tool_call_id: 'tc-1',
|
||||
name: 'notes.search',
|
||||
parameters: { query: '红黑树 插入 删除', limit: 5 },
|
||||
status: 'running',
|
||||
},
|
||||
timestamp: '2026-08-25T11:00:02Z',
|
||||
},
|
||||
{
|
||||
event: 'ToolResult',
|
||||
sequence: 4,
|
||||
run_id: 'run-1',
|
||||
data: {
|
||||
tool_call_id: 'tc-1',
|
||||
name: 'notes.search',
|
||||
status: 'completed',
|
||||
result: '找到 5 条相关结果,包括红黑树性质、插入操作、删除操作等...',
|
||||
duration_ms: 320,
|
||||
},
|
||||
timestamp: '2026-08-25T11:00:02Z',
|
||||
},
|
||||
{
|
||||
event: 'Citation',
|
||||
sequence: 5,
|
||||
run_id: 'run-1',
|
||||
data: {
|
||||
note_id: 'n-rbt',
|
||||
block_id: 'b1',
|
||||
heading_path: '数据结构 / 红黑树 / 性质',
|
||||
},
|
||||
timestamp: '2026-08-25T11:00:03Z',
|
||||
},
|
||||
{
|
||||
event: 'ThinkingDelta',
|
||||
sequence: 6,
|
||||
run_id: 'run-1',
|
||||
data: { text: '搜索结果很全面,让我整理一下结构...' },
|
||||
timestamp: '2026-08-25T11:00:03Z',
|
||||
},
|
||||
{
|
||||
event: 'TextDelta',
|
||||
sequence: 7,
|
||||
run_id: 'run-1',
|
||||
data: { text: '## 红黑树核心知识点整理\n\n### 1. 基本性质\n红黑树是一种自平衡二叉搜索树,每个节点带有颜色属性...' },
|
||||
timestamp: '2026-08-25T11:00:04Z',
|
||||
},
|
||||
{
|
||||
event: 'Usage',
|
||||
sequence: 8,
|
||||
run_id: 'run-1',
|
||||
data: { input_tokens: 2340, output_tokens: 890, total_tokens: 3230 },
|
||||
timestamp: '2026-08-25T11:02:30Z',
|
||||
},
|
||||
{
|
||||
event: 'RunCompleted',
|
||||
sequence: 9,
|
||||
run_id: 'run-1',
|
||||
data: { message: 'Task completed successfully' },
|
||||
timestamp: '2026-08-25T11:02:30Z',
|
||||
},
|
||||
]
|
||||
|
||||
export const mockPermissionRequest: PermissionRequest = {
|
||||
request_id: 'perm-1',
|
||||
run_id: 'run-2',
|
||||
tool_name: 'notes.create',
|
||||
permission: 'notes.write',
|
||||
parameters: { title: '红黑树知识点总结', folder_path: '/数据结构' },
|
||||
impact: '将在你的知识库中创建一篇新笔记',
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { ApiError, ErrorResponse } from '@/contracts'
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE || ''
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
params?: Record<string, string | number | boolean | undefined>
|
||||
token?: string
|
||||
}
|
||||
|
||||
export class ApiErrorClass extends Error {
|
||||
code: string
|
||||
details?: Record<string, unknown>
|
||||
|
||||
constructor(code: string, message: string, details?: Record<string, unknown>) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.code = code
|
||||
this.details = details
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { params, token, headers, ...rest } = options
|
||||
|
||||
let url = path.startsWith('http') ? path : `${BASE_URL}${path}`
|
||||
|
||||
if (params) {
|
||||
const usp = new URLSearchParams()
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null) usp.append(k, String(v))
|
||||
})
|
||||
const qs = usp.toString()
|
||||
if (qs) url += `?${qs}`
|
||||
}
|
||||
|
||||
const reqHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(headers as Record<string, string>),
|
||||
}
|
||||
|
||||
if (token) {
|
||||
reqHeaders['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const reqId = crypto.randomUUID()
|
||||
reqHeaders['X-Request-Id'] = reqId
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
...rest,
|
||||
headers: reqHeaders,
|
||||
})
|
||||
|
||||
if (resp.ok) {
|
||||
if (resp.status === 204) return undefined as T
|
||||
const ct = resp.headers.get('content-type') || ''
|
||||
if (ct.includes('application/json')) return (await resp.json()) as T
|
||||
return resp as unknown as T
|
||||
}
|
||||
|
||||
let errBody: ErrorResponse | null = null
|
||||
try {
|
||||
errBody = (await resp.json()) as ErrorResponse
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const code = errBody?.error?.code || `HTTP_${resp.status}`
|
||||
const message = errBody?.error?.message || `Request failed with status ${resp.status}`
|
||||
const details = errBody?.error?.details
|
||||
|
||||
throw new ApiErrorClass(code, message, details)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiErrorClass) throw e
|
||||
throw new ApiErrorClass('NETWORK_ERROR', (e as Error).message || 'Network error')
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
get<T>(path: string, options?: Omit<RequestOptions, 'method'>) {
|
||||
return request<T>(path, { ...options, method: 'GET' })
|
||||
},
|
||||
post<T>(path: string, body?: unknown, options?: Omit<RequestOptions, 'method' | 'body'>) {
|
||||
return request<T>(path, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
},
|
||||
patch<T>(path: string, body?: unknown, options?: Omit<RequestOptions, 'method' | 'body'>) {
|
||||
return request<T>(path, {
|
||||
...options,
|
||||
method: 'PATCH',
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
},
|
||||
delete<T>(path: string, options?: Omit<RequestOptions, 'method'>) {
|
||||
return request<T>(path, { ...options, method: 'DELETE' })
|
||||
},
|
||||
}
|
||||
|
||||
export default apiClient
|
||||
@@ -0,0 +1,138 @@
|
||||
import apiClient from './apiClient'
|
||||
import { SseClient } from './sseClient'
|
||||
import type { Conversation, ChatMessage, ModelEvent } from '@/contracts'
|
||||
|
||||
export async function listConversations(): Promise<Conversation[]> {
|
||||
return apiClient.get('/api/conversations')
|
||||
}
|
||||
|
||||
export async function getConversation(conversationId: string): Promise<Conversation> {
|
||||
return apiClient.get(`/api/conversations/${conversationId}`)
|
||||
}
|
||||
|
||||
export async function getMessages(conversationId: string): Promise<ChatMessage[]> {
|
||||
return apiClient.get(`/api/conversations/${conversationId}/messages`)
|
||||
}
|
||||
|
||||
export interface ChatRequest {
|
||||
conversation_id?: string
|
||||
message: string
|
||||
provider_id?: string
|
||||
model?: string
|
||||
use_rag?: boolean
|
||||
skill_id?: string
|
||||
attachments?: string[]
|
||||
}
|
||||
|
||||
export function streamChat(
|
||||
request: ChatRequest,
|
||||
handlers: {
|
||||
onEvent?: (event: ModelEvent) => void
|
||||
onError?: (error: Error) => void
|
||||
onDone?: () => void
|
||||
onOpen?: () => void
|
||||
}
|
||||
): SseClient {
|
||||
const client = new SseClient({
|
||||
url: '/api/chat',
|
||||
method: 'POST',
|
||||
body: request,
|
||||
onEvent: (eventName, data) => {
|
||||
handlers.onEvent?.({
|
||||
event: eventName as ModelEvent['event'],
|
||||
sequence: data.sequence as number,
|
||||
data: (data.data || {}) as Record<string, unknown>,
|
||||
timestamp: (data.timestamp as string) || new Date().toISOString(),
|
||||
})
|
||||
},
|
||||
onError: handlers.onError,
|
||||
onDone: handlers.onDone,
|
||||
onOpen: handlers.onOpen,
|
||||
})
|
||||
client.connect().catch(() => {})
|
||||
return client
|
||||
}
|
||||
|
||||
export const mockConversations: Conversation[] = [
|
||||
{
|
||||
conversation_id: 'conv-1',
|
||||
title: '关于红黑树的讨论',
|
||||
created_at: '2026-08-25T10:00:00Z',
|
||||
updated_at: '2026-08-25T10:30:00Z',
|
||||
message_count: 6,
|
||||
},
|
||||
{
|
||||
conversation_id: 'conv-2',
|
||||
title: '死锁避免算法',
|
||||
created_at: '2026-08-24T14:00:00Z',
|
||||
updated_at: '2026-08-24T15:20:00Z',
|
||||
message_count: 4,
|
||||
},
|
||||
{
|
||||
conversation_id: 'conv-3',
|
||||
title: 'TCP三次握手',
|
||||
created_at: '2026-08-22T09:00:00Z',
|
||||
updated_at: '2026-08-22T09:15:00Z',
|
||||
message_count: 3,
|
||||
},
|
||||
]
|
||||
|
||||
export const mockMessages: Record<string, ChatMessage[]> = {
|
||||
'conv-1': [
|
||||
{
|
||||
message_id: 'msg-1',
|
||||
conversation_id: 'conv-1',
|
||||
role: 'user',
|
||||
content: '红黑树的插入操作有哪些情况需要调整?',
|
||||
created_at: '2026-08-25T10:00:00Z',
|
||||
},
|
||||
{
|
||||
message_id: 'msg-2',
|
||||
conversation_id: 'conv-1',
|
||||
role: 'assistant',
|
||||
content:
|
||||
'红黑树插入后可能违反红黑性质,需要通过旋转和变色来修复。主要有以下几种情况:\n\n**情况1:叔叔节点是红色**\n- 将父节点和叔叔节点设为黑色\n- 将祖父节点设为红色\n- 当前节点上移到祖父节点,继续向上调整\n\n**情况2:叔叔节点是黑色,且当前节点是右孩子**\n- 以父节点为支点左旋\n- 将当前节点转换为左孩子,进入情况3\n\n**情况3:叔叔节点是黑色,且当前节点是左孩子**\n- 以祖父节点为支点右旋\n- 将父节点设为黑色,祖父节点设为红色\n\n以上三种情况需要从插入节点开始向上递归处理,直到根节点。',
|
||||
created_at: '2026-08-25T10:01:00Z',
|
||||
citations: [
|
||||
{
|
||||
note_id: 'n-rbt',
|
||||
block_id: 'b1',
|
||||
file_path: '/数据结构/红黑树.md',
|
||||
heading_path: '数据结构 / 红黑树 / 插入操作',
|
||||
content: '插入后可能破坏红黑性质,需要通过变色和旋转来修复...',
|
||||
},
|
||||
{
|
||||
note_id: 'n-rbt',
|
||||
block_id: 'b2',
|
||||
file_path: '/数据结构/红黑树.md',
|
||||
heading_path: '数据结构 / 红黑树 / 五种情况',
|
||||
content: 'Case 1: 叔叔为红色,直接变色...',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
message_id: 'msg-3',
|
||||
conversation_id: 'conv-1',
|
||||
role: 'user',
|
||||
content: '能对比一下 AVL 树和红黑树的性能差异吗?',
|
||||
created_at: '2026-08-25T10:10:00Z',
|
||||
},
|
||||
{
|
||||
message_id: 'msg-4',
|
||||
conversation_id: 'conv-1',
|
||||
role: 'assistant',
|
||||
content:
|
||||
'AVL 树和红黑树都是平衡二叉搜索树,但它们的平衡策略和性能特点有所不同:\n\n| 特性 | AVL 树 | 红黑树 |\n|------|--------|--------|\n| 严格平衡 | 高度差 ≤ 1 | 黑色高度相同 |\n| 查找速度 | 更快(严格平衡) | 略慢但仍为 O(log n) |\n| 插入/删除 | 旋转更多 | 旋转更少(颜色调整为主) |\n| 存储开销 | 每个节点存高度(int) | 每个节点存颜色(1 bit) |\n| 适用场景 | 读多写少 | 读写均衡或写较多 |\n\n**总结:**\n- 如果应用以查询为主,很少修改,AVL 树可能更优\n- 如果插入删除频繁,红黑树更合适,重平衡开销更低\n- 红黑树在工业界应用更广泛(C++ STL 的 map/set、Java 的 TreeMap 等)',
|
||||
created_at: '2026-08-25T10:11:00Z',
|
||||
citations: [
|
||||
{
|
||||
note_id: 'n-rbt',
|
||||
block_id: 'b3',
|
||||
file_path: '/数据结构/红黑树.md',
|
||||
heading_path: '数据结构 / 红黑树 / 与AVL树对比',
|
||||
content: '红黑树相比AVL树,牺牲了部分平衡性以换取更少的旋转操作...',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export { apiClient, ApiErrorClass } from './apiClient'
|
||||
export type { ApiError } from './apiClient'
|
||||
export { SseClient } from './sseClient'
|
||||
export type { SseClientOptions, SseEventHandler } from './sseClient'
|
||||
export * as noteService from './noteService'
|
||||
export * as searchService from './searchService'
|
||||
export * as chatService from './chatService'
|
||||
export * as agentService from './agentService'
|
||||
export * as skillService from './skillService'
|
||||
export * as pluginService from './pluginService'
|
||||
export * as providerService from './providerService'
|
||||
export * as taskService from './taskService'
|
||||
export * as indexService from './indexService'
|
||||
export * as systemService from './systemService'
|
||||
export * as workspaceService from './workspaceService'
|
||||
@@ -0,0 +1,36 @@
|
||||
import apiClient from './apiClient'
|
||||
import type { IndexStatus } from '@/contracts'
|
||||
|
||||
export async function getIndexStatus(): Promise<IndexStatus> {
|
||||
try {
|
||||
return await apiClient.get('/api/index/status')
|
||||
} catch {
|
||||
return mockIndexStatus
|
||||
}
|
||||
}
|
||||
|
||||
export async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full'): Promise<{ job_id: string }> {
|
||||
return apiClient.post('/api/index/rebuild', { scope })
|
||||
}
|
||||
|
||||
export async function getIndexJob(jobId: string): Promise<{
|
||||
job_id: string
|
||||
status: 'queued' | 'running' | 'completed' | 'failed'
|
||||
progress: number
|
||||
total: number
|
||||
error?: string
|
||||
}> {
|
||||
return apiClient.get(`/api/index/jobs/${jobId}`)
|
||||
}
|
||||
|
||||
export const mockIndexStatus: IndexStatus = {
|
||||
status: 'idle',
|
||||
pending_jobs: 0,
|
||||
total_notes: 42,
|
||||
total_blocks: 318,
|
||||
fts_enabled: true,
|
||||
vector_enabled: true,
|
||||
embedding_model: 'bge-m3',
|
||||
reranker_model: 'bge-reranker-base',
|
||||
last_indexed_at: new Date().toISOString(),
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import apiClient from './apiClient'
|
||||
import type { Note, NoteBlock } from '@/contracts'
|
||||
|
||||
export async function listNotes(params?: {
|
||||
folder?: string
|
||||
tag?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{ items: Note[]; total: number }> {
|
||||
return apiClient.get('/api/notes', { params })
|
||||
}
|
||||
|
||||
export async function getNote(noteId: string): Promise<{ note: Note; blocks: NoteBlock[] }> {
|
||||
return apiClient.get(`/api/notes/${noteId}`)
|
||||
}
|
||||
|
||||
export async function createNote(data: {
|
||||
title: string
|
||||
folder_path?: string
|
||||
content?: string
|
||||
}): Promise<Note> {
|
||||
return apiClient.post('/api/notes', data)
|
||||
}
|
||||
|
||||
export async function updateNote(
|
||||
noteId: string,
|
||||
data: { title?: string; content?: string; tags?: string[] }
|
||||
): Promise<Note> {
|
||||
return apiClient.patch(`/api/notes/${noteId}`, data)
|
||||
}
|
||||
|
||||
export async function deleteNote(noteId: string): Promise<void> {
|
||||
return apiClient.delete(`/api/notes/${noteId}`)
|
||||
}
|
||||
|
||||
export async function moveNote(noteId: string, target_folder: string): Promise<Note> {
|
||||
return apiClient.post(`/api/notes/${noteId}/move`, { target_folder })
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import apiClient from './apiClient'
|
||||
import type { Plugin } from '@/contracts'
|
||||
|
||||
export async function listPlugins(): Promise<Plugin[]> {
|
||||
try {
|
||||
return await apiClient.get('/api/plugins')
|
||||
} catch {
|
||||
return mockPlugins
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlugin(pluginId: string): Promise<Plugin> {
|
||||
return apiClient.get(`/api/plugins/${pluginId}`)
|
||||
}
|
||||
|
||||
export async function installPlugin(pluginId: string): Promise<Plugin> {
|
||||
return apiClient.post('/api/plugins/install', { plugin_id: pluginId })
|
||||
}
|
||||
|
||||
export async function enablePlugin(pluginId: string): Promise<Plugin> {
|
||||
return apiClient.post(`/api/plugins/${pluginId}/enable`)
|
||||
}
|
||||
|
||||
export async function disablePlugin(pluginId: string): Promise<Plugin> {
|
||||
return apiClient.post(`/api/plugins/${pluginId}/disable`)
|
||||
}
|
||||
|
||||
export async function uninstallPlugin(pluginId: string): Promise<void> {
|
||||
return apiClient.delete(`/api/plugins/${pluginId}`)
|
||||
}
|
||||
|
||||
export const mockPlugins: Plugin[] = [
|
||||
{
|
||||
plugin_id: 'github-integration',
|
||||
name: 'GitHub 集成',
|
||||
version: '1.3.2',
|
||||
description: '接入 GitHub API,支持搜索 Issue、查看 PR 和管理仓库',
|
||||
icon: '🐙',
|
||||
author: '知笔知己团队',
|
||||
status: 'ready',
|
||||
enabled: true,
|
||||
permissions: ['notes.read', 'network.request'],
|
||||
contributions: [
|
||||
{ type: 'tool', id: 'github.search_issues', name: '搜索 Issue', description: '搜索 GitHub 仓库中的 Issue' },
|
||||
{ type: 'tool', id: 'github.get_pr', name: '获取 PR 详情', description: '获取 Pull Request 的详细信息' },
|
||||
{ type: 'command', id: 'github.open_repo', name: '打开仓库', description: '在浏览器中打开对应 GitHub 仓库' },
|
||||
],
|
||||
backend_type: 'mcp',
|
||||
transport: 'stdio',
|
||||
dependent_skills: ['research-assistant'],
|
||||
},
|
||||
{
|
||||
plugin_id: 'translator',
|
||||
name: '翻译助手',
|
||||
version: '1.0.0',
|
||||
description: '提供多语言翻译能力,支持文档批量翻译',
|
||||
icon: '🌐',
|
||||
author: '社区贡献',
|
||||
status: 'ready',
|
||||
enabled: false,
|
||||
permissions: ['notes.read', 'notes.write', 'network.request'],
|
||||
contributions: [
|
||||
{ type: 'tool', id: 'translator.translate', name: '翻译文本', description: '翻译指定文本到目标语言' },
|
||||
{ type: 'command', id: 'translator.translate_note', name: '翻译当前笔记', description: '翻译当前打开的笔记' },
|
||||
{ type: 'settings_section', id: 'translator.settings', name: '翻译设置', description: '配置翻译服务和默认语言' },
|
||||
],
|
||||
backend_type: 'mcp',
|
||||
transport: 'stdio',
|
||||
},
|
||||
{
|
||||
plugin_id: 'kanban',
|
||||
name: '看板视图',
|
||||
version: '0.8.0',
|
||||
description: '为任务提供看板视图,支持拖拽排序和多维度筛选',
|
||||
icon: '📋',
|
||||
author: '社区贡献',
|
||||
status: 'installed',
|
||||
enabled: false,
|
||||
permissions: ['tasks.read', 'tasks.write'],
|
||||
contributions: [
|
||||
{ type: 'sidebar_panel', id: 'kanban.panel', name: '任务看板', description: '以看板方式查看和管理任务' },
|
||||
],
|
||||
backend_type: 'internal',
|
||||
},
|
||||
{
|
||||
plugin_id: 'pdf-importer',
|
||||
name: 'PDF 导入',
|
||||
version: '2.1.0',
|
||||
description: '导入 PDF 文档,提取文本和目录结构生成笔记',
|
||||
icon: '📄',
|
||||
author: '知笔知己团队',
|
||||
status: 'error',
|
||||
enabled: false,
|
||||
permissions: ['notes.write', 'attachments.read'],
|
||||
contributions: [
|
||||
{ type: 'importer', id: 'pdf.import', name: 'PDF 导入器', description: '从 PDF 文件导入内容' },
|
||||
],
|
||||
backend_type: 'mcp',
|
||||
transport: 'stdio',
|
||||
last_error: 'PDF 解析库初始化失败,请检查 Python 依赖',
|
||||
},
|
||||
{
|
||||
plugin_id: 'calendar',
|
||||
name: '日历同步',
|
||||
version: '0.5.0',
|
||||
description: '同步日历事件,自动生成相关笔记和任务提醒',
|
||||
icon: '📅',
|
||||
author: '社区贡献',
|
||||
status: 'dependency_missing',
|
||||
enabled: false,
|
||||
permissions: ['tasks.read', 'tasks.write', 'network.request'],
|
||||
contributions: [
|
||||
{ type: 'tool', id: 'calendar.events', name: '日历事件', description: '获取日历事件列表' },
|
||||
{ type: 'sidebar_panel', id: 'calendar.widget', name: '日历小部件', description: '侧边栏日历视图' },
|
||||
],
|
||||
backend_type: 'mcp',
|
||||
transport: 'websocket',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,149 @@
|
||||
import apiClient from './apiClient'
|
||||
import type { ProviderConfig, ModelInfo } from '@/contracts'
|
||||
|
||||
export async function listProviders(): Promise<ProviderConfig[]> {
|
||||
try {
|
||||
return await apiClient.get('/api/providers')
|
||||
} catch {
|
||||
return mockProviders
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProvider(providerId: string): Promise<ProviderConfig> {
|
||||
return apiClient.get(`/api/providers/${providerId}`)
|
||||
}
|
||||
|
||||
export async function createProvider(data: Omit<ProviderConfig, 'provider_id'> & { api_key?: string }): Promise<ProviderConfig> {
|
||||
return apiClient.post('/api/providers', data)
|
||||
}
|
||||
|
||||
export async function updateProvider(providerId: string, data: Partial<ProviderConfig> & { api_key?: string }): Promise<ProviderConfig> {
|
||||
return apiClient.patch(`/api/providers/${providerId}`, data)
|
||||
}
|
||||
|
||||
export async function deleteProvider(providerId: string): Promise<void> {
|
||||
return apiClient.delete(`/api/providers/${providerId}`)
|
||||
}
|
||||
|
||||
export async function listModels(providerId: string): Promise<ModelInfo[]> {
|
||||
try {
|
||||
return await apiClient.get(`/api/providers/${providerId}/models`)
|
||||
} catch {
|
||||
return mockModels[providerId] || []
|
||||
}
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
success: boolean
|
||||
latency_ms?: number
|
||||
error_code?: string
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
export async function testProvider(providerId: string): Promise<TestResult> {
|
||||
try {
|
||||
const result = await apiClient.post<{ success: boolean; latency_ms: number }>('/api/providers/test', { provider_id: providerId })
|
||||
return { success: result.success, latency_ms: result.latency_ms }
|
||||
} catch (e: any) {
|
||||
return { success: false, error_code: e.code || 'TEST_FAILED', error_message: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
export const mockProviders: ProviderConfig[] = [
|
||||
{
|
||||
provider_id: 'mock-provider',
|
||||
provider_type: 'mock',
|
||||
name: 'Mock Provider (测试)',
|
||||
default_model: 'mock-1',
|
||||
enabled: true,
|
||||
has_credential: true,
|
||||
capabilities: {
|
||||
chat: true,
|
||||
tool_calling: true,
|
||||
streaming: true,
|
||||
vision: false,
|
||||
reasoning: false,
|
||||
structured_output: true,
|
||||
embedding: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
provider_id: 'openai-compat-1',
|
||||
provider_type: 'openai-compatible',
|
||||
name: 'OpenAI 兼容服务',
|
||||
base_url: 'https://api.openai.com/v1',
|
||||
default_model: 'gpt-4o-mini',
|
||||
enabled: true,
|
||||
has_credential: true,
|
||||
capabilities: {
|
||||
chat: true,
|
||||
tool_calling: true,
|
||||
streaming: true,
|
||||
vision: true,
|
||||
reasoning: false,
|
||||
structured_output: true,
|
||||
embedding: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
provider_id: 'ollama-local',
|
||||
provider_type: 'ollama',
|
||||
name: 'Ollama (本地)',
|
||||
base_url: 'http://127.0.0.1:11434',
|
||||
default_model: 'qwen2.5:7b',
|
||||
enabled: false,
|
||||
has_credential: false,
|
||||
capabilities: {
|
||||
chat: true,
|
||||
tool_calling: false,
|
||||
streaming: true,
|
||||
vision: false,
|
||||
reasoning: false,
|
||||
structured_output: false,
|
||||
embedding: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export const mockModels: Record<string, ModelInfo[]> = {
|
||||
'mock-provider': [
|
||||
{
|
||||
model_id: 'mock-1',
|
||||
name: 'Mock Model v1',
|
||||
capabilities: { chat: true, tool_calling: true, streaming: true, structured_output: true },
|
||||
context_window: 8192,
|
||||
},
|
||||
],
|
||||
'openai-compat-1': [
|
||||
{
|
||||
model_id: 'gpt-4o-mini',
|
||||
name: 'GPT-4o Mini',
|
||||
capabilities: { chat: true, tool_calling: true, streaming: true, vision: true, structured_output: true },
|
||||
context_window: 128000,
|
||||
},
|
||||
{
|
||||
model_id: 'gpt-4o',
|
||||
name: 'GPT-4o',
|
||||
capabilities: { chat: true, tool_calling: true, streaming: true, vision: true, structured_output: true, reasoning: true },
|
||||
context_window: 128000,
|
||||
},
|
||||
{
|
||||
model_id: 'text-embedding-3-small',
|
||||
name: 'Text Embedding 3 Small',
|
||||
capabilities: { embedding: true },
|
||||
},
|
||||
],
|
||||
'ollama-local': [
|
||||
{
|
||||
model_id: 'qwen2.5:7b',
|
||||
name: 'Qwen 2.5 7B',
|
||||
capabilities: { chat: true, streaming: true },
|
||||
context_window: 32768,
|
||||
},
|
||||
{
|
||||
model_id: 'bge-m3',
|
||||
name: 'BGE M3',
|
||||
capabilities: { embedding: true },
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import apiClient from './apiClient'
|
||||
import type { SearchRequest, SearchResult } from '@/contracts'
|
||||
|
||||
export async function search(request: SearchRequest): Promise<{
|
||||
results: SearchResult[]
|
||||
total: number
|
||||
mode: SearchRequest['mode']
|
||||
}> {
|
||||
return apiClient.post('/api/search', request)
|
||||
}
|
||||
|
||||
export async function searchMock(query: string, mode = 'hybrid' as const): Promise<{
|
||||
results: SearchResult[]
|
||||
total: number
|
||||
mode: 'fts' | 'vector' | 'hybrid'
|
||||
}> {
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
if (!query.trim()) return { results: [], total: 0, mode }
|
||||
const results: SearchResult[] = [
|
||||
{
|
||||
block_id: 'b1',
|
||||
note_id: 'n-rbt',
|
||||
note_title: '红黑树',
|
||||
file_path: '/数据结构/红黑树.md',
|
||||
heading_path: '数据结构 / 红黑树 / 插入操作',
|
||||
snippet: '插入后可能破坏红黑性质,需要通过变色和旋转来修复...',
|
||||
score: 0.95,
|
||||
match_type: 'hybrid',
|
||||
tags: ['数据结构', '树'],
|
||||
},
|
||||
{
|
||||
block_id: 'b2',
|
||||
note_id: 'n-rbt',
|
||||
note_title: '红黑树',
|
||||
file_path: '/数据结构/红黑树.md',
|
||||
heading_path: '数据结构 / 红黑树 / 性质',
|
||||
snippet: '红黑树是一种自平衡二叉搜索树,每个节点带有颜色属性(红或黑)...',
|
||||
score: 0.87,
|
||||
match_type: 'fts',
|
||||
tags: ['数据结构'],
|
||||
},
|
||||
{
|
||||
block_id: 'b3',
|
||||
note_id: 'n-bst',
|
||||
note_title: '二叉搜索树',
|
||||
file_path: '/数据结构/二叉搜索树.md',
|
||||
heading_path: '数据结构 / 二叉搜索树 / 基本操作',
|
||||
snippet: '二叉搜索树的插入需要先找到合适的位置,再添加新节点...',
|
||||
score: 0.72,
|
||||
match_type: 'vector',
|
||||
tags: ['数据结构', '树'],
|
||||
},
|
||||
{
|
||||
block_id: 'b4',
|
||||
note_id: 'n-deadlock',
|
||||
note_title: '死锁',
|
||||
file_path: '/操作系统/死锁.md',
|
||||
heading_path: '操作系统 / 死锁 / 必要条件',
|
||||
snippet: '死锁的四个必要条件:互斥、占有并等待、不可抢占、循环等待...',
|
||||
score: 0.45,
|
||||
match_type: 'vector',
|
||||
tags: ['操作系统'],
|
||||
},
|
||||
]
|
||||
const filtered = results.filter(
|
||||
(r) =>
|
||||
r.note_title.includes(query) ||
|
||||
r.snippet.includes(query) ||
|
||||
r.heading_path.includes(query) ||
|
||||
query.length > 1
|
||||
)
|
||||
return { results: filtered, total: filtered.length, mode }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import apiClient from './apiClient'
|
||||
import type { Skill } from '@/contracts'
|
||||
|
||||
export async function listSkills(): Promise<Skill[]> {
|
||||
try {
|
||||
return await apiClient.get('/api/skills')
|
||||
} catch {
|
||||
return mockSkills
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSkill(skillId: string): Promise<Skill> {
|
||||
return apiClient.get(`/api/skills/${skillId}`)
|
||||
}
|
||||
|
||||
export async function installSkill(skillId: string): Promise<Skill> {
|
||||
return apiClient.post('/api/skills/install', { skill_id: skillId })
|
||||
}
|
||||
|
||||
export async function enableSkill(skillId: string): Promise<Skill> {
|
||||
return apiClient.post(`/api/skills/${skillId}/enable`)
|
||||
}
|
||||
|
||||
export async function disableSkill(skillId: string): Promise<Skill> {
|
||||
return apiClient.post(`/api/skills/${skillId}/disable`)
|
||||
}
|
||||
|
||||
export async function uninstallSkill(skillId: string): Promise<void> {
|
||||
return apiClient.delete(`/api/skills/${skillId}`)
|
||||
}
|
||||
|
||||
export const mockSkills: Skill[] = [
|
||||
{
|
||||
skill_id: 'exam-review',
|
||||
name: '期末复习助手',
|
||||
version: '1.0.0',
|
||||
description: '根据课程笔记生成复习要点和练习题,帮助高效备考',
|
||||
icon: '📚',
|
||||
author: '知笔知己团队',
|
||||
permissions: ['notes.search', 'notes.read', 'tasks.create'],
|
||||
tools: ['notes.search', 'notes.read', 'tasks.create'],
|
||||
retrieval_config: { top_k: 10, rerank: true, citation: true },
|
||||
model_requirements: { capabilities: ['chat', 'tool_calling'] },
|
||||
status: 'ready',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
skill_id: 'meeting-summary',
|
||||
name: '会议纪要生成',
|
||||
version: '1.1.0',
|
||||
description: '从音频或文本中提取会议要点、行动项和待办任务',
|
||||
icon: '📝',
|
||||
author: '知笔知己团队',
|
||||
permissions: ['notes.search', 'notes.write', 'tasks.write', 'attachments.read'],
|
||||
tools: ['notes.search', 'notes.create', 'tasks.create', 'attachments.read'],
|
||||
retrieval_config: { top_k: 5, rerank: false, citation: true },
|
||||
model_requirements: { capabilities: ['chat', 'tool_calling', 'structured_output'] },
|
||||
status: 'ready',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
skill_id: 'code-explainer',
|
||||
name: '代码解读助手',
|
||||
version: '0.9.0',
|
||||
description: '分析代码片段,解释功能、复杂度和优化建议',
|
||||
icon: '💻',
|
||||
author: '社区贡献',
|
||||
permissions: ['notes.search', 'notes.read'],
|
||||
tools: ['notes.search', 'notes.read', 'rag.search'],
|
||||
retrieval_config: { top_k: 8, rerank: true, citation: true },
|
||||
model_requirements: { capabilities: ['chat', 'tool_calling'] },
|
||||
status: 'installed',
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
skill_id: 'research-assistant',
|
||||
name: '文献研究助手',
|
||||
version: '1.2.0',
|
||||
description: '自动整理文献笔记,生成研究综述和引用关系图',
|
||||
icon: '🔬',
|
||||
author: '社区贡献',
|
||||
permissions: ['notes.search', 'notes.read', 'notes.write'],
|
||||
tools: ['notes.search', 'notes.read', 'notes.create', 'rag.search'],
|
||||
retrieval_config: { top_k: 15, rerank: true, citation: true },
|
||||
model_requirements: { capabilities: ['chat', 'tool_calling', 'reasoning'] },
|
||||
status: 'dependency_missing',
|
||||
enabled: false,
|
||||
missing_dependencies: ['文献引用插件', '知识图谱插件'],
|
||||
},
|
||||
{
|
||||
skill_id: 'language-tutor',
|
||||
name: '语言学习助手',
|
||||
version: '0.5.0',
|
||||
description: '基于你的学习笔记生成语言练习和记忆卡片',
|
||||
icon: '🌍',
|
||||
author: '社区贡献',
|
||||
permissions: ['notes.search', 'notes.read', 'tasks.create'],
|
||||
tools: ['notes.search', 'notes.read', 'tasks.create'],
|
||||
retrieval_config: { top_k: 6, rerank: false, citation: false },
|
||||
model_requirements: { capabilities: ['chat'] },
|
||||
status: 'ready',
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,114 @@
|
||||
export type SseEventHandler = (event: string, data: Record<string, unknown>) => void
|
||||
|
||||
export interface SseClientOptions {
|
||||
url: string
|
||||
method?: string
|
||||
body?: unknown
|
||||
token?: string
|
||||
onEvent?: SseEventHandler
|
||||
onError?: (error: Error) => void
|
||||
onOpen?: () => void
|
||||
onDone?: () => void
|
||||
}
|
||||
|
||||
export class SseClient {
|
||||
private controller: AbortController
|
||||
private reader: ReadableStreamDefaultReader<Uint8Array> | null = null
|
||||
private options: SseClientOptions
|
||||
private buffer = ''
|
||||
private connected = false
|
||||
|
||||
constructor(options: SseClientOptions) {
|
||||
this.options = options
|
||||
this.controller = new AbortController()
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const { url, method = 'POST', body, token, onEvent, onError, onOpen, onDone } = this.options
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'text/event-stream',
|
||||
}
|
||||
if (body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal: this.controller.signal,
|
||||
})
|
||||
|
||||
if (!resp.ok || !resp.body) {
|
||||
throw new Error(`SSE connection failed: ${resp.status}`)
|
||||
}
|
||||
|
||||
this.reader = resp.body.getReader()
|
||||
this.connected = true
|
||||
onOpen?.()
|
||||
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await this.reader.read()
|
||||
if (done) break
|
||||
|
||||
this.buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
const lines = this.buffer.split('\n')
|
||||
this.buffer = lines.pop() || ''
|
||||
|
||||
let eventName = 'message'
|
||||
let dataStr = ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) {
|
||||
if (dataStr) {
|
||||
try {
|
||||
const data = JSON.parse(dataStr)
|
||||
onEvent?.(eventName, data)
|
||||
if (eventName === 'Done' || eventName === 'RunCompleted' || eventName === 'RunFailed' || eventName === 'RunCancelled') {
|
||||
onDone?.()
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed json */
|
||||
}
|
||||
eventName = 'message'
|
||||
dataStr = ''
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('event:')) {
|
||||
eventName = trimmed.slice(6).trim()
|
||||
} else if (trimmed.startsWith('data:')) {
|
||||
const d = trimmed.slice(5).trim()
|
||||
dataStr += dataStr ? '\n' + d : d
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if ((e as Error).name === 'AbortError') return
|
||||
onError?.(e as Error)
|
||||
} finally {
|
||||
this.connected = false
|
||||
this.reader = null
|
||||
}
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.controller.abort()
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
return this.connected
|
||||
}
|
||||
}
|
||||
|
||||
export default SseClient
|
||||
@@ -0,0 +1,23 @@
|
||||
import apiClient from './apiClient'
|
||||
import type { SystemStatus } from '@/contracts'
|
||||
|
||||
export async function healthCheck(): Promise<{ status: string }> {
|
||||
try {
|
||||
return await apiClient.get<{ status: string }>('/health')
|
||||
} catch {
|
||||
return { status: 'unavailable' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStatus(): Promise<SystemStatus> {
|
||||
try {
|
||||
return await apiClient.get<SystemStatus>('/api/status')
|
||||
} catch {
|
||||
return {
|
||||
name: 'notes-agent',
|
||||
version: '0.1.0',
|
||||
environment: import.meta.env.DEV ? 'development' : 'production',
|
||||
ai_core_available: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import apiClient from './apiClient'
|
||||
import type { TaskItem, TaskStatus, TaskPriority } from '@/contracts'
|
||||
|
||||
export async function listTasks(params?: {
|
||||
status?: TaskStatus
|
||||
priority?: TaskPriority
|
||||
source?: 'user' | 'note' | 'agent'
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{ items: TaskItem[]; total: number }> {
|
||||
try {
|
||||
return await apiClient.get('/api/tasks', { params })
|
||||
} catch {
|
||||
return { items: mockTasks, total: mockTasks.length }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTask(taskId: string): Promise<TaskItem> {
|
||||
return apiClient.get(`/api/tasks/${taskId}`)
|
||||
}
|
||||
|
||||
export async function createTask(data: {
|
||||
title: string
|
||||
description?: string
|
||||
priority?: TaskPriority
|
||||
due_date?: string
|
||||
note_id?: string
|
||||
}): Promise<TaskItem> {
|
||||
return apiClient.post('/api/tasks', data)
|
||||
}
|
||||
|
||||
export async function updateTask(
|
||||
taskId: string,
|
||||
data: Partial<Pick<TaskItem, 'title' | 'description' | 'status' | 'priority' | 'due_date'>>
|
||||
): Promise<TaskItem> {
|
||||
return apiClient.patch(`/api/tasks/${taskId}`, data)
|
||||
}
|
||||
|
||||
export async function deleteTask(taskId: string): Promise<void> {
|
||||
return apiClient.delete(`/api/tasks/${taskId}`)
|
||||
}
|
||||
|
||||
export const mockTasks: TaskItem[] = [
|
||||
{
|
||||
task_id: 't-1',
|
||||
title: '完成红黑树章节复习',
|
||||
description: '整理插入、删除操作的所有情况,准备期末复习',
|
||||
status: 'todo',
|
||||
priority: 'high',
|
||||
due_date: '2026-08-30T23:59:00Z',
|
||||
note_id: 'n-rbt',
|
||||
note_title: '红黑树',
|
||||
source: 'user',
|
||||
created_at: '2026-08-20T10:00:00Z',
|
||||
updated_at: '2026-08-25T14:30:00Z',
|
||||
},
|
||||
{
|
||||
task_id: 't-2',
|
||||
title: '理解死锁的银行家算法',
|
||||
description: '推导银行家算法的安全性检查过程',
|
||||
status: 'in_progress',
|
||||
priority: 'medium',
|
||||
note_id: 'n-deadlock',
|
||||
note_title: '死锁',
|
||||
source: 'agent',
|
||||
created_at: '2026-08-22T09:00:00Z',
|
||||
updated_at: '2026-08-24T16:00:00Z',
|
||||
},
|
||||
{
|
||||
task_id: 't-3',
|
||||
title: 'TCP 三次握手与四次挥手',
|
||||
description: '',
|
||||
status: 'done',
|
||||
priority: 'high',
|
||||
note_id: 'n-tcp',
|
||||
note_title: 'TCP_IP',
|
||||
source: 'user',
|
||||
created_at: '2026-08-15T08:00:00Z',
|
||||
updated_at: '2026-08-18T20:00:00Z',
|
||||
},
|
||||
{
|
||||
task_id: 't-4',
|
||||
title: 'HTTP 状态码整理',
|
||||
description: '整理常见 HTTP 状态码及含义',
|
||||
status: 'todo',
|
||||
priority: 'low',
|
||||
note_id: 'n-http',
|
||||
note_title: 'HTTP协议',
|
||||
source: 'note',
|
||||
created_at: '2026-08-10T10:00:00Z',
|
||||
updated_at: '2026-08-10T10:00:00Z',
|
||||
},
|
||||
{
|
||||
task_id: 't-5',
|
||||
title: '链表操作实现练习',
|
||||
description: '实现单链表和双向链表的基本操作',
|
||||
status: 'todo',
|
||||
priority: 'medium',
|
||||
note_id: 'n-slist',
|
||||
note_title: '单链表',
|
||||
source: 'agent',
|
||||
created_at: '2026-08-23T11:00:00Z',
|
||||
updated_at: '2026-08-23T11:00:00Z',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { FileNode } from '@/contracts'
|
||||
|
||||
// Mock workspace service for web dev mode
|
||||
// In Tauri environment this will use Tauri IPC commands
|
||||
|
||||
export interface VaultInfo {
|
||||
path: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const MOCK_VAULTS: VaultInfo[] = [
|
||||
{ path: '/Users/demo/Documents/MyVault', name: '我的知识库' },
|
||||
{ path: '/Users/demo/Documents/StudyNotes', name: '学习笔记' },
|
||||
]
|
||||
|
||||
const MOCK_FILE_TREE: FileNode[] = [
|
||||
{
|
||||
id: 'f-data',
|
||||
name: '数据结构',
|
||||
path: '/数据结构',
|
||||
type: 'folder',
|
||||
is_open: true,
|
||||
children: [
|
||||
{ id: 'n-rbt', name: '红黑树.md', path: '/数据结构/红黑树.md', type: 'file' },
|
||||
{ id: 'n-bst', name: '二叉搜索树.md', path: '/数据结构/二叉搜索树.md', type: 'file' },
|
||||
{
|
||||
id: 'f-list',
|
||||
name: '链表',
|
||||
path: '/数据结构/链表',
|
||||
type: 'folder',
|
||||
is_open: false,
|
||||
children: [
|
||||
{ id: 'n-slist', name: '单链表.md', path: '/数据结构/链表/单链表.md', type: 'file' },
|
||||
{ id: 'n-dlist', name: '双向链表.md', path: '/数据结构/链表/双向链表.md', type: 'file' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'f-os',
|
||||
name: '操作系统',
|
||||
path: '/操作系统',
|
||||
type: 'folder',
|
||||
is_open: false,
|
||||
children: [
|
||||
{ id: 'n-deadlock', name: '死锁.md', path: '/操作系统/死锁.md', type: 'file' },
|
||||
{ id: 'n-sched', name: '进程调度.md', path: '/操作系统/进程调度.md', type: 'file' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'f-net',
|
||||
name: '计算机网络',
|
||||
path: '/计算机网络',
|
||||
type: 'folder',
|
||||
is_open: false,
|
||||
children: [
|
||||
{ id: 'n-tcp', name: 'TCP_IP.md', path: '/计算机网络/TCP_IP.md', type: 'file' },
|
||||
{ id: 'n-http', name: 'HTTP协议.md', path: '/计算机网络/HTTP协议.md', type: 'file' },
|
||||
],
|
||||
},
|
||||
{ id: 'n-welcome', name: '欢迎使用知笔知己.md', path: '/欢迎使用知笔知己.md', type: 'file' },
|
||||
]
|
||||
|
||||
export function getRecentVaults(): Promise<VaultInfo[]> {
|
||||
return Promise.resolve(MOCK_VAULTS)
|
||||
}
|
||||
|
||||
export function openVault(path: string): Promise<VaultInfo> {
|
||||
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Vault'
|
||||
return Promise.resolve({ path, name })
|
||||
}
|
||||
|
||||
export function createVault(path: string, name: string): Promise<VaultInfo> {
|
||||
return Promise.resolve({ path, name })
|
||||
}
|
||||
|
||||
export function getFileTree(): Promise<FileNode[]> {
|
||||
return Promise.resolve(JSON.parse(JSON.stringify(MOCK_FILE_TREE)))
|
||||
}
|
||||
|
||||
export function readFileContent(filePath: string): Promise<string> {
|
||||
const name = filePath.split('/').pop() || 'Untitled'
|
||||
if (name === '欢迎使用知笔知己.md') {
|
||||
return Promise.resolve(`# 欢迎使用知笔知己
|
||||
|
||||
这是一款本地优先的 AI 笔记软件,支持 Markdown 编辑、智能检索、RAG 问答和 Agent 助手。
|
||||
|
||||
## 核心特性
|
||||
|
||||
- **本地优先**:所有笔记以 Markdown 格式保存在本地,数据完全由你掌控
|
||||
- **混合检索**:FTS5 全文检索 + 向量语义检索,精准定位知识
|
||||
- **AI 问答**:基于 RAG 技术,让 AI 基于你的笔记回答问题
|
||||
- **Agent 助手**:通过工具调用,AI 可以帮你管理笔记、创建任务
|
||||
- **Skill 系统**:将常用 AI 工作流保存为可复用的 Skill
|
||||
- **插件扩展**:通过 Plugin 扩展应用能力
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 在左侧文件树中创建你的第一篇笔记
|
||||
2. 使用 \`Ctrl+P\` 打开命令面板
|
||||
3. 使用搜索功能快速找到你的笔记
|
||||
4. 打开 AI 对话,开始与你的知识对话
|
||||
|
||||
> 提示:你可以在设置中配置你的模型提供商,开始使用 AI 功能。
|
||||
|
||||
## 编辑器模式
|
||||
|
||||
- **所见即所得模式**:使用 Milkdown 提供流畅的 Markdown 编辑体验
|
||||
- **源码模式**:使用 CodeMirror 6 编辑原始 Markdown 源码
|
||||
|
||||
点击右上角按钮可以切换编辑模式。
|
||||
|
||||
## 代码示例
|
||||
|
||||
\`\`\`python
|
||||
def quick_sort(arr):
|
||||
if len(arr) <= 1:
|
||||
return arr
|
||||
pivot = arr[len(arr) // 2]
|
||||
left = [x for x in arr if x < pivot]
|
||||
middle = [x for x in arr if x == pivot]
|
||||
right = [x for x in arr if x > pivot]
|
||||
return quick_sort(left) + middle + quick_sort(right)
|
||||
\`\`\`
|
||||
|
||||
## 任务列表
|
||||
|
||||
- [x] 完成项目初始化
|
||||
- [x] 设计技术架构
|
||||
- [ ] 实现前端界面
|
||||
- [ ] 接入后端 AI Core
|
||||
- [ ] 性能优化与测试
|
||||
|
||||
---
|
||||
|
||||
祝你写作愉快!
|
||||
`)
|
||||
}
|
||||
if (name === '红黑树.md') {
|
||||
return Promise.resolve(`# 红黑树
|
||||
|
||||
红黑树(Red-Black Tree)是一种自平衡二叉搜索树,每个节点带有颜色属性(红色或黑色)。
|
||||
|
||||
## 性质
|
||||
|
||||
1. 每个节点是红色或黑色
|
||||
2. 根节点是黑色
|
||||
3. 所有叶子节点(NIL)是黑色
|
||||
4. 如果一个节点是红色,则它的两个子节点都是黑色
|
||||
5. 从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点
|
||||
|
||||
这些性质确保了红黑树的关键特性:**从根到叶子的最长可能路径不会超过最短可能路径的两倍长**。
|
||||
|
||||
## 插入操作
|
||||
|
||||
插入后可能破坏红黑性质,需要通过变色和旋转来修复。
|
||||
|
||||
### 情况1:叔叔节点是红色
|
||||
|
||||
将父节点和叔叔节点设为黑色,将祖父节点设为红色,当前节点上移到祖父节点,继续向上调整。
|
||||
|
||||
### 情况2:叔叔节点是黑色,且当前节点是右孩子
|
||||
|
||||
以父节点为支点左旋,将当前节点转换为左孩子,进入情况3。
|
||||
|
||||
### 情况3:叔叔节点是黑色,且当前节点是左孩子
|
||||
|
||||
以祖父节点为支点右旋,将父节点设为黑色,祖父节点设为红色。
|
||||
|
||||
## 与 AVL 树对比
|
||||
|
||||
| 特性 | AVL 树 | 红黑树 |
|
||||
|------|--------|--------|
|
||||
| 平衡严格度 | 高度差 ≤ 1 | 黑色高度相同 |
|
||||
| 查找速度 | 更快 | 略慢 |
|
||||
| 插入删除 | 旋转更多 | 旋转更少 |
|
||||
| 适用场景 | 读多写少 | 读写均衡 |
|
||||
|
||||
## 应用场景
|
||||
|
||||
- C++ STL 的 map/set
|
||||
- Java 的 TreeMap
|
||||
- Linux 内核的完全公平调度器
|
||||
`)
|
||||
}
|
||||
return Promise.resolve(`# ${name.replace('.md', '')}
|
||||
|
||||
这是一篇示例笔记。
|
||||
|
||||
## 第一部分
|
||||
|
||||
这里是笔记的内容。
|
||||
|
||||
## 第二部分
|
||||
|
||||
更多内容...
|
||||
|
||||
> 引用内容示例
|
||||
|
||||
\`\`\`javascript
|
||||
console.log('Hello, Notes Agent!');
|
||||
\`\`\`
|
||||
`)
|
||||
}
|
||||
|
||||
export function saveFileContent(filePath: string, content: string): Promise<void> {
|
||||
console.debug(`[workspaceService] Save ${filePath}, ${content.length} chars`)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
export function createFile(folderPath: string, name: string, content = ''): Promise<FileNode> {
|
||||
const path = `${folderPath}/${name}`
|
||||
const id = `n-${Date.now()}`
|
||||
return Promise.resolve({ id, name, path, type: 'file' })
|
||||
}
|
||||
|
||||
export function createFolder(parentPath: string, name: string): Promise<FileNode> {
|
||||
const path = `${parentPath}/${name}`
|
||||
const id = `f-${Date.now()}`
|
||||
return Promise.resolve({ id, name, path, type: 'folder', is_open: true, children: [] })
|
||||
}
|
||||
|
||||
export function renameFile(oldPath: string, newName: string): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
export function deleteFile(path: string): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
export function moveFile(sourcePath: string, targetPath: string): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest, ToolCall } from '@/contracts'
|
||||
import { mockAgentRuns, mockAgentEvents, mockTools, mockPermissionRequest } from '@/services/agentService'
|
||||
import * as agentService from '@/services/agentService'
|
||||
|
||||
export const useAgentStore = defineStore('agent', () => {
|
||||
const runs = ref<AgentRun[]>(mockAgentRuns)
|
||||
const activeRunId = ref<string | null>('run-1')
|
||||
const events = ref<AgentEvent[]>(mockAgentEvents.filter((e) => e.run_id === 'run-1'))
|
||||
const tools = ref<ToolDefinition[]>(mockTools)
|
||||
const isCreating = ref(false)
|
||||
const isRunning = ref(false)
|
||||
const permissionRequest = ref<PermissionRequest | null>(null)
|
||||
const toolCalls = ref<ToolCall[]>([])
|
||||
|
||||
const activeRun = computed(() =>
|
||||
runs.value.find((r) => r.run_id === activeRunId.value) || null
|
||||
)
|
||||
|
||||
const sortedRuns = computed(() =>
|
||||
[...runs.value].sort((a, b) => (b.started_at || '').localeCompare(a.started_at || ''))
|
||||
)
|
||||
|
||||
const currentStep = computed(() => {
|
||||
const tc = events.value.filter((e) => e.event === 'ToolCall').length
|
||||
return tc
|
||||
})
|
||||
|
||||
async function loadTools() {
|
||||
tools.value = await agentService.listTools()
|
||||
}
|
||||
|
||||
async function loadRuns() {
|
||||
const resp = await agentService.listAgentRuns()
|
||||
runs.value = resp.items
|
||||
}
|
||||
|
||||
async function loadRun(runId: string) {
|
||||
activeRunId.value = runId
|
||||
events.value = mockAgentEvents.filter((e) => e.run_id === runId)
|
||||
toolCalls.value = []
|
||||
for (const evt of events.value) {
|
||||
if (evt.event === 'ToolCall') {
|
||||
const data = evt.data as any
|
||||
toolCalls.value.push({
|
||||
tool_call_id: data.tool_call_id,
|
||||
name: data.name,
|
||||
parameters: data.parameters,
|
||||
status: data.status || 'completed',
|
||||
started_at: evt.timestamp,
|
||||
})
|
||||
} else if (evt.event === 'ToolResult') {
|
||||
const data = evt.data as any
|
||||
const tc = toolCalls.value.find((t) => t.tool_call_id === data.tool_call_id)
|
||||
if (tc) {
|
||||
tc.status = data.status
|
||||
tc.result = data.result
|
||||
tc.completed_at = evt.timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createRun(request: agentService.CreateAgentRunRequest) {
|
||||
isCreating.value = true
|
||||
try {
|
||||
const run = await agentService.createAgentRun(request)
|
||||
runs.value.unshift(run)
|
||||
activeRunId.value = run.run_id
|
||||
events.value = [{
|
||||
event: 'RunStarted',
|
||||
sequence: 1,
|
||||
run_id: run.run_id,
|
||||
data: { task: request.task },
|
||||
timestamp: new Date().toISOString(),
|
||||
}]
|
||||
isRunning.value = true
|
||||
// Mock events streaming
|
||||
simulateRun(run.run_id)
|
||||
return run
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function simulateRun(runId: string) {
|
||||
const runEvents: AgentEvent[] = [
|
||||
{ event: 'ThinkingDelta', sequence: 2, run_id: runId, data: { text: '我需要先搜索相关笔记...' }, timestamp: new Date().toISOString() },
|
||||
{ event: 'ToolCall', sequence: 3, run_id: runId, data: { tool_call_id: 'tc-mock-1', name: 'notes.search', parameters: { query: '红黑树', limit: 5 }, status: 'running' }, timestamp: new Date().toISOString() },
|
||||
{ event: 'ToolResult', sequence: 4, run_id: runId, data: { tool_call_id: 'tc-mock-1', name: 'notes.search', status: 'completed', result: '找到 5 条相关结果' }, timestamp: new Date().toISOString() },
|
||||
{ event: 'TextDelta', sequence: 5, run_id: runId, data: { text: '根据你的笔记,以下是...' }, timestamp: new Date().toISOString() },
|
||||
{ event: 'RunCompleted', sequence: 6, run_id: runId, data: { message: 'Task completed successfully' }, timestamp: new Date().toISOString() },
|
||||
]
|
||||
let idx = 0
|
||||
const push = () => {
|
||||
if (idx >= runEvents.length) {
|
||||
isRunning.value = false
|
||||
return
|
||||
}
|
||||
events.value.push(runEvents[idx])
|
||||
idx++
|
||||
setTimeout(push, 800)
|
||||
}
|
||||
setTimeout(push, 500)
|
||||
}
|
||||
|
||||
async function cancelRun(runId: string) {
|
||||
await agentService.cancelAgentRun(runId)
|
||||
const run = runs.value.find((r) => r.run_id === runId)
|
||||
if (run) run.status = 'cancelled'
|
||||
isRunning.value = false
|
||||
}
|
||||
|
||||
async function respondPermission(decision: 'allow' | 'deny', scope: 'once' | 'session' | 'always' = 'once') {
|
||||
if (!activeRunId.value || !permissionRequest.value) return
|
||||
await agentService.respondToPermission(activeRunId.value, permissionRequest.value.request_id, decision, scope)
|
||||
permissionRequest.value = null
|
||||
}
|
||||
|
||||
function showPermissionDemo() {
|
||||
permissionRequest.value = mockPermissionRequest
|
||||
}
|
||||
|
||||
return {
|
||||
runs,
|
||||
activeRunId,
|
||||
activeRun,
|
||||
sortedRuns,
|
||||
events,
|
||||
tools,
|
||||
isCreating,
|
||||
isRunning,
|
||||
permissionRequest,
|
||||
toolCalls,
|
||||
currentStep,
|
||||
loadTools,
|
||||
loadRuns,
|
||||
loadRun,
|
||||
createRun,
|
||||
cancelRun,
|
||||
respondPermission,
|
||||
showPermissionDemo,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { ChatMessage, Conversation, Citation } from '@/contracts'
|
||||
import { mockConversations, mockMessages } from '@/services/chatService'
|
||||
import type { SseClient } from '@/services/sseClient'
|
||||
|
||||
export const useChatStore = defineStore('chat', () => {
|
||||
const conversations = ref<Conversation[]>(mockConversations)
|
||||
const activeConversationId = ref<string | null>('conv-1')
|
||||
const messages = ref<ChatMessage[]>(mockMessages['conv-1'] || [])
|
||||
const isStreaming = ref(false)
|
||||
const inputText = ref('')
|
||||
const useRag = ref(true)
|
||||
const selectedSkillId = ref<string | null>(null)
|
||||
const selectedProviderId = ref('mock-provider')
|
||||
const selectedModel = ref('mock-1')
|
||||
let sseClient: SseClient | null = null
|
||||
|
||||
const activeConversation = computed(() =>
|
||||
conversations.value.find((c) => c.conversation_id === activeConversationId.value) || null
|
||||
)
|
||||
|
||||
const sortedConversations = computed(() =>
|
||||
[...conversations.value].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
||||
)
|
||||
|
||||
async function setActiveConversation(id: string) {
|
||||
activeConversationId.value = id
|
||||
messages.value = mockMessages[id] || []
|
||||
}
|
||||
|
||||
async function sendMessage(text: string) {
|
||||
if (!text.trim() || isStreaming.value) return
|
||||
const conversationId = activeConversationId.value || `conv-${Date.now()}`
|
||||
|
||||
if (!activeConversationId.value) {
|
||||
const newConv: Conversation = {
|
||||
conversation_id,
|
||||
title: text.slice(0, 30),
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
message_count: 0,
|
||||
}
|
||||
conversations.value.unshift(newConv)
|
||||
activeConversationId.value = conversationId
|
||||
}
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
message_id: `msg-${Date.now()}`,
|
||||
conversation_id: conversationId,
|
||||
role: 'user',
|
||||
content: text,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
messages.value.push(userMsg)
|
||||
inputText.value = ''
|
||||
isStreaming.value = true
|
||||
|
||||
const aiMsg: ChatMessage = {
|
||||
message_id: `msg-${Date.now() + 1}`,
|
||||
conversation_id: conversationId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
created_at: new Date().toISOString(),
|
||||
citations: [],
|
||||
tool_calls: [],
|
||||
}
|
||||
messages.value.push(aiMsg)
|
||||
|
||||
// Mock streaming
|
||||
const fullText =
|
||||
'这是一个模拟的 AI 回复。在实际环境中,这里会通过 SSE 接收后端 AI Core 的流式输出,基于 RAG 引擎和你的知识库生成回答,并附带来源引用。\n\n**要点总结:**\n1. 这是演示用的流式输出\n2. 实际会调用 ModelEvent SSE\n3. 支持 Citation、Tool Call 等事件\n\n你可以在设置中配置真实的模型 Provider 来启用完整功能。'
|
||||
const citations: Citation[] = [
|
||||
{
|
||||
note_id: 'n-rbt',
|
||||
block_id: 'b1',
|
||||
file_path: '/数据结构/红黑树.md',
|
||||
heading_path: '数据结构 / 红黑树 / 概述',
|
||||
content: '红黑树是一种自平衡二叉搜索树...',
|
||||
},
|
||||
]
|
||||
|
||||
let i = 0
|
||||
const interval = setInterval(() => {
|
||||
if (i >= fullText.length) {
|
||||
clearInterval(interval)
|
||||
isStreaming.value = false
|
||||
aiMsg.citations = citations
|
||||
return
|
||||
}
|
||||
const chunk = fullText.slice(i, i + 3)
|
||||
aiMsg.content += chunk
|
||||
i += 3
|
||||
}, 20)
|
||||
}
|
||||
|
||||
function stopGeneration() {
|
||||
if (sseClient) {
|
||||
sseClient.cancel()
|
||||
sseClient = null
|
||||
}
|
||||
isStreaming.value = false
|
||||
}
|
||||
|
||||
function createNewConversation() {
|
||||
const newConv: Conversation = {
|
||||
conversation_id: `conv-${Date.now()}`,
|
||||
title: '新对话',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
message_count: 0,
|
||||
}
|
||||
conversations.value.unshift(newConv)
|
||||
activeConversationId.value = newConv.conversation_id
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
function deleteConversation(id: string) {
|
||||
const idx = conversations.value.findIndex((c) => c.conversation_id === id)
|
||||
if (idx > -1) {
|
||||
conversations.value.splice(idx, 1)
|
||||
if (activeConversationId.value === id) {
|
||||
activeConversationId.value = conversations.value[0]?.conversation_id || null
|
||||
messages.value = conversations.value[0] ? mockMessages[conversations.value[0].conversation_id] || [] : []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
conversations,
|
||||
activeConversationId,
|
||||
activeConversation,
|
||||
sortedConversations,
|
||||
messages,
|
||||
isStreaming,
|
||||
inputText,
|
||||
useRag,
|
||||
selectedSkillId,
|
||||
selectedProviderId,
|
||||
selectedModel,
|
||||
setActiveConversation,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
createNewConversation,
|
||||
deleteConversation,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { SaveStatus } from '@/contracts'
|
||||
import * as workspaceService from '@/services/workspaceService'
|
||||
|
||||
export const useEditorStore = defineStore('editor', () => {
|
||||
const mode = ref<'wysiwyg' | 'source'>('wysiwyg')
|
||||
const content = ref('')
|
||||
const saveStatus = ref<SaveStatus>('idle')
|
||||
const lastSavedAt = ref<string | null>(null)
|
||||
const currentNoteId = ref<string | null>(null)
|
||||
const currentFilePath = ref<string | null>(null)
|
||||
const highlightBlockId = ref<string | null>(null)
|
||||
const cursorPosition = ref({ line: 0, column: 0 })
|
||||
|
||||
const wordCount = computed(() => {
|
||||
const text = content.value.replace(/[#*`>\-_\[\]()!]/g, '')
|
||||
return text.trim().length
|
||||
})
|
||||
|
||||
const lineCount = computed(() => content.value.split('\n').length)
|
||||
|
||||
function setMode(newMode: 'wysiwyg' | 'source') {
|
||||
mode.value = newMode
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
mode.value = mode.value === 'wysiwyg' ? 'source' : 'wysiwyg'
|
||||
}
|
||||
|
||||
function updateContent(newContent: string) {
|
||||
content.value = newContent
|
||||
if (saveStatus.value === 'saved' || saveStatus.value === 'idle') {
|
||||
saveStatus.value = 'dirty'
|
||||
}
|
||||
}
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function scheduleAutoSave(delay = 1500) {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => {
|
||||
void save()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!currentFilePath.value) return
|
||||
if (saveStatus.value === 'saving') return
|
||||
saveStatus.value = 'saving'
|
||||
try {
|
||||
await workspaceService.saveFileContent(currentFilePath.value, content.value)
|
||||
saveStatus.value = 'saved'
|
||||
lastSavedAt.value = new Date().toISOString()
|
||||
} catch {
|
||||
saveStatus.value = 'save_failed'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFile(filePath: string) {
|
||||
currentFilePath.value = filePath
|
||||
saveStatus.value = 'saving'
|
||||
try {
|
||||
content.value = await workspaceService.readFileContent(filePath)
|
||||
saveStatus.value = 'saved'
|
||||
lastSavedAt.value = new Date().toISOString()
|
||||
} catch {
|
||||
content.value = ''
|
||||
saveStatus.value = 'idle'
|
||||
}
|
||||
highlightBlockId.value = null
|
||||
}
|
||||
|
||||
function highlightBlock(blockId: string) {
|
||||
highlightBlockId.value = blockId
|
||||
setTimeout(() => {
|
||||
if (highlightBlockId.value === blockId) {
|
||||
highlightBlockId.value = null
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function setExternalChanged() {
|
||||
if (saveStatus.value === 'dirty') {
|
||||
saveStatus.value = 'conflict'
|
||||
} else {
|
||||
saveStatus.value = 'external_changed'
|
||||
}
|
||||
}
|
||||
|
||||
function closeFile() {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
currentFilePath.value = null
|
||||
currentNoteId.value = null
|
||||
content.value = ''
|
||||
saveStatus.value = 'idle'
|
||||
lastSavedAt.value = null
|
||||
highlightBlockId.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
content,
|
||||
saveStatus,
|
||||
lastSavedAt,
|
||||
currentNoteId,
|
||||
currentFilePath,
|
||||
highlightBlockId,
|
||||
cursorPosition,
|
||||
wordCount,
|
||||
lineCount,
|
||||
setMode,
|
||||
toggleMode,
|
||||
updateContent,
|
||||
scheduleAutoSave,
|
||||
save,
|
||||
loadFile,
|
||||
highlightBlock,
|
||||
setExternalChanged,
|
||||
closeFile,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
export { useWorkspaceStore } from './workspace'
|
||||
export { useEditorStore } from './editor'
|
||||
export { useSearchStore } from './search'
|
||||
export { useChatStore } from './chat'
|
||||
export { useAgentStore } from './agent'
|
||||
export { useSkillStore } from './skill'
|
||||
export { usePluginStore } from './plugin'
|
||||
export { useTaskStore } from './task'
|
||||
export { useThemeStore } from './theme'
|
||||
export { useProviderStore } from './provider'
|
||||
export { useSettingsStore } from './settings'
|
||||
@@ -0,0 +1,69 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Plugin } from '@/contracts'
|
||||
import { mockPlugins } from '@/services/pluginService'
|
||||
|
||||
export const usePluginStore = defineStore('plugin', () => {
|
||||
const plugins = ref<Plugin[]>(mockPlugins)
|
||||
const selectedPluginId = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
|
||||
const selectedPlugin = computed(() =>
|
||||
plugins.value.find((p) => p.plugin_id === selectedPluginId.value) || null
|
||||
)
|
||||
|
||||
const enabledPlugins = computed(() => plugins.value.filter((p) => p.enabled))
|
||||
const readyPlugins = computed(() => plugins.value.filter((p) => p.status === 'ready'))
|
||||
const errorPlugins = computed(() => plugins.value.filter((p) => p.status === 'error'))
|
||||
|
||||
async function loadPlugins() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const { listPlugins } = await import('@/services/pluginService')
|
||||
plugins.value = await listPlugins()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectPlugin(pluginId: string | null) {
|
||||
selectedPluginId.value = pluginId
|
||||
}
|
||||
|
||||
async function enablePlugin(pluginId: string) {
|
||||
const plugin = plugins.value.find((p) => p.plugin_id === pluginId)
|
||||
if (plugin) {
|
||||
plugin.enabled = true
|
||||
plugin.status = 'ready'
|
||||
}
|
||||
}
|
||||
|
||||
async function disablePlugin(pluginId: string) {
|
||||
const plugin = plugins.value.find((p) => p.plugin_id === pluginId)
|
||||
if (plugin) {
|
||||
plugin.enabled = false
|
||||
plugin.status = 'disabled'
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallPlugin(pluginId: string) {
|
||||
const idx = plugins.value.findIndex((p) => p.plugin_id === pluginId)
|
||||
if (idx > -1) plugins.value.splice(idx, 1)
|
||||
if (selectedPluginId.value === pluginId) selectedPluginId.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
plugins,
|
||||
selectedPluginId,
|
||||
selectedPlugin,
|
||||
enabledPlugins,
|
||||
readyPlugins,
|
||||
errorPlugins,
|
||||
isLoading,
|
||||
loadPlugins,
|
||||
selectPlugin,
|
||||
enablePlugin,
|
||||
disablePlugin,
|
||||
uninstallPlugin,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { ProviderConfig, ModelInfo } from '@/contracts'
|
||||
import { mockProviders, mockModels } from '@/services/providerService'
|
||||
|
||||
export const useProviderStore = defineStore('provider', () => {
|
||||
const providers = ref<ProviderConfig[]>(mockProviders)
|
||||
const modelsByProvider = ref<Record<string, ModelInfo[]>>(mockModels)
|
||||
const defaultProviderId = ref('mock-provider')
|
||||
const isLoading = ref(false)
|
||||
|
||||
const enabledProviders = computed(() => providers.value.filter((p) => p.enabled))
|
||||
const defaultProvider = computed(() =>
|
||||
providers.value.find((p) => p.provider_id === defaultProviderId.value) || null
|
||||
)
|
||||
|
||||
async function loadProviders() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const { listProviders } = await import('@/services/providerService')
|
||||
providers.value = await listProviders()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModels(providerId: string) {
|
||||
const { listModels } = await import('@/services/providerService')
|
||||
modelsByProvider.value[providerId] = await listModels(providerId)
|
||||
}
|
||||
|
||||
async function addProvider(data: Omit<ProviderConfig, 'provider_id'> & { api_key?: string }) {
|
||||
const newProvider: ProviderConfig = {
|
||||
...data,
|
||||
provider_id: `prov-${Date.now()}`,
|
||||
}
|
||||
providers.value.push(newProvider)
|
||||
return newProvider
|
||||
}
|
||||
|
||||
async function updateProvider(providerId: string, data: Partial<ProviderConfig>) {
|
||||
const p = providers.value.find((p) => p.provider_id === providerId)
|
||||
if (p) Object.assign(p, data)
|
||||
}
|
||||
|
||||
async function deleteProvider(providerId: string) {
|
||||
const idx = providers.value.findIndex((p) => p.provider_id === providerId)
|
||||
if (idx > -1) providers.value.splice(idx, 1)
|
||||
delete modelsByProvider.value[providerId]
|
||||
}
|
||||
|
||||
async function testProvider(providerId: string): Promise<{ success: boolean; latency_ms?: number; error?: string }> {
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
const p = providers.value.find((p) => p.provider_id === providerId)
|
||||
if (p?.enabled && p.has_credential) {
|
||||
return { success: true, latency_ms: 230 + Math.floor(Math.random() * 200) }
|
||||
}
|
||||
return { success: false, error: '认证失败,请检查 API Key' }
|
||||
}
|
||||
|
||||
function setDefaultProvider(providerId: string) {
|
||||
defaultProviderId.value = providerId
|
||||
}
|
||||
|
||||
return {
|
||||
providers,
|
||||
modelsByProvider,
|
||||
defaultProviderId,
|
||||
enabledProviders,
|
||||
defaultProvider,
|
||||
isLoading,
|
||||
loadProviders,
|
||||
loadModels,
|
||||
addProvider,
|
||||
updateProvider,
|
||||
deleteProvider,
|
||||
testProvider,
|
||||
setDefaultProvider,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { SearchResult, SearchRequest } from '@/contracts'
|
||||
import * as searchService from '@/services/searchService'
|
||||
|
||||
export const useSearchStore = defineStore('search', () => {
|
||||
const query = ref('')
|
||||
const mode = ref<'fts' | 'vector' | 'hybrid'>('hybrid')
|
||||
const results = ref<SearchResult[]>([])
|
||||
const total = ref(0)
|
||||
const isSearching = ref(false)
|
||||
const selectedIndex = ref(0)
|
||||
const recentQueries = ref<string[]>(['红黑树', '死锁', 'TCP三次握手'])
|
||||
const error = ref<string | null>(null)
|
||||
const vectorUnavailable = ref(false)
|
||||
|
||||
async function doSearch(request: SearchRequest) {
|
||||
query.value = request.query
|
||||
mode.value = request.mode || 'hybrid'
|
||||
isSearching.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const resp = await searchService.searchMock(request.query, request.mode || 'hybrid')
|
||||
results.value = resp.results
|
||||
total.value = resp.total
|
||||
selectedIndex.value = 0
|
||||
} catch (e: any) {
|
||||
error.value = e.message || '搜索失败'
|
||||
results.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
isSearching.value = false
|
||||
}
|
||||
|
||||
if (request.query && !recentQueries.value.includes(request.query)) {
|
||||
recentQueries.value.unshift(request.query)
|
||||
if (recentQueries.value.length > 10) recentQueries.value.pop()
|
||||
}
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
results.value = []
|
||||
query.value = ''
|
||||
total.value = 0
|
||||
selectedIndex.value = 0
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function selectNext() {
|
||||
if (selectedIndex.value < results.value.length - 1) selectedIndex.value++
|
||||
}
|
||||
|
||||
function selectPrev() {
|
||||
if (selectedIndex.value > 0) selectedIndex.value--
|
||||
}
|
||||
|
||||
function setMode(m: 'fts' | 'vector' | 'hybrid') {
|
||||
mode.value = m
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
mode,
|
||||
results,
|
||||
total,
|
||||
isSearching,
|
||||
selectedIndex,
|
||||
recentQueries,
|
||||
error,
|
||||
vectorUnavailable,
|
||||
doSearch,
|
||||
clearResults,
|
||||
selectNext,
|
||||
selectPrev,
|
||||
setMode,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { AiCoreStatus, IndexStatus } from '@/contracts'
|
||||
import { mockIndexStatus } from '@/services/indexService'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
// General
|
||||
const restoreLastVault = ref(true)
|
||||
const autoSaveInterval = ref(1500)
|
||||
const language = ref<'zh-CN' | 'en'>('zh-CN')
|
||||
const appVersion = ref('0.1.0')
|
||||
const aiCoreVersion = ref('0.1.0')
|
||||
|
||||
// Editor
|
||||
const defaultEditorMode = ref<'wysiwyg' | 'source'>('wysiwyg')
|
||||
const editorFontSize = ref(15)
|
||||
const editorLineHeight = ref(1.7)
|
||||
const editorLineWidth = ref(80)
|
||||
const spellCheck = ref(false)
|
||||
|
||||
// AI Core
|
||||
const aiCoreStatus = ref<AiCoreStatus>('running')
|
||||
const aiCoreAddress = ref('http://127.0.0.1:8000')
|
||||
|
||||
// Index
|
||||
const indexStatus = ref<IndexStatus>(mockIndexStatus)
|
||||
|
||||
// Permissions
|
||||
const permissionPolicy = ref<Record<string, 'allow' | 'confirm' | 'deny'>>({
|
||||
'notes.read': 'allow',
|
||||
'notes.search': 'allow',
|
||||
'notes.write': 'confirm',
|
||||
'notes.delete': 'confirm',
|
||||
'tasks.read': 'allow',
|
||||
'tasks.write': 'confirm',
|
||||
'attachments.read': 'confirm',
|
||||
'network.request': 'confirm',
|
||||
'secrets.use': 'confirm',
|
||||
})
|
||||
|
||||
function setAutoSaveInterval(ms: number) {
|
||||
autoSaveInterval.value = ms
|
||||
}
|
||||
|
||||
function setDefaultEditorMode(mode: 'wysiwyg' | 'source') {
|
||||
defaultEditorMode.value = mode
|
||||
}
|
||||
|
||||
function setPermission(permission: string, policy: 'allow' | 'confirm' | 'deny') {
|
||||
permissionPolicy.value[permission] = policy
|
||||
}
|
||||
|
||||
function setAiCoreStatus(status: AiCoreStatus) {
|
||||
aiCoreStatus.value = status
|
||||
}
|
||||
|
||||
async function restartAiCore(): Promise<boolean> {
|
||||
aiCoreStatus.value = 'starting'
|
||||
await new Promise((r) => setTimeout(r, 1500))
|
||||
aiCoreStatus.value = 'running'
|
||||
return true
|
||||
}
|
||||
|
||||
async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full') {
|
||||
indexStatus.value.status = 'indexing'
|
||||
setTimeout(() => {
|
||||
indexStatus.value.status = 'idle'
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
return {
|
||||
restoreLastVault,
|
||||
autoSaveInterval,
|
||||
language,
|
||||
appVersion,
|
||||
aiCoreVersion,
|
||||
defaultEditorMode,
|
||||
editorFontSize,
|
||||
editorLineHeight,
|
||||
editorLineWidth,
|
||||
spellCheck,
|
||||
aiCoreStatus,
|
||||
aiCoreAddress,
|
||||
indexStatus,
|
||||
permissionPolicy,
|
||||
setAutoSaveInterval,
|
||||
setDefaultEditorMode,
|
||||
setPermission,
|
||||
setAiCoreStatus,
|
||||
restartAiCore,
|
||||
rebuildIndex,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Skill } from '@/contracts'
|
||||
import { mockSkills } from '@/services/skillService'
|
||||
|
||||
export const useSkillStore = defineStore('skill', () => {
|
||||
const skills = ref<Skill[]>(mockSkills)
|
||||
const selectedSkillId = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
|
||||
const selectedSkill = computed(() =>
|
||||
skills.value.find((s) => s.skill_id === selectedSkillId.value) || null
|
||||
)
|
||||
|
||||
const enabledSkills = computed(() => skills.value.filter((s) => s.enabled))
|
||||
const installedSkills = computed(() => skills.value.filter((s) => s.status !== 'error'))
|
||||
const readySkills = computed(() => skills.value.filter((s) => s.status === 'ready'))
|
||||
|
||||
async function loadSkills() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const { listSkills } = await import('@/services/skillService')
|
||||
skills.value = await listSkills()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectSkill(skillId: string | null) {
|
||||
selectedSkillId.value = skillId
|
||||
}
|
||||
|
||||
async function enableSkill(skillId: string) {
|
||||
const skill = skills.value.find((s) => s.skill_id === skillId)
|
||||
if (skill) {
|
||||
skill.enabled = true
|
||||
skill.status = 'ready'
|
||||
}
|
||||
}
|
||||
|
||||
async function disableSkill(skillId: string) {
|
||||
const skill = skills.value.find((s) => s.skill_id === skillId)
|
||||
if (skill) {
|
||||
skill.enabled = false
|
||||
skill.status = 'disabled'
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallSkill(skillId: string) {
|
||||
const idx = skills.value.findIndex((s) => s.skill_id === skillId)
|
||||
if (idx > -1) skills.value.splice(idx, 1)
|
||||
if (selectedSkillId.value === skillId) selectedSkillId.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
skills,
|
||||
selectedSkillId,
|
||||
selectedSkill,
|
||||
enabledSkills,
|
||||
installedSkills,
|
||||
readySkills,
|
||||
isLoading,
|
||||
loadSkills,
|
||||
selectSkill,
|
||||
enableSkill,
|
||||
disableSkill,
|
||||
uninstallSkill,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { TaskItem, TaskStatus, TaskPriority, TaskSource } from '@/contracts'
|
||||
import { mockTasks } from '@/services/taskService'
|
||||
|
||||
export const useTaskStore = defineStore('task', () => {
|
||||
const tasks = ref<TaskItem[]>(mockTasks)
|
||||
const filterStatus = ref<TaskStatus | 'all'>('all')
|
||||
const filterPriority = ref<TaskPriority | 'all'>('all')
|
||||
const filterSource = ref<TaskSource | 'all'>('all')
|
||||
const isLoading = ref(false)
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
return tasks.value.filter((t) => {
|
||||
if (filterStatus.value !== 'all' && t.status !== filterStatus.value) return false
|
||||
if (filterPriority.value !== 'all' && t.priority !== filterPriority.value) return false
|
||||
if (filterSource.value !== 'all' && t.source !== filterSource.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const todoTasks = computed(() => tasks.value.filter((t) => t.status === 'todo'))
|
||||
const inProgressTasks = computed(() => tasks.value.filter((t) => t.status === 'in_progress'))
|
||||
const doneTasks = computed(() => tasks.value.filter((t) => t.status === 'done'))
|
||||
|
||||
async function loadTasks() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const { listTasks } = await import('@/services/taskService')
|
||||
const resp = await listTasks()
|
||||
tasks.value = resp.items
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createTask(data: { title: string; description?: string; priority?: TaskPriority; due_date?: string; note_id?: string }) {
|
||||
const newTask: TaskItem = {
|
||||
task_id: `t-${Date.now()}`,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
status: 'todo',
|
||||
priority: data.priority || 'medium',
|
||||
due_date: data.due_date,
|
||||
note_id: data.note_id,
|
||||
source: 'user',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
tasks.value.unshift(newTask)
|
||||
return newTask
|
||||
}
|
||||
|
||||
async function updateTask(taskId: string, data: Partial<Pick<TaskItem, 'title' | 'description' | 'status' | 'priority' | 'due_date'>>) {
|
||||
const task = tasks.value.find((t) => t.task_id === taskId)
|
||||
if (task) {
|
||||
Object.assign(task, data)
|
||||
task.updated_at = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTask(taskId: string) {
|
||||
const idx = tasks.value.findIndex((t) => t.task_id === taskId)
|
||||
if (idx > -1) tasks.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
function setFilterStatus(s: TaskStatus | 'all') { filterStatus.value = s }
|
||||
function setFilterPriority(p: TaskPriority | 'all') { filterPriority.value = p }
|
||||
function setFilterSource(s: TaskSource | 'all') { filterSource.value = s }
|
||||
|
||||
return {
|
||||
tasks,
|
||||
filterStatus,
|
||||
filterPriority,
|
||||
filterSource,
|
||||
filteredTasks,
|
||||
todoTasks,
|
||||
inProgressTasks,
|
||||
doneTasks,
|
||||
isLoading,
|
||||
loadTasks,
|
||||
createTask,
|
||||
updateTask,
|
||||
deleteTask,
|
||||
setFilterStatus,
|
||||
setFilterPriority,
|
||||
setFilterSource,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { ThemeConfig } from '@/contracts'
|
||||
|
||||
const builtinThemes: ThemeConfig[] = [
|
||||
{ theme_id: 'light', name: '浅色', version: '1.0.0', description: '默认浅色主题', is_dark: false, builtin: true },
|
||||
{ theme_id: 'dark', name: '深色', version: '1.0.0', description: '默认深色主题', is_dark: true, builtin: true },
|
||||
{ theme_id: 'sepia', name: '护眼', version: '1.0.0', description: '护眼暖色调', is_dark: false, builtin: true },
|
||||
]
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const themes = ref<ThemeConfig[]>(builtinThemes)
|
||||
const currentThemeId = ref<string>('light')
|
||||
const fontEditorSize = ref(15)
|
||||
const fontEditorFamily = ref('system-ui')
|
||||
const lineHeight = ref(1.7)
|
||||
|
||||
const currentTheme = computed(() =>
|
||||
themes.value.find((t) => t.theme_id === currentThemeId.value) || themes.value[0]
|
||||
)
|
||||
|
||||
const isDark = computed(() => currentTheme.value?.is_dark || false)
|
||||
|
||||
function applyTheme(themeId: string) {
|
||||
const theme = themes.value.find((t) => t.theme_id === themeId)
|
||||
if (!theme) return
|
||||
currentThemeId.value = themeId
|
||||
const root = document.documentElement
|
||||
if (theme.is_dark) {
|
||||
root.setAttribute('data-theme', 'dark')
|
||||
} else if (themeId === 'sepia') {
|
||||
root.setAttribute('data-theme', 'sepia')
|
||||
} else {
|
||||
root.setAttribute('data-theme', 'light')
|
||||
}
|
||||
localStorage.setItem('theme', themeId)
|
||||
}
|
||||
|
||||
function initTheme() {
|
||||
const saved = localStorage.getItem('theme')
|
||||
if (saved && themes.value.find((t) => t.theme_id === saved)) {
|
||||
applyTheme(saved)
|
||||
return
|
||||
}
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
applyTheme(prefersDark ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
applyTheme(isDark.value ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
function resetToDefault() {
|
||||
applyTheme('light')
|
||||
fontEditorSize.value = 15
|
||||
fontEditorFamily.value = 'system-ui'
|
||||
lineHeight.value = 1.7
|
||||
}
|
||||
|
||||
watch(fontEditorSize, (v) => {
|
||||
document.documentElement.style.setProperty('--font-editor-size', `${v}px`)
|
||||
})
|
||||
|
||||
watch(lineHeight, (v) => {
|
||||
document.documentElement.style.setProperty('--font-editor-line-height', String(v))
|
||||
})
|
||||
|
||||
return {
|
||||
themes,
|
||||
currentThemeId,
|
||||
currentTheme,
|
||||
isDark,
|
||||
fontEditorSize,
|
||||
fontEditorFamily,
|
||||
lineHeight,
|
||||
applyTheme,
|
||||
initTheme,
|
||||
toggleTheme,
|
||||
resetToDefault,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { FileNode } from '@/contracts'
|
||||
import * as workspaceService from '@/services/workspaceService'
|
||||
|
||||
export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
const vaultPath = ref('')
|
||||
const vaultName = ref('')
|
||||
const fileTree = ref<FileNode[]>([])
|
||||
const openFiles = ref<string[]>([])
|
||||
const activeFilePath = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const hasVault = ref(false)
|
||||
const recentVaults = ref<{ path: string; name: string }[]>([])
|
||||
|
||||
const activeFile = computed(() => {
|
||||
if (!activeFilePath.value) return null
|
||||
return findNodeByPath(fileTree.value, activeFilePath.value)
|
||||
})
|
||||
|
||||
function findNodeByPath(nodes: FileNode[], path: string): FileNode | null {
|
||||
for (const node of nodes) {
|
||||
if (node.path === path) return node
|
||||
if (node.children) {
|
||||
const found = findNodeByPath(node.children, path)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function toggleFolder(path: string) {
|
||||
const node = findNodeByPath(fileTree.value, path)
|
||||
if (node && node.type === 'folder') {
|
||||
node.is_open = !node.is_open
|
||||
}
|
||||
}
|
||||
|
||||
function openFile(path: string) {
|
||||
if (!openFiles.value.includes(path)) {
|
||||
openFiles.value.push(path)
|
||||
}
|
||||
activeFilePath.value = path
|
||||
}
|
||||
|
||||
function closeFile(path: string) {
|
||||
const idx = openFiles.value.indexOf(path)
|
||||
if (idx > -1) {
|
||||
openFiles.value.splice(idx, 1)
|
||||
if (activeFilePath.value === path) {
|
||||
activeFilePath.value = openFiles.value[Math.min(idx, openFiles.value.length - 1)] || null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveFile(path: string | null) {
|
||||
activeFilePath.value = path
|
||||
}
|
||||
|
||||
async function loadRecentVaults() {
|
||||
recentVaults.value = await workspaceService.getRecentVaults()
|
||||
}
|
||||
|
||||
async function openVault(path: string) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const info = await workspaceService.openVault(path)
|
||||
vaultPath.value = info.path
|
||||
vaultName.value = info.name
|
||||
fileTree.value = await workspaceService.getFileTree()
|
||||
hasVault.value = true
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createVault(path: string, name: string) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const info = await workspaceService.createVault(path, name)
|
||||
vaultPath.value = info.path
|
||||
vaultName.value = info.name
|
||||
fileTree.value = await workspaceService.getFileTree()
|
||||
hasVault.value = true
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function addFileToTree(parentPath: string, file: FileNode) {
|
||||
const parent = findNodeByPath(fileTree.value, parentPath)
|
||||
if (parent?.children) {
|
||||
parent.children.push(file)
|
||||
parent.is_open = true
|
||||
}
|
||||
}
|
||||
|
||||
function removeFromTree(path: string) {
|
||||
function remove(nodes: FileNode[]): boolean {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (nodes[i].path === path) {
|
||||
nodes.splice(i, 1)
|
||||
return true
|
||||
}
|
||||
if (nodes[i].children && remove(nodes[i].children!)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
remove(fileTree.value)
|
||||
}
|
||||
|
||||
return {
|
||||
vaultPath,
|
||||
vaultName,
|
||||
fileTree,
|
||||
openFiles,
|
||||
activeFilePath,
|
||||
activeFile,
|
||||
isLoading,
|
||||
hasVault,
|
||||
recentVaults,
|
||||
toggleFolder,
|
||||
openFile,
|
||||
closeFile,
|
||||
setActiveFile,
|
||||
loadRecentVaults,
|
||||
openVault,
|
||||
createVault,
|
||||
addFileToTree,
|
||||
removeFromTree,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
:root {
|
||||
/* Background */
|
||||
--color-background-primary: #ffffff;
|
||||
--color-background-secondary: #f7f8fa;
|
||||
--color-background-tertiary: #eef0f3;
|
||||
--color-background-hover: #f0f2f5;
|
||||
--color-background-active: #e4e7eb;
|
||||
--color-background-overlay: rgba(0, 0, 0, 0.45);
|
||||
|
||||
/* Surface */
|
||||
--color-surface-primary: #ffffff;
|
||||
--color-surface-secondary: #fafbfc;
|
||||
--color-surface-elevated: #ffffff;
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: #1f2328;
|
||||
--color-text-secondary: #656d76;
|
||||
--color-text-tertiary: #9198a0;
|
||||
--color-text-inverse: #ffffff;
|
||||
--color-text-link: #5b67f1;
|
||||
--color-text-disabled: #b0b4ba;
|
||||
|
||||
/* Accent */
|
||||
--color-accent-primary: #5b67f1;
|
||||
--color-accent-primary-hover: #4a55e0;
|
||||
--color-accent-primary-active: #3d47cc;
|
||||
--color-accent-secondary: #8b94f5;
|
||||
--color-accent-soft: #eef0ff;
|
||||
--color-accent-soft-hover: #e2e5ff;
|
||||
|
||||
/* Status */
|
||||
--color-success: #2da44e;
|
||||
--color-success-soft: #dafbe3;
|
||||
--color-warning: #d4a72c;
|
||||
--color-warning-soft: #fff5c2;
|
||||
--color-error: #cf222e;
|
||||
--color-error-soft: #ffebe9;
|
||||
--color-info: #0969da;
|
||||
--color-info-soft: #ddf4ff;
|
||||
|
||||
/* Border */
|
||||
--color-border-default: #e4e7eb;
|
||||
--color-border-subtle: #eef0f3;
|
||||
--color-border-focus: #5b67f1;
|
||||
--color-border-disabled: #eef0f3;
|
||||
|
||||
/* Shadow */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.16);
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 10px;
|
||||
--radius-xl: 14px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Spacing */
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 12px;
|
||||
--space-lg: 16px;
|
||||
--space-xl: 20px;
|
||||
--space-2xl: 28px;
|
||||
--space-3xl: 36px;
|
||||
|
||||
/* Typography - UI */
|
||||
--font-ui-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', Helvetica, Arial, sans-serif;
|
||||
--font-ui-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', Consolas, 'Cascadia Code', monospace;
|
||||
|
||||
/* Typography - Editor */
|
||||
--font-editor-sans: var(--font-ui-sans);
|
||||
--font-editor-mono: var(--font-ui-mono);
|
||||
--font-editor-size: 15px;
|
||||
--font-editor-line-height: 1.7;
|
||||
|
||||
/* Font sizes */
|
||||
--font-size-xs: 12px;
|
||||
--font-size-sm: 13px;
|
||||
--font-size-md: 14px;
|
||||
--font-size-lg: 15px;
|
||||
--font-size-xl: 17px;
|
||||
--font-size-2xl: 20px;
|
||||
--font-size-3xl: 26px;
|
||||
|
||||
/* Line heights */
|
||||
--line-height-tight: 1.25;
|
||||
--line-height-normal: 1.5;
|
||||
--line-height-relaxed: 1.7;
|
||||
|
||||
/* Z-index */
|
||||
--z-sidebar: 10;
|
||||
--z-dropdown: 100;
|
||||
--z-modal: 200;
|
||||
--z-tooltip: 300;
|
||||
--z-notification: 400;
|
||||
--z-titlebar: 500;
|
||||
|
||||
/* Motion */
|
||||
--motion-fast: 120ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--motion-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--motion-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Layout */
|
||||
--titlebar-height: 38px;
|
||||
--sidebar-primary-width: 52px;
|
||||
--sidebar-primary-width-expanded: 180px;
|
||||
--sidebar-secondary-width: 260px;
|
||||
--statusbar-height: 26px;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--color-background-primary: #0d1117;
|
||||
--color-background-secondary: #161b22;
|
||||
--color-background-tertiary: #21262d;
|
||||
--color-background-hover: #1f2630;
|
||||
--color-background-active: #2d333b;
|
||||
--color-background-overlay: rgba(0, 0, 0, 0.65);
|
||||
|
||||
--color-surface-primary: #161b22;
|
||||
--color-surface-secondary: #0d1117;
|
||||
--color-surface-elevated: #1c2128;
|
||||
|
||||
--color-text-primary: #e6edf3;
|
||||
--color-text-secondary: #8b949e;
|
||||
--color-text-tertiary: #6e7681;
|
||||
--color-text-inverse: #0d1117;
|
||||
--color-text-link: #7d8bff;
|
||||
--color-text-disabled: #484f58;
|
||||
|
||||
--color-accent-primary: #7d8bff;
|
||||
--color-accent-primary-hover: #909cff;
|
||||
--color-accent-primary-active: #a8b2ff;
|
||||
--color-accent-secondary: #5b67f1;
|
||||
--color-accent-soft: #1e2352;
|
||||
--color-accent-soft-hover: #2a3066;
|
||||
|
||||
--color-success: #3fb950;
|
||||
--color-success-soft: #033a16;
|
||||
--color-warning: #d29922;
|
||||
--color-warning-soft: #4d3a00;
|
||||
--color-error: #f85149;
|
||||
--color-error-soft: #5c1318;
|
||||
--color-info: #58a6ff;
|
||||
--color-info-soft: #051d4d;
|
||||
|
||||
--color-border-default: #30363d;
|
||||
--color-border-subtle: #21262d;
|
||||
--color-border-focus: #7d8bff;
|
||||
--color-border-disabled: #21262d;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-ui-sans);
|
||||
font-size: var(--font-size-md);
|
||||
line-height: var(--line-height-normal);
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-background-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text-link);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-default);
|
||||
border-radius: var(--radius-full);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
[data-theme='dark'] ::selection {
|
||||
background: var(--color-accent-primary);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
Reference in New Issue
Block a user