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
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"dependencies": {
"@neo-companion/shared": "workspace:*",
"@tauri-apps/api": "^2.9.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tiptap/core": "3.26.0",
"@tiptap/extension-heading": "3.26.0",
"@tiptap/extension-list": "3.26.0",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tauri-plugin-window-state = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"core:window:allow-show",
"core:window:allow-start-dragging",
"opener:default",
"dialog:default",
"window-state:default"
]
}
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use tauri::{LogicalSize, Manager, Size, WindowEvent};
pub fn run() {
let builder = tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_window_state::Builder::default().build());

#[cfg(target_os = "windows")]
Expand Down
9 changes: 5 additions & 4 deletions apps/desktop/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import WallpaperView from "./views/WallpaperView.vue";
import SettingsView from "./views/SettingsView.vue";
import KnowledgeView from "./views/KnowledgeView.vue";
import { useSettings } from "./composables/useSettings";
import { useTheme } from "./composables/useTheme";

const viewMode = new URLSearchParams(window.location.search).get("view");
const isPetView = viewMode === "pet";
Expand Down Expand Up @@ -67,7 +68,7 @@ const weather = ref<WeatherSummary | null>(null);
const lastWindow = ref<WindowSnapshot | null>(null);
const serverReady = ref(false);
const errorText = ref("");
const isPanelDark = ref(false);
const theme = useTheme();
const activePanelPage = ref<PanelPage>("focus");
const wallpaperVisible = ref(true);
const focusStartTime = ref("");
Expand Down Expand Up @@ -240,7 +241,7 @@ function onTaskToggle(task: typeof tasks.tasks.value[0]) {
}

function togglePanelTheme() {
isPanelDark.value = !isPanelDark.value;
theme.toggleTheme();
}

function setActivePanelPage(pageId: string) {
Expand Down Expand Up @@ -305,7 +306,7 @@ function toggleImmersive() {

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

<main v-else class="panel-shell" :class="{ 'dark-mode': isPanelDark }">
<main v-else class="panel-shell" :class="{ 'dark-mode': theme.isDark.value }">
<!-- Noise texture filter -->
<svg width="0" height="0" style="position: absolute">
<filter id="panel-noise-filter">
Expand All @@ -317,7 +318,7 @@ function toggleImmersive() {
<!-- Top navigation -->
<TopNav
:pet-state="pet.petState.value"
:is-dark="isPanelDark"
:is-dark="theme.isDark.value"
@toggle-theme="togglePanelTheme"
@open-settings="openSettings"
/>
Expand Down
51 changes: 50 additions & 1 deletion apps/desktop/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ChatMessage, FocusSession, Task, TtsResult, WeatherSummary, WsMessage } from "@neo-companion/shared";
import type { BoardColumn, KnowledgeNote, KnowledgeProject, KnowledgeTask, TaskStatus } from "./composables/useKnowledgeMock";

export const API_BASE = import.meta.env.VITE_NEO_SERVER_URL ?? "http://127.0.0.1:10103";
export const WS_BASE = API_BASE.replace(/^http/, "ws");
Expand Down Expand Up @@ -32,7 +33,55 @@ export const api = {
weather: () => request<WeatherSummary>("/api/weather"),
chat: (message: string) => request<{ text: string }>("/api/ai/chat", { method: "POST", body: JSON.stringify({ message }) }),
speak: (text: string, style?: string) =>
request<TtsResult>("/api/tts/speak", { method: "POST", body: JSON.stringify({ text, style }) })
request<TtsResult>("/api/tts/speak", { method: "POST", body: JSON.stringify({ text, style }) }),

// ── Knowledge workspace ──
knowledgeListProjects: () => request<KnowledgeProject[]>("/api/knowledge/projects?root=1"),
knowledgeChildProjects: (parentId: string) =>
request<KnowledgeProject[]>(`/api/knowledge/projects?parentId=${encodeURIComponent(parentId)}`),
knowledgeProjectPath: (id: string) =>
request<KnowledgeProject[]>(`/api/knowledge/projects/${encodeURIComponent(id)}/path`),
knowledgeCreateProject: (input: { title: string; parentId?: string | null; description?: string; color?: string; icon?: string }) =>
request<KnowledgeProject>("/api/knowledge/projects", { method: "POST", body: JSON.stringify(input) }),
knowledgeUpdateProject: (id: string, patch: Partial<Pick<KnowledgeProject, "title" | "description" | "color" | "icon" | "parentId" | "order">>) =>
request<KnowledgeProject>(`/api/knowledge/projects/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(patch) }),
knowledgeDeleteProject: (id: string) =>
request<void>(`/api/knowledge/projects/${encodeURIComponent(id)}`, { method: "DELETE" }),
knowledgeListNotes: (projectId: string) =>
request<KnowledgeNote[]>(`/api/knowledge/projects/${encodeURIComponent(projectId)}/notes`),
knowledgeCreateNote: (projectId: string, title: string) =>
request<KnowledgeNote>(`/api/knowledge/projects/${encodeURIComponent(projectId)}/notes`, { method: "POST", body: JSON.stringify({ title }) }),
knowledgeUpdateNote: (id: string, patch: Partial<Pick<KnowledgeNote, "title" | "body" | "tags">>) =>
request<KnowledgeNote>(`/api/knowledge/notes/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(patch) }),
knowledgeDeleteNote: (id: string) =>
request<void>(`/api/knowledge/notes/${encodeURIComponent(id)}`, { method: "DELETE" }),
knowledgeBacklinks: (id: string) =>
request<{ sourceType: "note" | "task"; sourceId: string }[]>(`/api/knowledge/notes/${encodeURIComponent(id)}/backlinks`),
knowledgeListColumns: (projectId: string) =>
request<BoardColumn[]>(`/api/knowledge/projects/${encodeURIComponent(projectId)}/columns`),
knowledgeCreateColumn: (projectId: string, input: { title: string; status: TaskStatus; order: number }) =>
request<BoardColumn>(`/api/knowledge/projects/${encodeURIComponent(projectId)}/columns`, { method: "POST", body: JSON.stringify(input) }),
knowledgeUpdateColumn: (id: string, patch: Partial<Pick<BoardColumn, "title" | "status" | "order">>) =>
request<BoardColumn>(`/api/knowledge/columns/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(patch) }),
knowledgeDeleteColumn: (id: string) =>
request<void>(`/api/knowledge/columns/${encodeURIComponent(id)}`, { method: "DELETE" }),
knowledgeListTasks: (projectId: string) =>
request<KnowledgeTask[]>(`/api/knowledge/projects/${encodeURIComponent(projectId)}/tasks`),
knowledgeCreateTask: (projectId: string, columnId: string, title: string) =>
request<KnowledgeTask>(`/api/knowledge/projects/${encodeURIComponent(projectId)}/tasks`, { method: "POST", body: JSON.stringify({ columnId, title }) }),
knowledgeUpdateTask: (id: string, patch: Partial<Pick<KnowledgeTask, "title" | "description" | "status" | "columnId" | "order">> & { linkedNoteId?: string | null }) =>
request<KnowledgeTask>(`/api/knowledge/tasks/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(patch) }),
knowledgeDeleteTask: (id: string) =>
request<void>(`/api/knowledge/tasks/${encodeURIComponent(id)}`, { method: "DELETE" }),
knowledgeMoveTask: (id: string, columnId: string, index: number) =>
request<void>(`/api/knowledge/tasks/${encodeURIComponent(id)}/move`, { method: "POST", body: JSON.stringify({ columnId, index }) }),
knowledgeGetRootPath: () => request<{ path: string }>("/api/knowledge/root-path"),
knowledgeSetRootPath: (path: string) =>
request<{ path: string }>("/api/knowledge/root-path", { method: "PUT", body: JSON.stringify({ path }) }),
knowledgeMirrorExport: (path?: string) =>
request<{ projects: number; notes: number; columns: number; tasks: number }>("/api/knowledge/mirror/export", { method: "POST", body: JSON.stringify({ path }) }),
knowledgeMirrorImport: (path?: string) =>
request<{ importedProjects: number; importedNotes: number; importedColumns: number; importedTasks: number; skipped: number }>("/api/knowledge/mirror/import", { method: "POST", body: JSON.stringify({ path }) })
};

let activeWs: WebSocket | null = null;
Expand Down
53 changes: 53 additions & 0 deletions apps/desktop/src/components/knowledge/BacklinksPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed } from "vue";
import type { KnowledgeWorkspaceState } from "../../composables/useKnowledgeWorkspace";

const props = defineProps<{
workspace: KnowledgeWorkspaceState;
entityId: string | null;
entityType: "note" | "task";
}>();

const emit = defineEmits<{
navigate: [sourceTitle: string];
}>();

const backlinks = computed(() => {
if (!props.entityId) return [];
return props.workspace.backlinksFor(props.entityId);
});

function sourceTitle(link: ReturnType<typeof props.workspace.backlinksFor>[number]): string {
if (link.sourceType === "note") {
const found = props.workspace.allNotes.value.find((n) => n.id === link.sourceId);
return found?.title ?? link.sourceId;
}
const found = props.workspace.allTasks.value.find((t) => t.id === link.sourceId);
return found?.title ?? link.sourceId;
}

function follow(link: ReturnType<typeof props.workspace.backlinksFor>[number]): void {
emit("navigate", sourceTitle(link));
}
</script>

<template>
<aside v-if="backlinks.length > 0" class="backlinks-panel">
<header class="backlinks-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
引用此{{ entityType === "note" ? "笔记" : "任务" }}
</header>

<ul class="backlinks-list">
<li v-for="link in backlinks" :key="`${link.sourceId}-${link.targetId}`" class="backlinks-item">
<button type="button" @click="follow(link)">
<span class="backlinks-type">{{ link.sourceType === "note" ? "笔记" : "任务" }}</span>
<span class="backlinks-title">{{ sourceTitle(link) }}</span>
</button>
</li>
</ul>
</aside>
</template>
24 changes: 0 additions & 24 deletions apps/desktop/src/components/knowledge/BoardCard.vue

This file was deleted.

159 changes: 148 additions & 11 deletions apps/desktop/src/components/knowledge/BoardColumn.vue
Original file line number Diff line number Diff line change
@@ -1,22 +1,159 @@
<script setup lang="ts">
import type { BoardColumnMock, KnowledgeTask } from "../../composables/useKnowledgeMock";
import BoardCard from "./BoardCard.vue";
import { ref, computed } from "vue";
import type { BoardColumn, KnowledgeTask } from "../../composables/useKnowledgeMock";
import type { KnowledgeWorkspaceState } from "../../composables/useKnowledgeWorkspace";
import BoardTaskCard from "./BoardTaskCard.vue";

defineProps<{
column: BoardColumnMock;
const props = defineProps<{
column: BoardColumn;
tasks: KnowledgeTask[];
workspace: KnowledgeWorkspaceState;
isDropTarget: boolean;
draggingTaskId: string | null;
}>();

const emit = defineEmits<{
dragStart: [taskId: string];
dragEnd: [];
dropTask: [taskId: string, columnId: string, index: number];
dragHover: [columnId: string];
dragLeave: [];
}>();

const newTaskTitle = ref("");
const isCreating = ref(false);
const columnRef = ref<HTMLElement | null>(null);
const dropIndex = ref<number | null>(null);

// 拖拽落点对应的卡片 id(在该卡片上方插入);落点在列末尾时为 null 且 dropAtEnd=true。
const visibleTasks = computed(() => props.tasks.filter((t) => t.id !== props.draggingTaskId));
const dropTargetId = computed<string | null>(() => {
if (dropIndex.value === null) return null;
const list = visibleTasks.value;
if (dropIndex.value >= list.length) return null;
return list[dropIndex.value].id;
});
const dropAtEnd = computed(
() => dropIndex.value !== null && dropIndex.value >= visibleTasks.value.length,
);

function onDragEnter(event: DragEvent): void {
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
}

function onDragOver(event: DragEvent): void {
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
emit("dragHover", props.column.id);
dropIndex.value = computeDropIndex(event);
}

function onDragLeave(event: DragEvent): void {
if (!columnRef.value) return;
const related = event.relatedTarget as Node | null;
if (related && columnRef.value.contains(related)) return;
dropIndex.value = null;
emit("dragLeave");
}

function onDrop(event: DragEvent): void {
event.preventDefault();
const taskId =
event.dataTransfer?.getData("application/x-neo-task") || event.dataTransfer?.getData("text/plain");
const index = dropIndex.value ?? props.tasks.length;
dropIndex.value = null;
if (!taskId) return;
emit("dropTask", taskId, props.column.id, index);
}

function computeDropIndex(event: DragEvent): number {
if (!columnRef.value) return props.tasks.length;
const cards = Array.from(columnRef.value.querySelectorAll(".board-task-card")).filter(
(card) => card.getAttribute("data-task-id") !== props.draggingTaskId,
);
if (cards.length === 0) return 0;
const rect = columnRef.value.getBoundingClientRect();
const relativeY = event.clientY - rect.top;
for (let i = 0; i < cards.length; i += 1) {
const cardRect = cards[i].getBoundingClientRect();
const cardMiddle = cardRect.top + cardRect.height / 2 - rect.top;
if (relativeY < cardMiddle) return i;
}
return cards.length;
}

function startCreate(): void {
isCreating.value = true;
newTaskTitle.value = "";
}

function confirmCreate(): void {
const title = newTaskTitle.value.trim();
if (!title) return;
props.workspace.createTask(props.column.id, title);
newTaskTitle.value = "";
isCreating.value = false;
}

function cancelCreate(): void {
isCreating.value = false;
newTaskTitle.value = "";
}
</script>

<template>
<section class="knowledge-board-column" :data-status="column.status">
<header>
<span class="title">{{ column.title }}</span>
<span class="count">{{ tasks.length }}</span>
<section
ref="columnRef"
class="board-column"
:class="{ 'is-drop-target': isDropTarget }"
@dragenter="onDragEnter"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<header class="board-column-header">
<span class="board-column-title">{{ column.title }}</span>
<span class="board-column-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 class="board-column-cards">
<BoardTaskCard
v-for="task in tasks"
:key="task.id"
:task="task"
:workspace="workspace"
:is-dragging="draggingTaskId === task.id"
:is-drop-above="dropTargetId === task.id"
@dragstart="$emit('dragStart', task.id); $emit('dragHover', column.id)"
@dragend="$emit('dragEnd')"
/>

<div v-if="tasks.length === 0" class="board-column-empty">
拖拽任务到此处
</div>

<div v-if="dropAtEnd" class="board-drop-line" aria-hidden="true" />
</div>

<div class="board-column-footer">
<button v-if="!isCreating" type="button" class="board-column-add" @click="startCreate">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
添加任务
</button>

<div v-else class="board-column-create" @keydown.esc="cancelCreate">
<input
v-model="newTaskTitle"
type="text"
placeholder="任务标题"
autofocus
@keydown.enter="confirmCreate"
/>
<button type="button" @click="confirmCreate">创建</button>
</div>
</div>
</section>
</template>
Loading
Loading