UI polish pass: word count, slash commands, task lists, graph peek, bulk delete, export

- WordCount component (toggle words/chars vs read time, persisted mode)
- TipTap: TaskList/TaskItem extensions, slash command menu (H1-H3, lists, code, quote, task)
- Markdown serializer: task list → `- [ ]` / `- [x]` roundtrip
- GraphView: slide-in peek panel for note/task nodes (body, tags, linked nodes); tag nodes still navigate
- ChatView: bulk-select conversations with two-click confirm delete + chat retention policy (default 90d)
- NoteEditorView: pill tab bar with animated edit/preview toggle + WordCount in toolbar
- WorkspaceNoteEditor: inline search with tag matching, inline new-note creation, WordCount
- WorkspaceTaskPanel: task body rendered in slide-over + Edit link
- Settings: data export (Markdown ZIP / JSON) via GET /api/export
- Accessibility: skip-to-content link, aria-labels on all icon-only buttons
- Fix: export route used non-existent fabledassistant.database — corrected to async_session()
- Fix: ToolCallRecord.status type now includes "running" (was causing TS build error)
- Dockerfile: upgrade npm to latest before install to suppress major-version notice
- npm audit fix: patched minimatch (ReDoS) and rollup (path traversal) — 0 vulnerabilities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 23:15:01 -05:00
parent de7709039a
commit ef141f07f8
28 changed files with 2980 additions and 160 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
FROM node:20-alpine AS build-frontend FROM node:20-alpine AS build-frontend
WORKDIR /build WORKDIR /build
COPY frontend/package.json frontend/package-lock.json* ./ COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install RUN npm install -g npm@latest --quiet && npm install
COPY frontend/ . COPY frontend/ .
RUN npm run build RUN npm run build
+1751 -107
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -9,17 +9,19 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"d3": "^7",
"@tiptap/extension-link": "^2.11.0", "@tiptap/extension-link": "^2.11.0",
"@tiptap/extension-placeholder": "^2.11.0", "@tiptap/extension-placeholder": "^2.11.0",
"@tiptap/extension-task-item": "^2.27.2",
"@tiptap/extension-task-list": "^2.27.2",
"@tiptap/pm": "^2.11.0", "@tiptap/pm": "^2.11.0",
"@tiptap/starter-kit": "^2.11.0", "@tiptap/starter-kit": "^2.11.0",
"@tiptap/suggestion": "^2.11.0", "@tiptap/suggestion": "^2.11.0",
"@tiptap/vue-3": "^2.11.0", "@tiptap/vue-3": "^2.11.0",
"d3": "^7",
"dompurify": "^3.1.0",
"marked": "^15.0.0",
"pinia": "^2.2.0", "pinia": "^2.2.0",
"vue": "^3.5.0", "vue": "^3.5.0",
"marked": "^15.0.0",
"dompurify": "^3.1.0",
"vue-router": "^4.4.0" "vue-router": "^4.4.0"
}, },
"devDependencies": { "devDependencies": {
+20 -2
View File
@@ -144,9 +144,10 @@ onUnmounted(() => {
<template> <template>
<template v-if="authStore.isAuthenticated"> <template v-if="authStore.isAuthenticated">
<a href="#main-content" class="skip-link">Skip to main content</a>
<div class="app-shell"> <div class="app-shell">
<AppHeader /> <AppHeader />
<div class="app-content"> <div id="main-content" class="app-content">
<router-view /> <router-view />
</div> </div>
</div> </div>
@@ -157,7 +158,7 @@ onUnmounted(() => {
<div class="shortcuts-panel"> <div class="shortcuts-panel">
<div class="shortcuts-header"> <div class="shortcuts-header">
<h3>Keyboard Shortcuts</h3> <h3>Keyboard Shortcuts</h3>
<button class="shortcuts-close" @click="closeShortcuts">&times;</button> <button class="shortcuts-close" aria-label="Close keyboard shortcuts" @click="closeShortcuts">&times;</button>
</div> </div>
<div class="shortcuts-body"> <div class="shortcuts-body">
<div class="shortcuts-section"> <div class="shortcuts-section">
@@ -262,6 +263,23 @@ onUnmounted(() => {
</template> </template>
<style> <style>
.skip-link {
position: absolute;
top: -100%;
left: 0.5rem;
z-index: 9999;
padding: 0.4rem 0.75rem;
background: var(--color-primary);
color: #fff;
border-radius: 0 0 4px 4px;
font-size: 0.875rem;
text-decoration: none;
transition: top 0.1s;
}
.skip-link:focus {
top: 0;
}
.app-shell { .app-shell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+36
View File
@@ -144,6 +144,42 @@
text-decoration: none; text-decoration: none;
} }
/* Task list */
.prose ul[data-type="taskList"] {
list-style: none;
padding-left: 0.25rem;
}
.prose ul[data-type="taskList"] li {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.2rem;
}
.prose ul[data-type="taskList"] li > label {
flex-shrink: 0;
user-select: none;
line-height: 1.6;
}
.prose ul[data-type="taskList"] li > label input[type="checkbox"] {
cursor: pointer;
accent-color: var(--color-primary);
width: 0.95em;
height: 0.95em;
margin: 0;
}
.prose ul[data-type="taskList"] li > div {
flex: 1;
}
.prose ul[data-type="taskList"] li[data-checked="true"] > div {
text-decoration: line-through;
color: var(--color-text-muted);
}
/* Tiptap editor */ /* Tiptap editor */
.tiptap-editor .ProseMirror { .tiptap-editor .ProseMirror {
outline: none; outline: none;
+2 -2
View File
@@ -155,7 +155,7 @@ function promoteAutoNote(note: { id: number; title: string }) {
<span v-if="contextNoteId" class="context-indicator"> <span v-if="contextNoteId" class="context-indicator">
Note #{{ contextNoteId }} Note #{{ contextNoteId }}
</span> </span>
<button class="btn-close" @click="emit('close')">&times;</button> <button class="btn-close" aria-label="Close chat panel" @click="emit('close')">&times;</button>
</div> </div>
<div ref="messagesEl" class="panel-messages"> <div ref="messagesEl" class="panel-messages">
@@ -224,7 +224,7 @@ function promoteAutoNote(note: { id: number; title: string }) {
<div v-if="attachedNote" class="attached-note"> <div v-if="attachedNote" class="attached-note">
<span class="attached-note-pill"> <span class="attached-note-pill">
{{ attachedNote.title }} {{ attachedNote.title }}
<button class="attached-note-remove" @click="removeAttachedNote">&times;</button> <button class="attached-note-remove" aria-label="Remove attached note" @click="removeAttachedNote">&times;</button>
</span> </span>
</div> </div>
@@ -115,7 +115,7 @@ defineExpose({ focus });
<div v-if="attachedNote" class="attached-note"> <div v-if="attachedNote" class="attached-note">
<span class="attached-note-pill"> <span class="attached-note-pill">
{{ attachedNote.title }} {{ attachedNote.title }}
<button class="attached-note-remove" @click="removeAttachedNote"> <button class="attached-note-remove" aria-label="Remove attached note" @click="removeAttachedNote">
&times; &times;
</button> </button>
</span> </span>
+1 -1
View File
@@ -114,7 +114,7 @@ onMounted(loadVersions);
<div class="modal-card history-modal"> <div class="modal-card history-modal">
<div class="history-header"> <div class="history-header">
<h3 class="history-title">Version History</h3> <h3 class="history-title">Version History</h3>
<button class="history-close" @click="emit('close')">&times;</button> <button class="history-close" aria-label="Close version history" @click="emit('close')">&times;</button>
</div> </div>
<div class="history-body"> <div class="history-body">
@@ -73,6 +73,12 @@ const buttons = [
command: () => props.editor?.chain().focus().toggleCodeBlock().run(), command: () => props.editor?.chain().focus().toggleCodeBlock().run(),
isActive: () => props.editor?.isActive("codeBlock") ?? false, isActive: () => props.editor?.isActive("codeBlock") ?? false,
}, },
{
label: "☑",
title: "Task List",
command: () => props.editor?.chain().focus().toggleTaskList().run(),
isActive: () => props.editor?.isActive("taskList") ?? false,
},
{ {
label: "[[", label: "[[",
title: "Insert wikilink (type note title to search)", title: "Insert wikilink (type note title to search)",
+1 -1
View File
@@ -134,7 +134,7 @@ onMounted(loadLogs);
</span> </span>
<div class="log-entry-actions"> <div class="log-entry-actions">
<button class="btn-log-edit" @click="startEdit(log)" title="Edit">Edit</button> <button class="btn-log-edit" @click="startEdit(log)" title="Edit">Edit</button>
<button class="btn-log-delete" @click="deleteLog(log)" title="Delete">&times;</button> <button class="btn-log-delete" aria-label="Delete log entry" @click="deleteLog(log)">&times;</button>
</div> </div>
</div> </div>
<div class="log-content prose" v-html="renderMarkdown(log.content)"></div> <div class="log-content prose" v-html="renderMarkdown(log.content)"></div>
+6
View File
@@ -7,9 +7,12 @@ import Placeholder from "@tiptap/extension-placeholder";
import { marked } from "marked"; import { marked } from "marked";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { serializeToMarkdown } from "@/utils/markdownSerializer"; import { serializeToMarkdown } from "@/utils/markdownSerializer";
import TaskList from "@tiptap/extension-task-list";
import TaskItem from "@tiptap/extension-task-item";
import { TagDecoration } from "@/extensions/TagDecoration"; import { TagDecoration } from "@/extensions/TagDecoration";
import { WikilinkDecoration } from "@/extensions/WikilinkDecoration"; import { WikilinkDecoration } from "@/extensions/WikilinkDecoration";
import { WikilinkSuggestion } from "@/extensions/WikilinkSuggestion"; import { WikilinkSuggestion } from "@/extensions/WikilinkSuggestion";
import { SlashCommands } from "@/extensions/SlashCommands";
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -76,9 +79,12 @@ try {
Placeholder.configure({ Placeholder.configure({
placeholder: props.placeholder, placeholder: props.placeholder,
}), }),
TaskList,
TaskItem.configure({ nested: true }),
TagDecoration, TagDecoration,
WikilinkDecoration, WikilinkDecoration,
WikilinkSuggestion, WikilinkSuggestion,
SlashCommands,
], ],
onUpdate({ editor: ed }) { onUpdate({ editor: ed }) {
if (updatingFromProp) return; if (updatingFromProp) return;
@@ -14,7 +14,7 @@ const toastStore = useToastStore();
:class="`toast--${toast.type}`" :class="`toast--${toast.type}`"
> >
<span class="toast-msg">{{ toast.message }}</span> <span class="toast-msg">{{ toast.message }}</span>
<button class="toast-close" @click="toastStore.dismiss(toast.id)">&times;</button> <button class="toast-close" aria-label="Dismiss notification" @click="toastStore.dismiss(toast.id)">&times;</button>
</div> </div>
</transition-group> </transition-group>
</div> </div>
+60
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed, ref } from "vue";
const props = defineProps<{ body: string }>();
const STORAGE_KEY = "word_count_mode";
const mode = ref<"words" | "time">(
(localStorage.getItem(STORAGE_KEY) as "words" | "time") ?? "words"
);
function toggle() {
mode.value = mode.value === "words" ? "time" : "words";
localStorage.setItem(STORAGE_KEY, mode.value);
}
const plainText = computed(() =>
props.body
.replace(/```[\s\S]*?```/g, " ")
.replace(/`[^`]*`/g, " ")
.replace(/!?\[.*?\]\(.*?\)/g, " ")
.replace(/\[\[([^\]]*)\]\]/g, "$1")
.replace(/[#*_~>|\\]/g, " ")
.trim()
);
const wordCount = computed(() => {
if (!plainText.value) return 0;
return plainText.value.split(/\s+/).filter(Boolean).length;
});
const charCount = computed(() => props.body.length);
const readingTime = computed(() => Math.max(1, Math.ceil(wordCount.value / 200)));
const label = computed(() => {
if (!props.body.trim()) return "";
if (mode.value === "time") return `~${readingTime.value} min read`;
return `${wordCount.value.toLocaleString()} words · ${charCount.value.toLocaleString()} chars`;
});
</script>
<template>
<button v-if="label" class="word-count" @click="toggle" :title="mode === 'words' ? 'Switch to reading time' : 'Switch to word count'">
{{ label }}
</button>
</template>
<style scoped>
.word-count {
background: none;
border: none;
font-size: 0.72rem;
color: var(--color-text-muted);
cursor: pointer;
padding: 0;
white-space: nowrap;
flex-shrink: 0;
}
.word-count:hover { color: var(--color-text); }
</style>
+189 -9
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from "vue"; import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
import type { Editor } from "@tiptap/vue-3"; import type { Editor } from "@tiptap/vue-3";
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client"; import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
@@ -9,6 +9,7 @@ import { useNotesStore } from "@/stores/notes";
import TiptapEditor from "@/components/TiptapEditor.vue"; import TiptapEditor from "@/components/TiptapEditor.vue";
import TagInput from "@/components/TagInput.vue"; import TagInput from "@/components/TagInput.vue";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue"; import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import WordCount from "@/components/WordCount.vue";
const props = defineProps<{ const props = defineProps<{
projectId: number; projectId: number;
@@ -35,6 +36,25 @@ const projectNotes = ref<NoteItem[]>([]);
const listLoading = ref(false); const listLoading = ref(false);
const deletingId = ref<number | null>(null); // confirming delete for this id const deletingId = ref<number | null>(null); // confirming delete for this id
const pendingDelete = ref<number | null>(null); // mid-deletion const pendingDelete = ref<number | null>(null); // mid-deletion
const searchQuery = ref("");
const filteredNotes = computed(() => {
const q = searchQuery.value.trim().toLowerCase();
if (!q) return projectNotes.value;
return projectNotes.value.filter(
(n) =>
n.title.toLowerCase().includes(q) ||
n.tags?.some((t) => t.toLowerCase().includes(q))
);
});
function matchedTags(note: NoteItem): string[] {
const q = searchQuery.value.trim().toLowerCase();
if (!q) return [];
return (note.tags ?? []).filter(
(t) => t.toLowerCase().includes(q) && !note.title.toLowerCase().includes(q)
);
}
// Editor state // Editor state
const editingId = ref<number | null>(null); const editingId = ref<number | null>(null);
@@ -46,6 +66,44 @@ const saving = ref(false);
const tagSuggestions = useTagSuggestions(noteTitle, noteBody, noteTags, () => { dirty.value = true; }); const tagSuggestions = useTagSuggestions(noteTitle, noteBody, noteTags, () => { dirty.value = true; });
// New note inline creation
const showNewNoteInput = ref(false);
const newNoteTitle = ref("");
const creatingNote = ref(false);
const newNoteTitleRef = ref<HTMLInputElement | null>(null);
async function startNewNote() {
showNewNoteInput.value = true;
newNoteTitle.value = "";
await nextTick();
newNoteTitleRef.value?.focus();
}
function cancelNewNote() {
showNewNoteInput.value = false;
newNoteTitle.value = "";
}
async function createNote() {
const title = newNoteTitle.value.trim();
if (!title || creatingNote.value) return;
creatingNote.value = true;
try {
const note = await apiPost<{ id: number; title: string; body: string; tags: string[]; updated_at: string }>(
"/api/notes",
{ title, project_id: props.projectId, body: "", is_task: false }
);
showNewNoteInput.value = false;
newNoteTitle.value = "";
await loadProjectNotes();
await openNote(note.id);
} catch {
toast.show("Failed to create note", "error");
} finally {
creatingNote.value = false;
}
}
// TipTap editor ref (for MarkdownToolbar) // TipTap editor ref (for MarkdownToolbar)
const titleRef = ref<HTMLInputElement | null>(null); const titleRef = ref<HTMLInputElement | null>(null);
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null); const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
@@ -168,6 +226,7 @@ async function saveNote() {
function backToList() { function backToList() {
view.value = "list"; view.value = "list";
deletingId.value = null; deletingId.value = null;
searchQuery.value = "";
} }
function requestDelete(id: number, e: Event) { function requestDelete(id: number, e: Event) {
@@ -245,6 +304,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
<div class="panel-header"> <div class="panel-header">
<button class="btn-back-arrow" @click="backToList"> Notes</button> <button class="btn-back-arrow" @click="backToList"> Notes</button>
<div class="editor-header-right"> <div class="editor-header-right">
<WordCount :body="noteBody" />
<span v-if="dirty && !saving" class="unsaved">Unsaved</span> <span v-if="dirty && !saving" class="unsaved">Unsaved</span>
<span v-if="saving" class="saving-txt">Saving...</span> <span v-if="saving" class="saving-txt">Saving...</span>
<button class="btn-save" :disabled="saving || !dirty" @click="saveNote">Save</button> <button class="btn-save" :disabled="saving || !dirty" @click="saveNote">Save</button>
@@ -288,7 +348,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
> >
#{{ tag }}{{ tagSuggestions.appliedTags.value.has(tag) ? ' ✓' : '' }} #{{ tag }}{{ tagSuggestions.appliedTags.value.has(tag) ? ' ✓' : '' }}
</button> </button>
<button class="btn-dismiss-suggestions" @click="tagSuggestions.dismissTagSuggestions()"></button> <button class="btn-dismiss-suggestions" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()"></button>
</div> </div>
<!-- Formatting toolbar --> <!-- Formatting toolbar -->
@@ -308,7 +368,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
<button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button> <button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button>
</span> </span>
<button class="btn-link-all" @click="applyAllLinks" title="Link all suggestions">All</button> <button class="btn-link-all" @click="applyAllLinks" title="Link all suggestions">All</button>
<button class="btn-dismiss-suggestions" @click="linkSuggestions = []"></button> <button class="btn-dismiss-suggestions" aria-label="Dismiss link suggestions" @click="linkSuggestions = []"></button>
</div> </div>
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()"> <div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()">
@@ -319,14 +379,42 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
<!-- LIST VIEW --> <!-- LIST VIEW -->
<template v-else> <template v-else>
<div class="panel-header"> <div class="panel-header">
<span class="panel-title">Notes</span> <template v-if="showNewNoteInput">
<input
ref="newNoteTitleRef"
v-model="newNoteTitle"
class="new-note-input"
placeholder="Note title..."
:disabled="creatingNote"
@keydown.enter="createNote"
@keydown.escape="cancelNewNote"
/>
<button class="btn-new-note-confirm" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
{{ creatingNote ? '...' : '+' }}
</button>
<button class="btn-new-note-cancel" aria-label="Cancel new note" @click="cancelNewNote"></button>
</template>
<template v-else>
<span class="panel-title">Notes</span>
<button class="btn-new-note" @click="startNewNote" title="New note">+ New</button>
</template>
</div>
<div class="note-search-bar">
<input
v-model="searchQuery"
class="note-search-input"
placeholder="Search notes..."
type="search"
/>
<button v-if="searchQuery" class="btn-search-clear" @click="searchQuery = ''" title="Clear search"></button>
</div> </div>
<div v-if="listLoading" class="state-msg">Loading...</div> <div v-if="listLoading" class="state-msg">Loading...</div>
<ul v-else class="note-list"> <ul v-else class="note-list">
<li <li
v-for="note in projectNotes" v-for="note in filteredNotes"
:key="note.id" :key="note.id"
class="note-row" class="note-row"
:class="{ active: editingId === note.id }" :class="{ active: editingId === note.id }"
@@ -335,7 +423,11 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
<div class="note-row-main"> <div class="note-row-main">
<span class="note-row-title">{{ note.title }}</span> <span class="note-row-title">{{ note.title }}</span>
<span v-if="note.tags?.length" class="note-row-tags"> <span v-if="note.tags?.length" class="note-row-tags">
<span v-for="tag in note.tags.slice(0, 3)" :key="tag" class="note-tag-pill">#{{ tag }}</span> <span
v-for="tag in note.tags.slice(0, 3)"
:key="tag"
:class="['note-tag-pill', { 'tag-match': matchedTags(note).includes(tag) }]"
>#{{ tag }}</span>
<span v-if="note.tags.length > 3" class="note-tag-more">+{{ note.tags.length - 3 }}</span> <span v-if="note.tags.length > 3" class="note-tag-more">+{{ note.tags.length - 3 }}</span>
</span> </span>
<span class="note-row-age">{{ formatDate(note.updated_at) }}</span> <span class="note-row-age">{{ formatDate(note.updated_at) }}</span>
@@ -350,7 +442,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
> >
{{ pendingDelete === note.id ? '...' : 'Delete?' }} {{ pendingDelete === note.id ? '...' : 'Delete?' }}
</button> </button>
<button class="btn-cancel-delete" @click="cancelDelete($event)"></button> <button class="btn-cancel-delete" aria-label="Cancel delete" @click="cancelDelete($event)"></button>
</template> </template>
<button <button
v-else v-else
@@ -363,8 +455,8 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
</div> </div>
</li> </li>
<li v-if="projectNotes.length === 0" class="state-msg"> <li v-if="filteredNotes.length === 0" class="state-msg">
No notes yet ask the agent to create one. {{ searchQuery.trim() ? 'No notes match.' : 'No notes yet ask the agent to create one.' }}
</li> </li>
</ul> </ul>
</template> </template>
@@ -588,6 +680,41 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
padding: 0.5rem 0.6rem; padding: 0.5rem 0.6rem;
} }
/* Search bar */
.note-search-bar {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.note-search-input {
flex: 1;
background: var(--color-input-bg, var(--color-bg));
border: 1px solid var(--color-border);
border-radius: 5px;
padding: 0.25rem 0.5rem;
font-size: 0.82rem;
color: var(--color-text);
min-width: 0;
}
.note-search-input:focus { outline: none; border-color: var(--color-primary); }
.note-search-input::-webkit-search-cancel-button { display: none; }
.btn-search-clear {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.72rem;
cursor: pointer;
padding: 0.1rem 0.25rem;
flex-shrink: 0;
border-radius: 3px;
}
.btn-search-clear:hover { color: var(--color-text); }
/* List */ /* List */
.note-list { .note-list {
list-style: none; list-style: none;
@@ -646,6 +773,11 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
max-width: 6rem; max-width: 6rem;
} }
.note-tag-pill.tag-match {
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
outline: 1px solid var(--color-primary);
}
.note-tag-more { .note-tag-more {
font-size: 0.62rem; font-size: 0.62rem;
color: var(--color-text-muted); color: var(--color-text-muted);
@@ -707,4 +839,52 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
font-size: 0.85rem; font-size: 0.85rem;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.btn-new-note {
margin-left: auto;
background: none;
border: 1px solid var(--color-border);
border-radius: 5px;
padding: 0.2rem 0.55rem;
font-size: 0.75rem;
color: var(--color-text-muted);
cursor: pointer;
}
.btn-new-note:hover { border-color: var(--color-primary); color: var(--color-primary); }
.new-note-input {
flex: 1;
background: var(--color-input-bg, var(--color-bg));
border: 1px solid var(--color-primary);
border-radius: 5px;
padding: 0.22rem 0.5rem;
font-size: 0.83rem;
color: var(--color-text);
min-width: 0;
}
.new-note-input:focus { outline: none; }
.btn-new-note-confirm {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 5px;
padding: 0.22rem 0.5rem;
font-size: 0.9rem;
cursor: pointer;
flex-shrink: 0;
line-height: 1;
}
.btn-new-note-confirm:disabled { opacity: 0.4; cursor: default; }
.btn-new-note-cancel {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.75rem;
cursor: pointer;
padding: 0.1rem 0.3rem;
flex-shrink: 0;
}
.btn-new-note-cancel:hover { color: var(--color-text); }
</style> </style>
+63 -2
View File
@@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from "vue"; import { ref, computed, onMounted } from "vue";
import { RouterLink } from "vue-router";
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client"; import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import TaskLogSection from "@/components/TaskLogSection.vue"; import TaskLogSection from "@/components/TaskLogSection.vue";
import { renderMarkdown } from "@/utils/markdown";
const props = defineProps<{ projectId: number }>(); const props = defineProps<{ projectId: number }>();
@@ -23,6 +25,7 @@ interface Task {
milestone_id: number | null; milestone_id: number | null;
due_date: string | null; due_date: string | null;
updated_at: string; updated_at: string;
body?: string;
} }
const tasks = ref<Task[]>([]); const tasks = ref<Task[]>([]);
@@ -31,6 +34,8 @@ const loading = ref(false);
// Detail slide-over // Detail slide-over
const activeTask = ref<Task | null>(null); const activeTask = ref<Task | null>(null);
const taskBody = ref("");
const loadingBody = ref(false);
// Collapsed milestone groups (set of milestone IDs, null = "No Milestone" group) // Collapsed milestone groups (set of milestone IDs, null = "No Milestone" group)
const collapsedGroups = ref<Set<number | null>>(new Set()); const collapsedGroups = ref<Set<number | null>>(new Set());
@@ -97,12 +102,25 @@ function toggleGroup(key: number | null) {
} }
} }
function openTask(task: Task) { async function openTask(task: Task) {
activeTask.value = task; activeTask.value = task;
taskBody.value = task.body ?? "";
loadingBody.value = true;
try {
const full = await apiGet<Task>(`/api/tasks/${task.id}`);
taskBody.value = full.body ?? "";
// Update cached task so re-opens don't re-fetch if unchanged
task.body = full.body;
} catch {
// Non-critical — body just won't show
} finally {
loadingBody.value = false;
}
} }
function closeTask() { function closeTask() {
activeTask.value = null; activeTask.value = null;
taskBody.value = "";
deleteConfirmPending.value = false; deleteConfirmPending.value = false;
} }
@@ -194,6 +212,12 @@ defineExpose({ reload: loadAll });
<div v-if="activeTask" class="task-detail"> <div v-if="activeTask" class="task-detail">
<div class="detail-header"> <div class="detail-header">
<button class="btn-back-arrow" @click="closeTask"> Tasks</button> <button class="btn-back-arrow" @click="closeTask"> Tasks</button>
<RouterLink
:to="`/tasks/${activeTask.id}/edit`"
target="_blank"
class="btn-edit-task"
title="Open full editor"
>Edit </RouterLink>
<span <span
:class="['status-badge', `status-${activeTask.status}`]" :class="['status-badge', `status-${activeTask.status}`]"
@click="cycleStatus(activeTask, $event)" @click="cycleStatus(activeTask, $event)"
@@ -206,7 +230,7 @@ defineExpose({ reload: loadAll });
<button class="btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask"> <button class="btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">
{{ deletingTask ? '...' : 'Delete?' }} {{ deletingTask ? '...' : 'Delete?' }}
</button> </button>
<button class="btn-delete-cancel" @click="cancelDeleteTask"></button> <button class="btn-delete-cancel" aria-label="Cancel delete" @click="cancelDeleteTask"></button>
</template> </template>
<button v-else class="btn-delete-task" title="Delete task" @click="deleteActiveTask"> <button v-else class="btn-delete-task" title="Delete task" @click="deleteActiveTask">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
@@ -233,6 +257,11 @@ defineExpose({ reload: loadAll });
</select> </select>
</div> </div>
<div v-if="taskBody || loadingBody" class="detail-body">
<div v-if="loadingBody" class="body-loading"></div>
<div v-else class="prose" v-html="renderMarkdown(taskBody)" />
</div>
<div class="detail-log"> <div class="detail-log">
<TaskLogSection :task-id="activeTask.id" /> <TaskLogSection :task-id="activeTask.id" />
</div> </div>
@@ -527,6 +556,38 @@ defineExpose({ reload: loadAll });
.status-badge.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); } .status-badge.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); }
.status-badge.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); background: color-mix(in srgb, var(--color-success, #27ae60) 10%, transparent); } .status-badge.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); background: color-mix(in srgb, var(--color-success, #27ae60) 10%, transparent); }
.btn-edit-task {
background: none;
border: none;
color: var(--color-primary);
font-size: 0.78rem;
cursor: pointer;
padding: 0.1rem 0.3rem;
border-radius: 3px;
text-decoration: none;
flex-shrink: 0;
}
.btn-edit-task:hover { text-decoration: underline; }
.detail-body {
padding: 0.5rem 0.75rem 0.5rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
max-height: 40%;
overflow-y: auto;
}
.body-loading {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.detail-body .prose {
font-size: 0.83rem;
line-height: 1.5;
color: var(--color-text);
}
.btn-delete-task { .btn-delete-task {
background: none; background: none;
border: none; border: none;
+69
View File
@@ -0,0 +1,69 @@
import { Extension } from "@tiptap/vue-3";
import { PluginKey } from "@tiptap/pm/state";
import Suggestion from "@tiptap/suggestion";
import { createSuggestionRenderer } from "./suggestionRenderer";
import type { SuggestionItem } from "@/components/SuggestionDropdown.vue";
import type { Editor } from "@tiptap/vue-3";
const slashCommandsKey = new PluginKey("slashCommands");
interface SlashCommand {
label: string;
value: string;
keywords: string[];
}
const COMMANDS: SlashCommand[] = [
{ label: "Heading 1", value: "h1", keywords: ["heading", "h1", "title"] },
{ label: "Heading 2", value: "h2", keywords: ["heading", "h2", "subtitle"] },
{ label: "Heading 3", value: "h3", keywords: ["heading", "h3"] },
{ label: "Bullet List", value: "ul", keywords: ["bullet", "list", "ul", "unordered"] },
{ label: "Numbered List", value: "ol", keywords: ["numbered", "ordered", "ol", "number"] },
{ label: "Task List", value: "task", keywords: ["task", "todo", "checkbox", "check"] },
{ label: "Code Block", value: "code", keywords: ["code", "block", "pre", "codeblock"] },
{ label: "Blockquote", value: "quote", keywords: ["quote", "blockquote"] },
];
function applyCommand(editor: Editor, value: string) {
const chain = editor.chain().focus();
switch (value) {
case "h1": chain.toggleHeading({ level: 1 }).run(); break;
case "h2": chain.toggleHeading({ level: 2 }).run(); break;
case "h3": chain.toggleHeading({ level: 3 }).run(); break;
case "ul": chain.toggleBulletList().run(); break;
case "ol": chain.toggleOrderedList().run(); break;
case "task": chain.toggleTaskList().run(); break;
case "code": chain.toggleCodeBlock().run(); break;
case "quote": chain.toggleBlockquote().run(); break;
}
}
export const SlashCommands = Extension.create({
name: "slashCommands",
addProseMirrorPlugins() {
return [
Suggestion<SuggestionItem>({
editor: this.editor,
pluginKey: slashCommandsKey,
char: "/",
allowSpaces: false,
items: ({ query }): SuggestionItem[] => {
const q = query.toLowerCase();
return COMMANDS
.filter((cmd) =>
!q ||
cmd.label.toLowerCase().includes(q) ||
cmd.keywords.some((k) => k.includes(q))
)
.map((cmd) => ({ label: cmd.label, value: cmd.value }));
},
command: ({ editor, range, props: item }) => {
editor.chain().focus().deleteRange(range).run();
applyCommand(editor as Editor, item.value);
},
render: createSuggestionRenderer,
}),
];
},
});
+12
View File
@@ -115,6 +115,17 @@ export const useChatStore = defineStore("chat", () => {
} }
} }
async function bulkDeleteConversations(ids: number[]) {
if (!ids.length) return;
await apiPost("/api/chat/conversations/bulk-delete", { ids });
const idSet = new Set(ids);
conversations.value = conversations.value.filter((c) => !idSet.has(c.id));
if (currentConversation.value && idSet.has(currentConversation.value.id)) {
currentConversation.value = null;
}
for (const id of ids) delete convStreams.value[id];
}
async function deleteConversation(id: number) { async function deleteConversation(id: number) {
try { try {
await apiDelete(`/api/chat/conversations/${id}`); await apiDelete(`/api/chat/conversations/${id}`);
@@ -494,6 +505,7 @@ export const useChatStore = defineStore("chat", () => {
createConversation, createConversation,
fetchConversation, fetchConversation,
deleteConversation, deleteConversation,
bulkDeleteConversations,
updateTitle, updateTitle,
sendMessage, sendMessage,
reconnectIfGenerating, reconnectIfGenerating,
+1 -1
View File
@@ -10,7 +10,7 @@ export interface ToolCallRecord {
function: string; function: string;
arguments: Record<string, unknown>; arguments: Record<string, unknown>;
result: { success: boolean; type?: string; data?: Record<string, unknown>; error?: string; suggested_tags?: string[] }; result: { success: boolean; type?: string; data?: Record<string, unknown>; error?: string; suggested_tags?: string[] };
status: "success" | "error" | "declined"; status: "running" | "success" | "error" | "declined";
} }
export interface ToolPendingRecord { export interface ToolPendingRecord {
+21 -1
View File
@@ -36,8 +36,15 @@ function serializeNode(node: JSONContent, listIndent = 0): string {
(listIndent === 0 ? "\n" : "") (listIndent === 0 ? "\n" : "")
); );
case "taskList":
return (
serializeTaskList(node.content || [], listIndent) +
(listIndent === 0 ? "\n" : "")
);
case "listItem": case "listItem":
// Handled by bulletList/orderedList case "taskItem":
// Handled by their respective list serializers
return ""; return "";
case "codeBlock": { case "codeBlock": {
@@ -106,6 +113,17 @@ function serializeOrderedList(
return out; return out;
} }
function serializeTaskList(items: JSONContent[], indent: number): string {
let out = "";
for (const item of items) {
if (item.type !== "taskItem") continue;
const checked = item.attrs?.checked ? "x" : " ";
const prefix = " ".repeat(indent) + `- [${checked}] `;
out += serializeListItem(item.content || [], prefix, indent);
}
return out;
}
function serializeListItem( function serializeListItem(
children: JSONContent[], children: JSONContent[],
prefix: string, prefix: string,
@@ -131,6 +149,8 @@ function serializeListItem(
indent + 1, indent + 1,
child.attrs?.start ?? 1 child.attrs?.start ?? 1
); );
} else if (child.type === "taskList") {
out += serializeTaskList(child.content || [], indent + 1);
} else { } else {
out += serializeNode(child, indent + 1); out += serializeNode(child, indent + 1);
} }
+158 -8
View File
@@ -229,6 +229,51 @@ async function newConversation() {
await startNewConversation(); await startNewConversation();
} }
// Bulk selection state
const selectMode = ref(false);
const selectedIds = ref<Set<number>>(new Set());
const bulkConfirm = ref(false);
const bulkDeleting = ref(false);
function toggleSelectMode() {
selectMode.value = !selectMode.value;
selectedIds.value = new Set();
bulkConfirm.value = false;
}
function toggleSelectConv(id: number) {
if (selectedIds.value.has(id)) {
selectedIds.value.delete(id);
} else {
selectedIds.value.add(id);
}
selectedIds.value = new Set(selectedIds.value); // trigger reactivity
}
function selectAll() {
selectedIds.value = new Set(store.conversations.map((c) => c.id));
}
function clearSelection() {
selectedIds.value = new Set();
bulkConfirm.value = false;
}
async function bulkDelete() {
if (!bulkConfirm.value) { bulkConfirm.value = true; return; }
bulkDeleting.value = true;
try {
const ids = [...selectedIds.value];
await store.bulkDeleteConversations(ids);
if (ids.includes(convId.value ?? -1)) router.push("/chat");
selectedIds.value = new Set();
bulkConfirm.value = false;
selectMode.value = false;
} finally {
bulkDeleting.value = false;
}
}
async function removeConversation(id: number) { async function removeConversation(id: number) {
await store.deleteConversation(id); await store.deleteConversation(id);
if (convId.value === id) { if (convId.value === id) {
@@ -441,9 +486,31 @@ onUnmounted(() => {
@click="sidebarOpen = false" @click="sidebarOpen = false"
></div> ></div>
<aside class="chat-sidebar" :class="{ open: sidebarOpen }"> <aside class="chat-sidebar" :class="{ open: sidebarOpen }">
<button class="btn-new-conv" @click="newConversation"> <div class="sidebar-top-bar">
+ New Chat <button class="btn-new-conv" @click="newConversation">+ New Chat</button>
</button> <button
class="btn-select-mode"
:class="{ active: selectMode }"
@click="toggleSelectMode"
title="Select conversations"
>Select</button>
</div>
<!-- Bulk action bar -->
<div v-if="selectMode" class="bulk-bar">
<span class="bulk-count">{{ selectedIds.size }} selected</span>
<button class="bulk-link" @click="selectAll">All</button>
<button class="bulk-link" @click="clearSelection">None</button>
<button
v-if="selectedIds.size"
class="bulk-delete-btn"
:class="{ confirm: bulkConfirm }"
:disabled="bulkDeleting"
@click="bulkDelete"
>
{{ bulkDeleting ? '...' : bulkConfirm ? `Delete ${selectedIds.size}?` : `Delete (${selectedIds.size})` }}
</button>
</div>
<!-- RAG project scope --> <!-- RAG project scope -->
<div class="rag-scope-section"> <div class="rag-scope-section">
@@ -459,14 +526,22 @@ onUnmounted(() => {
v-for="conv in group.convs" v-for="conv in group.convs"
:key="conv.id" :key="conv.id"
class="conv-item" class="conv-item"
:class="{ active: convId === conv.id }" :class="{ active: convId === conv.id, selected: selectedIds.has(conv.id) }"
@click="selectConversation(conv.id)" @click="selectMode ? toggleSelectConv(conv.id) : selectConversation(conv.id)"
> >
<input
v-if="selectMode"
type="checkbox"
class="conv-checkbox"
:checked="selectedIds.has(conv.id)"
@click.stop="toggleSelectConv(conv.id)"
/>
<div class="conv-info"> <div class="conv-info">
<span class="conv-title">{{ conv.title || "Untitled" }}</span> <span class="conv-title">{{ conv.title || "Untitled" }}</span>
<span class="conv-date">{{ formatConvDate(conv.updated_at) }}</span> <span class="conv-date">{{ formatConvDate(conv.updated_at) }}</span>
</div> </div>
<button <button
v-if="!selectMode"
class="btn-delete-conv" class="btn-delete-conv"
@click.stop="removeConversation(conv.id)" @click.stop="removeConversation(conv.id)"
title="Delete conversation" title="Delete conversation"
@@ -484,7 +559,7 @@ onUnmounted(() => {
<section class="chat-main"> <section class="chat-main">
<template v-if="store.currentConversation"> <template v-if="store.currentConversation">
<div class="chat-header"> <div class="chat-header">
<button class="btn-sidebar-toggle hide-desktop" @click="toggleSidebar"> <button class="btn-sidebar-toggle hide-desktop" aria-label="Toggle sidebar" @click="toggleSidebar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/> <line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/> <line x1="3" y1="12" x2="21" y2="12"/>
@@ -726,7 +801,7 @@ onUnmounted(() => {
</template> </template>
<div v-else class="no-conversation"> <div v-else class="no-conversation">
<button class="btn-sidebar-toggle no-conv-toggle hide-desktop" @click="toggleSidebar"> <button class="btn-sidebar-toggle no-conv-toggle hide-desktop" aria-label="Toggle sidebar" @click="toggleSidebar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/> <line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/> <line x1="3" y1="12" x2="21" y2="12"/>
@@ -758,8 +833,14 @@ onUnmounted(() => {
background: var(--color-bg-secondary); background: var(--color-bg-secondary);
} }
.sidebar-top-bar {
display: flex;
gap: 0.5rem;
padding: 0.75rem;
}
.btn-new-conv { .btn-new-conv {
margin: 0.75rem; flex: 1;
padding: 0.5rem; padding: 0.5rem;
background: var(--color-primary); background: var(--color-primary);
color: #fff; color: #fff;
@@ -772,6 +853,75 @@ onUnmounted(() => {
opacity: 0.9; opacity: 0.9;
} }
.btn-select-mode {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
font-size: 0.82rem;
padding: 0.4rem 0.6rem;
cursor: pointer;
white-space: nowrap;
}
.btn-select-mode:hover,
.btn-select-mode.active { border-color: var(--color-primary); color: var(--color-primary); }
.bulk-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.75rem;
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-secondary));
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
flex-wrap: wrap;
}
.bulk-count {
font-size: 0.75rem;
color: var(--color-text-muted);
flex-shrink: 0;
}
.bulk-link {
background: none;
border: none;
color: var(--color-primary);
font-size: 0.75rem;
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.bulk-delete-btn {
margin-left: auto;
background: none;
border: 1px solid var(--color-danger, #e74c3c);
color: var(--color-danger, #e74c3c);
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
padding: 0.2rem 0.55rem;
cursor: pointer;
}
.bulk-delete-btn:disabled { opacity: 0.5; cursor: default; }
.bulk-delete-btn.confirm {
background: var(--color-danger, #e74c3c);
color: #fff;
}
.conv-checkbox {
flex-shrink: 0;
accent-color: var(--color-primary);
cursor: pointer;
width: 0.9rem;
height: 0.9rem;
}
.conv-item.selected {
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-secondary));
}
.rag-scope-section { .rag-scope-section {
padding: 0.5rem 0.75rem 0.25rem; padding: 0.5rem 0.75rem 0.25rem;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
+262 -1
View File
@@ -6,6 +6,7 @@ import { zoom, zoomIdentity } from "d3-zoom";
import { drag } from "d3-drag"; import { drag } from "d3-drag";
import { select } from "d3-selection"; import { select } from "d3-selection";
import { apiGet } from "@/api/client"; import { apiGet } from "@/api/client";
import { renderMarkdown } from "@/utils/markdown";
const router = useRouter(); const router = useRouter();
@@ -62,6 +63,52 @@ const tooltip = ref<{ visible: boolean; x: number; y: number; node: GraphNode |
node: null, node: null,
}); });
// peek panel state
const peekNode = ref<GraphNode | null>(null);
const peekBody = ref("");
const peekLoading = ref(false);
const peekLinkedNodes = computed<GraphNode[]>(() => {
if (!peekNode.value) return [];
const pid = String(peekNode.value.id);
const linkedIds = new Set<string>();
for (const e of edges.value) {
const sid = String(typeof e.source === "object" ? (e.source as GraphNode).id : e.source);
const tid = String(typeof e.target === "object" ? (e.target as GraphNode).id : e.target);
if (sid === pid) linkedIds.add(tid);
if (tid === pid) linkedIds.add(sid);
}
return nodes.value.filter(
(n) => linkedIds.has(String(n.id)) && n.type !== "tag"
);
});
async function openPeek(node: GraphNode) {
peekNode.value = node;
peekBody.value = "";
peekLoading.value = true;
try {
const endpoint = node.type === "task" ? `/api/tasks/${node.id}` : `/api/notes/${node.id}`;
const data = await apiGet<{ body?: string }>(endpoint);
peekBody.value = data.body ?? "";
} catch {
peekBody.value = "";
} finally {
peekLoading.value = false;
}
}
function closePeek() {
peekNode.value = null;
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && peekNode.value) {
closePeek();
e.stopPropagation();
}
}
let simulation: Simulation<GraphNode, undefined> | null = null; let simulation: Simulation<GraphNode, undefined> | null = null;
let resizeObserver: ResizeObserver | null = null; let resizeObserver: ResizeObserver | null = null;
@@ -208,7 +255,7 @@ function initGraph() {
if (d.type === "tag") { if (d.type === "tag") {
router.push(`/notes?tags=${encodeURIComponent(d.title)}`); router.push(`/notes?tags=${encodeURIComponent(d.title)}`);
} else { } else {
router.push(d.type === "task" ? `/tasks/${d.id}` : `/notes/${d.id}`); openPeek(d);
} }
}) })
.on("mouseover", (event: MouseEvent, d: GraphNode) => { .on("mouseover", (event: MouseEvent, d: GraphNode) => {
@@ -391,11 +438,13 @@ onMounted(async () => {
resizeObserver = new ResizeObserver(handleResize); resizeObserver = new ResizeObserver(handleResize);
if (containerRef.value) resizeObserver.observe(containerRef.value); if (containerRef.value) resizeObserver.observe(containerRef.value);
window.addEventListener("keydown", handleKeyDown);
}); });
onUnmounted(() => { onUnmounted(() => {
simulation?.stop(); simulation?.stop();
resizeObserver?.disconnect(); resizeObserver?.disconnect();
window.removeEventListener("keydown", handleKeyDown);
}); });
</script> </script>
@@ -466,6 +515,57 @@ onUnmounted(() => {
<p>No notes to display. Create some notes to see the graph.</p> <p>No notes to display. Create some notes to see the graph.</p>
</div> </div>
<!-- Peek panel -->
<Transition name="peek-slide">
<div v-if="peekNode" class="graph-peek-panel">
<div class="peek-header">
<span :class="['peek-type-badge', `peek-type-${peekNode.type}`]">{{ peekNode.type }}</span>
<div class="peek-actions">
<router-link
:to="peekNode.type === 'task' ? `/tasks/${peekNode.id}` : `/notes/${peekNode.id}`"
class="peek-link"
>Open</router-link>
<router-link
:to="peekNode.type === 'task' ? `/tasks/${peekNode.id}/edit` : `/notes/${peekNode.id}/edit`"
class="peek-link"
>Edit</router-link>
<button class="peek-close" @click="closePeek" title="Close (Esc)"></button>
</div>
</div>
<h2 class="peek-title">{{ peekNode.title }}</h2>
<div v-if="peekNode.tags?.length" class="peek-tags">
<span v-for="tag in peekNode.tags" :key="tag" class="tag-chip">#{{ tag }}</span>
</div>
<div class="peek-body-wrap">
<div v-if="peekLoading" class="peek-loading">Loading...</div>
<div
v-else-if="peekBody"
class="prose peek-prose"
v-html="renderMarkdown(peekBody)"
/>
<div v-else class="peek-empty">No content.</div>
</div>
<div v-if="peekLinkedNodes.length" class="peek-linked">
<div class="peek-linked-label">Linked notes</div>
<ul class="peek-linked-list">
<li
v-for="n in peekLinkedNodes"
:key="n.id"
class="peek-linked-item"
@click="openPeek(n)"
>
<span :class="['peek-linked-type', `peek-type-${n.type}`]">{{ n.type[0].toUpperCase() }}</span>
{{ n.title }}
</li>
</ul>
</div>
</div>
</Transition>
<div <div
v-if="tooltip.visible && tooltip.node" v-if="tooltip.visible && tooltip.node"
class="graph-tooltip" class="graph-tooltip"
@@ -669,4 +769,165 @@ onUnmounted(() => {
color: var(--color-text-muted); color: var(--color-text-muted);
text-transform: capitalize; text-transform: capitalize;
} }
/* ── Peek panel ── */
.graph-peek-panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 340px;
background: var(--color-bg-card);
border-left: 1px solid var(--color-border);
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 20;
}
.peek-slide-enter-active,
.peek-slide-leave-active {
transition: transform 0.2s ease;
}
.peek-slide-enter-from,
.peek-slide-leave-to {
transform: translateX(100%);
}
.peek-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.peek-type-badge {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.15rem 0.45rem;
border-radius: 10px;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
}
.peek-type-task { border-color: var(--color-primary); color: var(--color-primary); }
.peek-actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.peek-link {
font-size: 0.78rem;
color: var(--color-primary);
text-decoration: none;
}
.peek-link:hover { text-decoration: underline; }
.peek-close {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.8rem;
cursor: pointer;
padding: 0.1rem 0.25rem;
border-radius: 3px;
}
.peek-close:hover { color: var(--color-text); }
.peek-title {
margin: 0;
padding: 0.75rem 0.75rem 0.4rem;
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
flex-shrink: 0;
}
.peek-tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0 0.75rem 0.5rem;
flex-shrink: 0;
}
.peek-body-wrap {
flex: 1;
overflow-y: auto;
padding: 0 0.75rem 0.5rem;
border-bottom: 1px solid var(--color-border);
}
.peek-prose {
font-size: 0.85rem;
}
.peek-loading,
.peek-empty {
font-size: 0.82rem;
color: var(--color-text-muted);
padding-top: 0.5rem;
}
.peek-linked {
flex-shrink: 0;
padding: 0.5rem 0.75rem 0.75rem;
}
.peek-linked-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: 0.4rem;
}
.peek-linked-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
max-height: 160px;
overflow-y: auto;
}
.peek-linked-item {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.82rem;
color: var(--color-text);
cursor: pointer;
padding: 0.25rem 0.4rem;
border-radius: 4px;
}
.peek-linked-item:hover {
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card));
color: var(--color-primary);
}
.peek-linked-type {
font-size: 0.65rem;
font-weight: 700;
width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
background: var(--color-bg);
border: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--color-text-muted);
}
</style> </style>
+58 -15
View File
@@ -13,6 +13,7 @@ import { apiGet, apiPost } from "@/api/client";
import type { Editor } from "@tiptap/vue-3"; import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue"; import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import TiptapEditor from "@/components/TiptapEditor.vue"; import TiptapEditor from "@/components/TiptapEditor.vue";
import WordCount from "@/components/WordCount.vue";
import TagInput from "@/components/TagInput.vue"; import TagInput from "@/components/TagInput.vue";
import ProjectSelector from "@/components/ProjectSelector.vue"; import ProjectSelector from "@/components/ProjectSelector.vue";
import MilestoneSelector from "@/components/MilestoneSelector.vue"; import MilestoneSelector from "@/components/MilestoneSelector.vue";
@@ -147,6 +148,8 @@ watch(body, () => {
if (projectId.value) scheduleLinkCheck(); if (projectId.value) scheduleLinkCheck();
}); });
watch(noteId, () => { showPreview.value = false; });
watch(projectId, (pid) => { watch(projectId, (pid) => {
if (pid) fetchLinkSuggestions(); if (pid) fetchLinkSuggestions();
else linkSuggestions.value = []; else linkSuggestions.value = [];
@@ -324,6 +327,7 @@ onUnmounted(() => assist.clearSelection());
</button> </button>
<button v-if="isEditing" class="btn-delete" @click="remove">Delete</button> <button v-if="isEditing" class="btn-delete" @click="remove">Delete</button>
<button v-if="isEditing" class="btn-history" @click="showHistory = true">History</button> <button v-if="isEditing" class="btn-history" @click="showHistory = true">History</button>
<WordCount :body="body" />
</div> </div>
<input <input
ref="titleRef" ref="titleRef"
@@ -363,21 +367,23 @@ onUnmounted(() => assist.clearSelection());
<!-- Normal editor --> <!-- Normal editor -->
<template v-else> <template v-else>
<div v-show="!showPreview" class="body-editor-wrap"> <Transition name="tab-fade" mode="out-in">
<TiptapEditor <div v-if="!showPreview" class="body-editor-wrap">
ref="editorRef" <TiptapEditor
:modelValue="body" ref="editorRef"
placeholder="Write your note in Markdown..." :modelValue="body"
@update:modelValue="onBodyUpdate" placeholder="Write your note in Markdown..."
@selectionChange="onSelectionChange" @update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()" @selectionChange="onSelectionChange"
@escape="titleRef?.focus()"
/>
</div>
<div
v-else
class="preview-pane prose"
v-html="renderedPreview"
/> />
</div> </Transition>
<div
v-show="showPreview"
class="preview-pane prose"
v-html="renderedPreview"
/>
</template> </template>
<!-- Error from assist --> <!-- Error from assist -->
@@ -431,7 +437,7 @@ onUnmounted(() => assist.clearSelection());
#{{ tag }} #{{ tag }}
<span v-if="appliedTags.has(tag)" class="tag-check">&#10003;</span> <span v-if="appliedTags.has(tag)" class="tag-check">&#10003;</span>
</button> </button>
<button class="btn-dismiss-tags" @click="dismissTagSuggestions">&times;</button> <button class="btn-dismiss-tags" aria-label="Dismiss tag suggestions" @click="dismissTagSuggestions">&times;</button>
</template> </template>
</div> </div>
@@ -573,6 +579,43 @@ onUnmounted(() => assist.clearSelection());
gap: 0.25rem; gap: 0.25rem;
} }
.editor-tabs {
display: inline-flex;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 2px;
gap: 2px;
align-self: flex-start;
}
.tab {
background: none;
border: none;
border-radius: 6px;
padding: 0.22rem 0.75rem;
font-size: 0.78rem;
font-weight: 500;
color: var(--color-text-muted);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.tab:hover { color: var(--color-text); }
.tab.active {
background: var(--color-surface);
color: var(--color-text);
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
}
.tab-fade-enter-active,
.tab-fade-leave-active {
transition: opacity 0.12s ease;
}
.tab-fade-enter-from,
.tab-fade-leave-to {
opacity: 0;
}
.body-editor-wrap { .body-editor-wrap {
min-height: 200px; min-height: 200px;
} }
+80
View File
@@ -28,6 +28,19 @@ const exporting = ref(false);
const restoring = ref(false); const restoring = ref(false);
const restoreFileInput = ref<HTMLInputElement | null>(null); const restoreFileInput = ref<HTMLInputElement | null>(null);
// Chat retention
const chatRetentionDays = ref(90);
const savingRetention = ref(false);
async function saveRetention() {
savingRetention.value = true;
try {
await apiPut("/api/settings", { chat_retention_days: String(chatRetentionDays.value) });
} finally {
savingRetention.value = false;
}
}
// Notification preferences // Notification preferences
const notifyTaskReminders = ref(true); const notifyTaskReminders = ref(true);
const notifySecurityAlerts = ref(true); const notifySecurityAlerts = ref(true);
@@ -92,6 +105,9 @@ onMounted(async () => {
// Load notification preferences from user settings // Load notification preferences from user settings
const allSettings = await apiGet<Record<string, string>>("/api/settings"); const allSettings = await apiGet<Record<string, string>>("/api/settings");
defaultModel.value = allSettings.default_model ?? ""; defaultModel.value = allSettings.default_model ?? "";
chatRetentionDays.value = allSettings.chat_retention_days !== undefined
? Number(allSettings.chat_retention_days)
: 90;
if (allSettings.notify_task_reminders !== undefined) { if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false"; notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
} }
@@ -234,6 +250,29 @@ async function exportData(scope: "user" | "full") {
} }
} }
const exportingNotes = ref(false);
async function exportNotes(format: "markdown" | "json") {
exportingNotes.value = true;
try {
const res = await fetch(`/api/export?format=${format}`);
if (!res.ok) throw new Error(`Error ${res.status}`);
const blob = await res.blob();
const ext = format === "json" ? "json" : "zip";
const stamp = new Date().toISOString().slice(0, 10);
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `fabledassistant-${stamp}.${ext}`;
a.click();
URL.revokeObjectURL(a.href);
toastStore.show("Export downloaded");
} catch (e) {
toastStore.show("Export failed: " + (e as Error).message, "error");
} finally {
exportingNotes.value = false;
}
}
function triggerRestoreUpload() { function triggerRestoreUpload() {
restoreFileInput.value?.click(); restoreFileInput.value?.click();
} }
@@ -561,11 +600,41 @@ function hostname(url: string): string {
</div> </div>
</section> </section>
<!-- Chat Retention half width -->
<section class="settings-section">
<h2>Chat History</h2>
<p class="section-desc">
Conversations older than this many days are automatically deleted when you load the chat.
Set to <strong>0</strong> to keep conversations forever.
</p>
<div class="field retention-field">
<label class="field-label">Retention period (days)</label>
<div class="retention-row">
<input
v-model.number="chatRetentionDays"
type="number"
min="0"
max="3650"
class="input retention-input"
/>
<button class="btn-primary" :disabled="savingRetention" @click="saveRetention">
{{ savingRetention ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</section>
<!-- Data half width --> <!-- Data half width -->
<section class="settings-section"> <section class="settings-section">
<h2>Data</h2> <h2>Data</h2>
<p class="section-desc">Export your data or restore from a backup.</p> <p class="section-desc">Export your data or restore from a backup.</p>
<div class="data-actions"> <div class="data-actions">
<button class="btn-secondary" @click="exportNotes('markdown')" :disabled="exportingNotes">
{{ exportingNotes ? "Exporting..." : "Export as Markdown" }}
</button>
<button class="btn-secondary" @click="exportNotes('json')" :disabled="exportingNotes">
{{ exportingNotes ? "Exporting..." : "Export as JSON" }}
</button>
<button class="btn-secondary" @click="exportData('user')" :disabled="exporting"> <button class="btn-secondary" @click="exportData('user')" :disabled="exporting">
{{ exporting ? "Exporting..." : "Export My Data" }} {{ exporting ? "Exporting..." : "Export My Data" }}
</button> </button>
@@ -960,6 +1029,17 @@ function hostname(url: string): string {
color: var(--color-danger); color: var(--color-danger);
} }
.retention-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.4rem;
}
.retention-input {
width: 6rem;
}
/* Data buttons */ /* Data buttons */
.data-actions { .data-actions {
display: flex; display: flex;
+4 -2
View File
@@ -15,6 +15,7 @@ import type { TaskStatus, TaskPriority } from "@/types/task";
import type { Editor } from "@tiptap/vue-3"; import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue"; import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import TiptapEditor from "@/components/TiptapEditor.vue"; import TiptapEditor from "@/components/TiptapEditor.vue";
import WordCount from "@/components/WordCount.vue";
import TagInput from "@/components/TagInput.vue"; import TagInput from "@/components/TagInput.vue";
import ProjectSelector from "@/components/ProjectSelector.vue"; import ProjectSelector from "@/components/ProjectSelector.vue";
import MilestoneSelector from "@/components/MilestoneSelector.vue"; import MilestoneSelector from "@/components/MilestoneSelector.vue";
@@ -388,6 +389,7 @@ useEditorGuards(dirty, save);
<button class="btn-assist-toggle" :class="{ active: assistOpen }" @click="toggleAssist"> <button class="btn-assist-toggle" :class="{ active: assistOpen }" @click="toggleAssist">
Assist Assist
</button> </button>
<WordCount :body="body" />
</div> </div>
<input <input
v-model="title" v-model="title"
@@ -579,7 +581,7 @@ useEditorGuards(dirty, save);
#{{ tag }} #{{ tag }}
<span v-if="appliedTags.has(tag)" class="tag-check">&#10003;</span> <span v-if="appliedTags.has(tag)" class="tag-check">&#10003;</span>
</button> </button>
<button class="btn-dismiss-tags" @click="dismissTagSuggestions">&times;</button> <button class="btn-dismiss-tags" aria-label="Dismiss tag suggestions" @click="dismissTagSuggestions">&times;</button>
</template> </template>
</div> </div>
@@ -591,7 +593,7 @@ useEditorGuards(dirty, save);
<div class="assist-panel-header"> <div class="assist-panel-header">
<span class="assist-panel-title"> AI Assist</span> <span class="assist-panel-title"> AI Assist</span>
<button class="btn-proofread" @click="assist.proofread()" :disabled="assist.state.value === 'streaming'">Proofread</button> <button class="btn-proofread" @click="assist.proofread()" :disabled="assist.state.value === 'streaming'">Proofread</button>
<button class="btn-close-assist" @click="toggleAssist">×</button> <button class="btn-close-assist" aria-label="Close assist panel" @click="toggleAssist">×</button>
</div> </div>
<div class="assist-panel-body"> <div class="assist-panel-body">
<div v-if="assist.state.value === 'idle'" class="assist-idle"> <div v-if="assist.state.value === 'idle'" class="assist-idle">
+2
View File
@@ -12,6 +12,7 @@ from fabledassistant.routes.admin import admin_bp
from fabledassistant.routes.api import api from fabledassistant.routes.api import api
from fabledassistant.routes.auth import auth_bp from fabledassistant.routes.auth import auth_bp
from fabledassistant.routes.chat import chat_bp from fabledassistant.routes.chat import chat_bp
from fabledassistant.routes.export import export_bp
from fabledassistant.routes.notes import notes_bp from fabledassistant.routes.notes import notes_bp
from fabledassistant.routes.images import images_bp from fabledassistant.routes.images import images_bp
from fabledassistant.routes.milestones import milestones_bp from fabledassistant.routes.milestones import milestones_bp
@@ -56,6 +57,7 @@ def create_app() -> Quart:
app.register_blueprint(api) app.register_blueprint(api)
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(chat_bp) app.register_blueprint(chat_bp)
app.register_blueprint(export_bp)
app.register_blueprint(images_bp) app.register_blueprint(images_bp)
app.register_blueprint(milestones_bp) app.register_blueprint(milestones_bp)
app.register_blueprint(notes_bp) app.register_blueprint(notes_bp)
+22
View File
@@ -10,6 +10,8 @@ from fabledassistant.routes.utils import not_found
from fabledassistant.config import Config from fabledassistant.config import Config
from fabledassistant.services.chat import ( from fabledassistant.services.chat import (
add_message, add_message,
bulk_delete_conversations,
cleanup_old_conversations,
create_conversation, create_conversation,
delete_conversation, delete_conversation,
get_conversation, get_conversation,
@@ -38,6 +40,14 @@ async def list_conversations_route():
uid = get_current_user_id() uid = get_current_user_id()
limit = min(request.args.get("limit", 50, type=int), 500) limit = min(request.args.get("limit", 50, type=int), 500)
offset = request.args.get("offset", 0, type=int) offset = request.args.get("offset", 0, type=int)
# Apply retention policy before returning list
retention_str = await get_setting(uid, "chat_retention_days", "90")
try:
retention_days = int(retention_str)
except (ValueError, TypeError):
retention_days = 90
if retention_days > 0:
await cleanup_old_conversations(uid, retention_days)
conversations, total = await list_conversations(uid, limit=limit, offset=offset) conversations, total = await list_conversations(uid, limit=limit, offset=offset)
return jsonify({ return jsonify({
"conversations": conversations, "conversations": conversations,
@@ -45,6 +55,18 @@ async def list_conversations_route():
}) })
@chat_bp.route("/conversations/bulk-delete", methods=["POST"])
@login_required
async def bulk_delete_conversations_route():
uid = get_current_user_id()
data = await request.get_json()
ids = data.get("ids", []) if isinstance(data, dict) else []
if not isinstance(ids, list) or not all(isinstance(i, int) for i in ids):
return jsonify({"error": "ids must be a list of integers"}), 400
count = await bulk_delete_conversations(uid, ids)
return jsonify({"deleted": count})
@chat_bp.route("/conversations", methods=["POST"]) @chat_bp.route("/conversations", methods=["POST"])
@login_required @login_required
async def create_conversation_route(): async def create_conversation_route():
+117
View File
@@ -0,0 +1,117 @@
import io
import json
import re
import zipfile
from datetime import datetime, timezone
from quart import Blueprint, Response, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.models import async_session
from fabledassistant.models.note import Note
from sqlalchemy import select
export_bp = Blueprint("export", __name__)
def _safe_filename(title: str) -> str:
name = re.sub(r"[^\w\s-]", "", title or "untitled").strip()
name = re.sub(r"[\s]+", "_", name) or "untitled"
return name[:80]
def _note_frontmatter(note: Note) -> str:
lines = ["---"]
lines.append(f"title: {json.dumps(note.title or '')}")
lines.append(f"type: {'task' if note.is_task else 'note'}")
if note.tags:
lines.append(f"tags: [{', '.join(note.tags)}]")
if note.is_task:
lines.append(f"status: {note.status or 'todo'}")
lines.append(f"priority: {note.priority or 'none'}")
if note.due_date:
lines.append(f"due_date: {note.due_date}")
if note.project_id:
lines.append(f"project_id: {note.project_id}")
lines.append(f"created_at: {note.created_at.isoformat() if note.created_at else ''}")
lines.append(f"updated_at: {note.updated_at.isoformat() if note.updated_at else ''}")
lines.append("---")
return "\n".join(lines) + "\n\n"
async def _fetch_notes(uid: int):
async with async_session() as db:
result = await db.execute(
select(Note)
.where(Note.user_id == uid)
.order_by(Note.updated_at.desc())
)
return result.scalars().all()
@export_bp.get("/api/export")
@login_required
async def export_data():
uid = get_current_user_id()
fmt = request.args.get("format", "markdown")
notes = await _fetch_notes(uid)
if fmt == "json":
payload = [
{
"id": n.id,
"title": n.title,
"body": n.body,
"is_task": n.is_task,
"status": n.status,
"priority": n.priority,
"tags": n.tags or [],
"due_date": str(n.due_date) if n.due_date else None,
"project_id": n.project_id,
"milestone_id": n.milestone_id,
"parent_id": n.parent_id,
"created_at": n.created_at.isoformat() if n.created_at else None,
"updated_at": n.updated_at.isoformat() if n.updated_at else None,
}
for n in notes
]
data = json.dumps(payload, indent=2, ensure_ascii=False).encode("utf-8")
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
return Response(
data,
status=200,
headers={
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": f'attachment; filename="fabledassistant-{stamp}.json"',
},
)
# Markdown ZIP
buf = io.BytesIO()
used_names: dict[str, int] = {}
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
index_rows = []
for note in notes:
base = _safe_filename(note.title)
count = used_names.get(base, 0)
used_names[base] = count + 1
fname = f"{base}_{count}.md" if count else f"{base}.md"
content = _note_frontmatter(note) + (note.body or "")
zf.writestr(fname, content.encode("utf-8"))
index_rows.append({"file": fname, "title": note.title, "id": note.id})
zf.writestr("index.json", json.dumps(index_rows, indent=2, ensure_ascii=False))
buf.seek(0)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
return Response(
buf.read(),
status=200,
headers={
"Content-Type": "application/zip",
"Content-Disposition": f'attachment; filename="fabledassistant-{stamp}.zip"',
},
)
+31 -2
View File
@@ -1,7 +1,7 @@
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select from sqlalchemy import func, select, delete as sa_delete
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from fabledassistant.models import async_session from fabledassistant.models import async_session
@@ -103,6 +103,35 @@ async def delete_conversation(user_id: int, conversation_id: int) -> bool:
return True return True
async def bulk_delete_conversations(user_id: int, ids: list[int]) -> int:
"""Delete multiple conversations by ID for a user. Returns count deleted."""
if not ids:
return 0
async with async_session() as session:
result = await session.execute(
sa_delete(Conversation)
.where(Conversation.user_id == user_id, Conversation.id.in_(ids))
.returning(Conversation.id)
)
await session.commit()
return len(result.fetchall())
async def cleanup_old_conversations(user_id: int, days: int) -> int:
"""Delete conversations older than `days` days. Returns count deleted."""
if days <= 0:
return 0
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
async with async_session() as session:
result = await session.execute(
sa_delete(Conversation)
.where(Conversation.user_id == user_id, Conversation.updated_at < cutoff)
.returning(Conversation.id)
)
await session.commit()
return len(result.fetchall())
async def update_conversation( async def update_conversation(
user_id: int, user_id: int,
conversation_id: int, conversation_id: int,