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:
2026-08-29 10:36:16 +08:00
parent 694c1b27c1
commit f9efc4f4a2
42 changed files with 8044 additions and 167 deletions
+23 -6
View File
@@ -6,16 +6,33 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"vue": "latest"
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/theme-one-dark": "^6.1.0",
"@milkdown/plugin-block": "^7.22.0",
"@milkdown/plugin-cursor": "^7.22.0",
"@milkdown/plugin-history": "^7.22.0",
"@milkdown/plugin-listener": "^7.22.0",
"@milkdown/plugin-slash": "^7.22.0",
"@milkdown/plugin-tooltip": "^7.22.0",
"@milkdown/plugin-trailing": "^7.22.0",
"@milkdown/theme-nord": "^7.22.0",
"@milkdown/vue": "^7.22.0",
"@vueuse/core": "^14.0.0",
"codemirror": "^6.0.0",
"marked": "^15.0.0",
"pinia": "^4.0.0",
"vue": "^3.5.0",
"vue-router": "^5.0.0"
},
"devDependencies": {
"@types/node": "latest",
"@vitejs/plugin-vue": "latest",
"@types/node": "^22.0.0",
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "~5.9.3",
"vite": "latest",
"vue-tsc": "latest"
"vite": "^6.0.0",
"vue-tsc": "^2.0.0"
}
}
+3026 -70
View File
File diff suppressed because it is too large Load Diff
+12 -89
View File
@@ -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>
+191
View File
@@ -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>
+364
View File
@@ -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'
+441
View File
@@ -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
View File
@@ -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')
+100
View File
@@ -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
+289
View File
@@ -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: '将在你的知识库中创建一篇新笔记',
}
+102
View File
@@ -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
+138
View File
@@ -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树,牺牲了部分平衡性以换取更少的旋转操作...',
},
],
},
],
}
+15
View File
@@ -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'
+36
View File
@@ -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(),
}
+38
View File
@@ -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 })
}
+119
View File
@@ -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',
},
]
+149
View File
@@ -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 },
},
],
}
+73
View File
@@ -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 }
}
+104
View File
@@ -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,
},
]
+114
View File
@@ -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
+23
View File
@@ -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,
}
}
}
+105
View File
@@ -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',
},
]
+233
View File
@@ -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()
}
+145
View File
@@ -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,
}
})
+147
View File
@@ -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,
}
})
+122
View File
@@ -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,
}
})
+11
View File
@@ -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'
+69
View File
@@ -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,
}
})
+80
View File
@@ -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,
}
})
+78
View File
@@ -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,
}
})
+93
View File
@@ -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,
}
})
+69
View File
@@ -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,
}
})
+89
View File
@@ -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,
}
})
+81
View File
@@ -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,
}
})
+132
View File
@@ -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,
}
})
+247
View File
@@ -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);
}
+6
View File
@@ -1,8 +1,14 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
server: {
host: '127.0.0.1',
port: 5173,