Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# NeoCompanion

[![Status](https://img.shields.io/badge/Status-Draft--Approved--v3.2-blue.svg)](#)
[![Version](https://img.shields.io/badge/Version-3.2-green.svg)](#)
[![Status](https://img.shields.io/badge/Status-Draft--Approved--v3.3-blue.svg)](#)
[![Version](https://img.shields.io/badge/Version-3.3-green.svg)](#)
[![License](https://img.shields.io/badge/License-MIT-purple.svg)](#)

> 一个融入桌面的 AI 助手——状态融入壁纸,轻交互近在手边,编辑留给面板。
Expand Down Expand Up @@ -92,7 +92,7 @@ echo '{"state":"success","description":"Build pass"}' > ~/.NeoCompanion/hooks/bu

### 🔒 本地优先,隐私可控

所有数据存储在本地,不上传云端。窗口检测可开关,应用黑名单可配置,发送给 AI 的内容可审查。不需要注册账户
业务数据和索引默认存储在本地,不需要注册账户。窗口检测可开关,应用黑名单可配置;只有用户启用云端 Chat 或 Embedding Provider 后,对应的问答上下文或待向量化文本才会发送给该服务,并在设置中明确展示边界

---

Expand All @@ -106,7 +106,7 @@ NeoCompanion 的能力由浅入深分为四层:
|-------------------------------------------------------|
| 2. Routine Layer (番茄钟 / 待办清单 / 天气 / 助手日志) |
|-------------------------------------------------------|
| 3. Gateway Layer (内置原子能力 / OpenClaw / LLM 对话) |
| 3. Knowledge & AI (项目 / 笔记 / 看板 / 混合检索 / AI) |
|-------------------------------------------------------|
| 4. Hook & System (安全 Hook API / 本地状态感知与记忆) |
+-------------------------------------------------------+
Expand All @@ -116,7 +116,7 @@ NeoCompanion 的能力由浅入深分为四层:

**日常琐碎陪伴层**——陪伴式番茄钟、助手待办清单、天气碎碎念、助手工作日志。

**智能网关与对话层**——内置基础 Agent(文件读写、网页搜索、剪贴板获取);复杂跨软件任务委托给 [OpenClaw](https://github.com/openclaw/openclaw);日常提问直连大模型 API
**知识与 AI 层**——在单一本地工作空间中组织项目、Markdown 笔记、任务与看板;通过全文检索和向量检索为 AI 对话提供可核验的本地上下文

**Hook 与系统层**——安全本地多通道 Hook(HTTP / UDS / File Watcher / MQTT);浮动权限审批气泡;本地隐私感知引擎;本地长期记忆。

Expand All @@ -128,13 +128,13 @@ NeoCompanion 的能力由浅入深分为四层:

桌面常驻悬浮助手的基本 2D 精灵图形态、语音 TTS 播报。助手番茄钟、待办、天气碎碎念。壁纸层 MVP(天气时间 + 专注主控盘 + 热区轻交互)。Hook 角标通知。

### 🔌 v2:开放 Hook 极客生态
### 📚 v2:本地知识工作空间

安全本地 Hook API(HTTP + File Watcher 多通道推送)。零端口 UDS / Named Pipe 本地挂载。浮动权限审批气泡 + 全局热键(`Ctrl+Shift+Y` / `Ctrl+Shift+N`)。AI 对话面板完善(一键拾取剪贴板报错)。OpenClaw 配置自动打桩
项目、Markdown 笔记、统一任务与看板形成可用闭环。SQLite FTS5 提供本地全文搜索,`sqlite-vec` 提供可选向量检索;AI 回答展示可点击来源。Embedding 未配置或失败时自动降级为全文搜索。通用 Hook API 与权限审批能力继续独立演进

### 🧠 v3:完整智能网关与分布式远程挂载
### 🧠 v3:知识增强的长期陪伴

分布式远程宿主挂载(将 LanceDB、SQLite 托管至 NAS / 云服务器)。深度打通 OpenClaw 跨软件执行网关。助手性格记忆演进,形成有长期偏好的情感交互
增加本地文件夹同步、多格式导入和本地 embedding 模型;将用户主动维护的知识与助手长期记忆分开治理,并在明确授权下形成有来源、可追溯的个性化交互

---

Expand All @@ -145,10 +145,10 @@ NeoCompanion 的能力由浅入深分为四层:
| 桌面运行时 | **Tauri v2** (Rust) |
| 前端 UI | **Vue 3** + Vite + Pinia + TanStack Query |
| 本地服务 | **Fastify** (TypeScript Sidecar) |
| 数据库 | **SQLite** (Drizzle ORM) + 向量检索 (hnswlib-wasm / LanceDB) |
| AI | 多模型适配器 (DeepSeek / OpenAI / Claude),用户可自定义 |
| 数据库 | **SQLite** (Drizzle ORM + FTS5) + **sqlite-vec** |
| AI | 聊天模型适配器 + OpenAI-compatible Embedding Adapter |

架构核心:Tauri (Rust) 提供系统级能力 → Fastify (TypeScript) 处理业务逻辑和 AI 调度 → Vue 提供 UI → SQLite + 向量检索本地存储全部数据
架构核心:Tauri (Rust) 提供系统级能力 → Fastify (TypeScript) 处理业务逻辑、索引与 AI 调度 → Vue 提供 UI → SQLite 统一存储业务数据、全文索引和向量索引

详见 [**系统架构设计**](docs/ARCHITECTURE.md)。

Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,24 @@
"dependencies": {
"@neo-companion/shared": "workspace:*",
"@tauri-apps/api": "^2.9.0",
"@tiptap/core": "3.26.0",
"@tiptap/extension-heading": "3.26.0",
"@tiptap/extension-list": "3.26.0",
"@tiptap/extensions": "3.26.0",
"@tiptap/markdown": "3.26.0",
"@tiptap/pm": "3.26.0",
"@tiptap/starter-kit": "3.26.0",
"@tiptap/suggestion": "3.26.0",
"@tiptap/vue-3": "3.26.0",
"marked": "^17.0.6",
"textarea-caret": "^3.1.0",
"vue": "3.5.34"
},
"devDependencies": {
"@tauri-apps/cli": "2.11.2",
"@types/textarea-caret": "^3.0.3",
"@vitejs/plugin-vue": "^6.0.2",
"jsdom": "^29.1.1",
"typescript": "6.0.3",
"vite": "8.0.14",
"vitest": "4.1.7",
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
"windows": ["main", "panel", "wallpaper", "settings"],
"permissions": [
"core:default",
"core:window:allow-hide",
"core:window:allow-primary-monitor",
"core:window:allow-set-focus",
"core:window:allow-set-position",
"core:window:allow-set-size",
"core:window:allow-show",
"core:window:allow-start-dragging",
"opener:default",
"window-state:default"
]
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/capabilities/windows-wallpaper.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"identifier": "windows-wallpaper",
"description": "Wallpaper plugin — Windows only",
"platforms": ["windows"],
"windows": ["wallpaper"],
"windows": ["main", "wallpaper"],
"permissions": [
"wallpaper:default"
]
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,22 @@
"skipTaskbar": false,
"visible": false,
"center": true
},
{
"title": "NeoCompanion 知识工作空间",
"label": "knowledge",
"url": "/?view=knowledge",
"width": 1280,
"height": 820,
"minWidth": 1024,
"minHeight": 680,
"transparent": false,
"decorations": true,
"alwaysOnTop": false,
"resizable": true,
"skipTaskbar": false,
"visible": false,
"center": true
}
],
"security": {
Expand Down
21 changes: 16 additions & 5 deletions apps/desktop/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import TopNav from "./components/panel/TopNav.vue";
import CreateFocusCard from "./components/panel/cards/CreateFocusCard.vue";
import TodayTasksCard from "./components/panel/cards/TodayTasksCard.vue";
import WeeklyFocusCard from "./components/panel/cards/WeeklyFocusCard.vue";
import ClawGatewayCard from "./components/panel/cards/ClawGatewayCard.vue";
import KnowledgeEntryCard from "./components/panel/cards/KnowledgeEntryCard.vue";
import AssistantStatusCard from "./components/panel/cards/AssistantStatusCard.vue";
import HookNotificationsCard from "./components/panel/cards/HookNotificationsCard.vue";
import AiChatCard from "./components/panel/cards/AiChatCard.vue";
Expand All @@ -33,20 +33,22 @@ import PageDots from "./components/panel/PageDots.vue";
import ErrorToast from "./components/shared/ErrorToast.vue";
import WallpaperView from "./views/WallpaperView.vue";
import SettingsView from "./views/SettingsView.vue";
import KnowledgeView from "./views/KnowledgeView.vue";
import { useSettings } from "./composables/useSettings";

const viewMode = new URLSearchParams(window.location.search).get("view");
const isPetView = viewMode === "pet";
const isWallpaperView = viewMode === "wallpaper";
const isSettingsView = viewMode === "settings";
const isKnowledgeView = viewMode === "knowledge";
const isTauriRuntime = "__TAURI_INTERNALS__" in window;
type PanelPage = "focus" | "tasks" | "weekly" | "claw" | "assistant" | "hooks" | "chat" | "diary";
type PanelPage = "focus" | "tasks" | "weekly" | "knowledge" | "assistant" | "hooks" | "chat" | "diary";

const panelPages: Array<{ id: PanelPage; label: string }> = [
{ id: "focus", label: "专注" },
{ id: "tasks", label: "任务" },
{ id: "weekly", label: "本周" },
{ id: "claw", label: "OpenClaw" },
{ id: "knowledge", label: "知识库" },
{ id: "assistant", label: "助手" },
{ id: "hooks", label: "Hook" },
{ id: "chat", label: "AI 对话" },
Expand Down Expand Up @@ -75,7 +77,7 @@ const contextMenu = ref<{ x: number; y: number } | null>(null);
let disconnectWs: (() => void) | null = null;

onMounted(async () => {
if (isSettingsView) {
if (isSettingsView || isKnowledgeView) {
return;
}
if (isWallpaperView) {
Expand Down Expand Up @@ -162,6 +164,13 @@ async function openSettings() {
await settings?.setFocus();
}

async function openKnowledge() {
if (!isTauriRuntime) return;
const knowledge = await WebviewWindow.getByLabel("knowledge");
await knowledge?.show();
await knowledge?.setFocus();
}

async function setWallpaperLayerVisible(visible: boolean) {
if (!isTauriRuntime) return;

Expand Down Expand Up @@ -294,6 +303,8 @@ function toggleImmersive() {

<SettingsView v-else-if="isSettingsView" />

<KnowledgeView v-else-if="isKnowledgeView" />

<main v-else class="panel-shell" :class="{ 'dark-mode': isPanelDark }">
<!-- Noise texture filter -->
<svg width="0" height="0" style="position: absolute">
Expand Down Expand Up @@ -331,7 +342,7 @@ function toggleImmersive() {

<WeeklyFocusCard v-else-if="activePanelPage === 'weekly'" @start-focus="onStartFocusFromCard" />

<ClawGatewayCard v-else-if="activePanelPage === 'claw'" />
<KnowledgeEntryCard v-else-if="activePanelPage === 'knowledge'" @open-knowledge="openKnowledge" />

<AssistantStatusCard v-else-if="activePanelPage === 'assistant'" :pet-state="pet.petState.value" />

Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/components/knowledge/BoardCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { KnowledgeTask } from "../../composables/useKnowledgeMock";

defineProps<{
task: KnowledgeTask;
}>();
</script>

<template>
<article class="knowledge-board-card" :data-status="task.status">
<div class="title">{{ task.title }}</div>
<div v-if="task.tags.length || task.linkedNoteId" class="meta">
<span v-for="tag in task.tags" :key="tag" class="tag">{{ tag }}</span>
<span v-if="task.linkedNoteId" class="linked" title="关联笔记">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
<path d="M9 17H7A5 5 0 0 1 7 7h2" />
<path d="M15 7h2a5 5 0 0 1 0 10h-2" />
<path d="M8 12h8" />
</svg>
笔记
</span>
</div>
</article>
</template>
22 changes: 22 additions & 0 deletions apps/desktop/src/components/knowledge/BoardColumn.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { BoardColumnMock, KnowledgeTask } from "../../composables/useKnowledgeMock";
import BoardCard from "./BoardCard.vue";

defineProps<{
column: BoardColumnMock;
tasks: KnowledgeTask[];
}>();
</script>

<template>
<section class="knowledge-board-column" :data-status="column.status">
<header>
<span class="title">{{ column.title }}</span>
<span class="count">{{ tasks.length }}</span>
</header>
<div class="cards">
<BoardCard v-for="task in tasks" :key="task.id" :task="task" />
<div v-if="!tasks.length" class="empty">空</div>
</div>
</section>
</template>
27 changes: 27 additions & 0 deletions apps/desktop/src/components/knowledge/BoardPane.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed } from "vue";
import type { BoardColumnMock, KnowledgeTask } from "../../composables/useKnowledgeMock";
import BoardColumn from "./BoardColumn.vue";

const props = defineProps<{
columns: BoardColumnMock[];
tasks: KnowledgeTask[];
}>();

const tasksByColumn = computed(() =>
Object.fromEntries(
props.columns.map((col) => [col.id, props.tasks.filter((t) => t.boardColumnId === col.id)]),
) as Record<string, KnowledgeTask[]>,
);
</script>

<template>
<div class="knowledge-board-pane">
<BoardColumn
v-for="column in columns"
:key="column.id"
:column="column"
:tasks="tasksByColumn[column.id] ?? []"
/>
</div>
</template>
18 changes: 18 additions & 0 deletions apps/desktop/src/components/knowledge/IndexStatusDot.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { computed } from "vue";
import { INDEX_STATUS_LABELS, type IndexStatus } from "../../composables/useKnowledgeMock";

const props = defineProps<{
status: IndexStatus;
showLabel?: boolean;
}>();

const label = computed(() => INDEX_STATUS_LABELS[props.status]);
</script>

<template>
<span class="knowledge-index-dot" :class="`status-${status}`" :title="label">
<span class="dot" aria-hidden="true"></span>
<span v-if="showLabel ?? true" class="label">{{ label }}</span>
</span>
</template>
62 changes: 62 additions & 0 deletions apps/desktop/src/components/knowledge/KnowledgeTopbar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<script setup lang="ts">
import {
VIEW_LABELS,
type IndexStatus,
type KnowledgeProject,
type KnowledgeViewKey,
} from "../../composables/useKnowledgeMock";
import IndexStatusDot from "./IndexStatusDot.vue";

defineProps<{
project: KnowledgeProject | null;
activeView: KnowledgeViewKey;
searchQuery: string;
indexStatus: IndexStatus;
}>();

const emit = defineEmits<{
selectView: [view: KnowledgeViewKey];
"update:searchQuery": [value: string];
}>();

const views: KnowledgeViewKey[] = ["notes", "board", "tasks"];
</script>

<template>
<header class="knowledge-topbar">
<div class="knowledge-topbar-left">
<h1 class="knowledge-project-title">{{ project?.name ?? "—" }}</h1>
<span class="knowledge-project-meta">
{{ project?.noteCount ?? 0 }} 篇笔记 · {{ project?.taskCount ?? 0 }} 项任务
</span>
</div>

<nav class="knowledge-view-tabs" aria-label="视图切换">
<button
v-for="view in views"
:key="view"
type="button"
:class="{ active: activeView === view }"
@click="emit('selectView', view)"
>
{{ VIEW_LABELS[view] }}
</button>
</nav>

<div class="knowledge-topbar-right">
<label class="knowledge-search">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
<input
type="text"
placeholder="搜索本项目笔记与任务"
:value="searchQuery"
@input="emit('update:searchQuery', ($event.target as HTMLInputElement).value)"
/>
</label>
<IndexStatusDot :status="indexStatus" />
</div>
</header>
</template>
Loading
Loading