Add Tiptap inline-preview editor, layout improvements, and README

Replace plain textarea with Tiptap (ProseMirror) WYSIWYG editor for notes
and tasks. Headings, bold, lists, and code blocks now render inline while
editing. Tags and wikilinks get visual highlighting via decoration plugins,
and autocomplete uses @tiptap/suggestion with heading disambiguation.

Layout: pin navbar with app-shell flex column (100dvh), sticky editor
toolbar above scrolling content, AI Assist panel fixed at bottom 1/3 with
full-height section selector and prompt. Increase max-width to 960px
across all views. Hide assist controls during streaming/review.

Other fixes: markdown paste handling, auto-syncing assist sections via
body watcher, trailing newline on AI accept, ChatView height fix.

Add README.md describing the app, usage guide, and technical overview.
Update summary.md with Phase 5.2 roadmap and current status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 21:24:35 -05:00
parent cbfdf5289e
commit 586026bff6
29 changed files with 2088 additions and 438 deletions
+139
View File
@@ -0,0 +1,139 @@
# Fabled Assistant
A self-hosted note-taking and task-tracking application with integrated LLM capabilities. Write, organize, and enhance your notes with the help of a local AI assistant — all running on your own hardware.
## What It Does
**Notes with inline formatting** — Write in Markdown with a live-preview editor. Headings, bold, italic, lists, and code blocks render inline as you type, similar to Obsidian or Typora.
**Task tracking** — Any note can become a task with a status (todo, in progress, done), priority level, and due date. Convert freely between notes and tasks without losing content.
**Wikilinks and backlinks** — Link notes together with `[[Title]]` syntax. Click a wikilink to navigate to (or auto-create) the referenced note. Each note shows what links to it.
**Tag organization** — Add `#tags` anywhere in your text. Tags are extracted automatically and support hierarchical filtering (e.g., `#project/webapp` matches both `project` and `project/webapp`). Tag autocomplete suggests existing tags as you type.
**AI writing assistant** — Select a section of your note and give the assistant an instruction ("summarize this", "make it more concise", "add examples"). The assistant streams a suggestion that you can accept or reject. Powered by Ollama running locally.
**AI chat** — Have conversations with your AI assistant. The chat is context-aware: it can automatically find and reference your notes to give more relevant answers. Attach specific notes for focused discussions. Save useful responses as new notes.
**Multi-user support** — Multiple users can share the same instance with isolated data. The first registered user becomes the admin.
**Dark and light themes** — Defaults to dark mode with a one-click toggle. All views respect your preference.
## Getting Started
### Prerequisites
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
- A machine with enough resources to run an LLM (8GB+ RAM recommended for smaller models)
### Quick Start
1. Clone the repository:
```bash
git clone https://github.com/your-username/fabledassistant.git
cd fabledassistant
```
2. Start the application:
```bash
docker compose up --build
```
3. Open `http://localhost:5000` in your browser.
4. Register the first user account (this account becomes the admin).
5. Go to **Settings** to download an LLM model. Smaller models like `llama3.2` (2GB) work well for getting started.
### Day-to-Day Usage
- **Create a note** from the Notes page. Use Markdown — formatting renders live in the editor.
- **Link notes** by typing `[[` to get a wikilink autocomplete dropdown.
- **Tag your notes** by typing `#` followed by a tag name. Autocomplete suggests existing tags.
- **Use the AI assistant** in the bottom panel of the editor. Select a section, write an instruction, and click Generate.
- **Chat with the AI** from the Chat page. Attach notes for context or let the assistant find relevant notes automatically.
- **Convert notes to tasks** from the note viewer to add status, priority, and due dates.
- **Save chat responses** as notes to preserve useful AI-generated content.
- **Backup your data** from Settings (admin users can export and restore full backups).
## Configuration
Configuration is done via environment variables. See `docker-compose.yml` for defaults.
| Variable | Default | Description |
|----------|---------|-------------|
| `DATABASE_URL` | `postgresql+asyncpg://...` | PostgreSQL connection string |
| `SECRET_KEY` | (required) | Session signing key |
| `OLLAMA_BASE_URL` | `http://ollama:11434` | Ollama API endpoint |
| `DEFAULT_MODEL` | `llama3.1` | Default LLM model to auto-pull on startup |
| `LOG_LEVEL` | `INFO` | Logging level |
For production deployments, `docker-compose.prod.yml` supports Docker secrets (`SECRET_KEY_FILE`, `DATABASE_URL_FILE`) and includes network isolation, health checks, and resource limits.
## Technical Overview
### Stack
| Layer | Technology |
|-------|------------|
| Frontend | Vue 3, TypeScript, Vite, Pinia, Vue Router |
| Editor | Tiptap (ProseMirror) with custom extensions |
| Backend | Python 3.12, Quart (async Flask-like framework) |
| Database | PostgreSQL 16, SQLAlchemy 2.0 (async), Alembic migrations |
| LLM | Ollama (or any OpenAI-compatible API) |
| Deployment | Docker Compose (app + PostgreSQL + Ollama) |
### Architecture
The application runs as a single container serving both the Vue.js SPA and the REST API. The frontend is built by Vite during the Docker image build and served as static files by Quart. API routes live under `/api/`.
LLM interactions use Server-Sent Events (SSE) for streaming responses. Chat generation runs in background `asyncio` tasks with an in-memory event buffer that supports client reconnection without data loss.
The editor uses a Markdown-to-HTML-to-Markdown round-trip: content is stored as Markdown, converted to HTML for Tiptap editing, and serialized back to Markdown on every change. ProseMirror decoration plugins provide visual highlighting for tags and wikilinks without custom node types.
### Database
All data is stored in PostgreSQL. The schema uses a unified model where tasks are notes with additional attributes (`status`, `priority`, `due_date`). Migrations are idempotent raw SQL and run automatically on container startup.
### Project Structure
```
fabledassistant/
├── docker-compose.yml # Development stack
├── docker-compose.prod.yml # Production stack (Docker Swarm)
├── Dockerfile # Multi-stage build (Node + Python)
├── alembic/ # Database migrations
├── src/fabledassistant/ # Python backend
│ ├── app.py # Quart app factory
│ ├── models/ # SQLAlchemy models
│ ├── routes/ # API route blueprints
│ ├── services/ # Business logic
│ └── static/ # Built frontend (generated)
└── frontend/ # Vue.js frontend
└── src/
├── views/ # Page components
├── components/ # Reusable UI components
├── extensions/ # Tiptap/ProseMirror plugins
├── composables/ # Vue composables
├── stores/ # Pinia state management
└── api/ # API client
```
### Development
All development is done via Docker:
```bash
# Start the dev stack
docker compose up --build
# Reset database (destroy volumes and rebuild)
docker compose down -v && docker compose up --build
```
No local dependency installation required.
## License
This project is privately maintained.
+6
View File
@@ -9,6 +9,12 @@
"preview": "vite preview"
},
"dependencies": {
"@tiptap/extension-link": "^2.11.0",
"@tiptap/extension-placeholder": "^2.11.0",
"@tiptap/pm": "^2.11.0",
"@tiptap/starter-kit": "^2.11.0",
"@tiptap/suggestion": "^2.11.0",
"@tiptap/vue-3": "^2.11.0",
"pinia": "^2.2.0",
"vue": "^3.5.0",
"marked": "^15.0.0",
+25 -26
View File
@@ -1,8 +1,6 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRoute } from "vue-router";
import { onMounted, onUnmounted, watch } from "vue";
import AppHeader from "@/components/AppHeader.vue";
import ChatPanel from "@/components/ChatPanel.vue";
import ToastNotification from "@/components/ToastNotification.vue";
import { useTheme } from "@/composables/useTheme";
import { useAuthStore } from "@/stores/auth";
@@ -11,25 +9,9 @@ import { useSettingsStore } from "@/stores/settings";
useTheme();
const route = useRoute();
const authStore = useAuthStore();
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
const chatPanelOpen = ref(false);
const contextNoteId = computed(() => {
const id = route.params.id;
if (!id) return null;
const path = route.path;
if (path.startsWith("/notes/") || path.startsWith("/tasks/")) {
return Number(id);
}
return null;
});
function toggleChatPanel() {
chatPanelOpen.value = !chatPanelOpen.value;
}
function startAppServices() {
chatStore.startStatusPolling();
@@ -65,16 +47,33 @@ onUnmounted(() => {
<template>
<template v-if="authStore.isAuthenticated">
<AppHeader @toggle-chat-panel="toggleChatPanel" />
<router-view />
<ChatPanel
v-if="chatPanelOpen"
:context-note-id="contextNoteId"
@close="chatPanelOpen = false"
/>
<div class="app-shell">
<AppHeader />
<div class="app-content">
<router-view />
</div>
</div>
</template>
<template v-else>
<router-view />
</template>
<ToastNotification />
</template>
<style>
.app-shell {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
overflow: hidden;
}
.app-shell > .app-header {
flex-shrink: 0;
}
.app-content {
flex: 1;
min-height: 0;
overflow-y: auto;
}
</style>
+26
View File
@@ -143,3 +143,29 @@
filter: brightness(0.9);
text-decoration: none;
}
/* Tiptap editor */
.tiptap-editor .ProseMirror {
outline: none;
min-height: 200px;
padding: 0.75rem;
}
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
color: var(--color-text-muted, var(--color-text-secondary));
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.tiptap-wrapper {
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
}
.tiptap-wrapper:focus-within {
box-shadow: var(--focus-ring, 0 0 0 2px var(--color-primary));
}
-22
View File
@@ -12,10 +12,6 @@ const chatStore = useChatStore();
const router = useRouter();
const mobileMenuOpen = ref(false);
const emit = defineEmits<{
toggleChatPanel: [];
}>();
const statusLabel = computed(() => {
if (chatStore.ollamaStatus === "unavailable") return "Ollama unavailable";
if (chatStore.modelStatus === "not_found") return "Model downloading...";
@@ -68,9 +64,6 @@ router.afterEach(() => {
<span class="status-indicator" :class="statusClass" :title="statusLabel">
<span class="status-dot"></span>
</span>
<button class="btn-chat-panel" @click="emit('toggleChatPanel')" title="Open chat panel">
&#x1F4AC;
</button>
<button class="theme-toggle" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`">
{{ theme === "dark" ? "\u2600" : "\u263E" }}
</button>
@@ -169,20 +162,6 @@ router.afterEach(() => {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.btn-chat-panel {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.25rem 0.5rem;
cursor: pointer;
font-size: 1rem;
color: var(--color-text);
line-height: 1;
margin-left: 0.25rem;
}
.btn-chat-panel:hover {
background: var(--color-bg-card);
}
.theme-toggle {
background: none;
border: 1px solid var(--color-border);
@@ -267,7 +246,6 @@ router.afterEach(() => {
border-top: 1px solid var(--color-border);
width: 100%;
}
.btn-chat-panel,
.theme-toggle,
.btn-logout {
min-height: 44px;
+78 -13
View File
@@ -1,18 +1,78 @@
<script setup lang="ts">
const emit = defineEmits<{
insert: [before: string, after: string, placeholder: string];
import type { Editor } from "@tiptap/vue-3";
const props = defineProps<{
editor: Editor | null;
}>();
const buttons = [
{ label: "B", before: "**", after: "**", placeholder: "bold", title: "Bold" },
{ label: "I", before: "_", after: "_", placeholder: "italic", title: "Italic" },
{ label: "H1", before: "# ", after: "", placeholder: "Heading", title: "Heading 1" },
{ label: "H2", before: "## ", after: "", placeholder: "Heading", title: "Heading 2" },
{ label: "Link", before: "[", after: "](url)", placeholder: "text", title: "Link" },
{ label: "UL", before: "- ", after: "", placeholder: "item", title: "Bullet List" },
{ label: "OL", before: "1. ", after: "", placeholder: "item", title: "Numbered List" },
{ label: "`", before: "`", after: "`", placeholder: "code", title: "Inline Code" },
{ label: "```", before: "```\n", after: "\n```", placeholder: "code block", title: "Code Block" },
{
label: "B",
title: "Bold",
command: () => props.editor?.chain().focus().toggleBold().run(),
isActive: () => props.editor?.isActive("bold") ?? false,
},
{
label: "I",
title: "Italic",
command: () => props.editor?.chain().focus().toggleItalic().run(),
isActive: () => props.editor?.isActive("italic") ?? false,
},
{
label: "H1",
title: "Heading 1",
command: () =>
props.editor?.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => props.editor?.isActive("heading", { level: 1 }) ?? false,
},
{
label: "H2",
title: "Heading 2",
command: () =>
props.editor?.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => props.editor?.isActive("heading", { level: 2 }) ?? false,
},
{
label: "Link",
title: "Link",
command: () => {
const ed = props.editor;
if (!ed) return;
if (ed.isActive("link")) {
ed.chain().focus().unsetLink().run();
} else {
const url = prompt("URL:");
if (url) {
ed.chain().focus().setLink({ href: url }).run();
}
}
},
isActive: () => props.editor?.isActive("link") ?? false,
},
{
label: "UL",
title: "Bullet List",
command: () => props.editor?.chain().focus().toggleBulletList().run(),
isActive: () => props.editor?.isActive("bulletList") ?? false,
},
{
label: "OL",
title: "Numbered List",
command: () => props.editor?.chain().focus().toggleOrderedList().run(),
isActive: () => props.editor?.isActive("orderedList") ?? false,
},
{
label: "`",
title: "Inline Code",
command: () => props.editor?.chain().focus().toggleCode().run(),
isActive: () => props.editor?.isActive("code") ?? false,
},
{
label: "```",
title: "Code Block",
command: () => props.editor?.chain().focus().toggleCodeBlock().run(),
isActive: () => props.editor?.isActive("codeBlock") ?? false,
},
];
</script>
@@ -21,9 +81,9 @@ const buttons = [
<button
v-for="btn in buttons"
:key="btn.label"
class="md-btn"
:class="['md-btn', { active: btn.isActive() }]"
:title="btn.title"
@click="emit('insert', btn.before, btn.after, btn.placeholder)"
@click="btn.command()"
>
{{ btn.label }}
</button>
@@ -50,4 +110,9 @@ const buttons = [
.md-btn:hover {
background: var(--color-bg-secondary);
}
.md-btn.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
</style>
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { ref, watch } from "vue";
export interface SuggestionItem {
label: string;
value: string;
}
const props = defineProps<{
items: SuggestionItem[];
command: (item: SuggestionItem) => void;
}>();
const selectedIndex = ref(0);
watch(
() => props.items,
() => {
selectedIndex.value = 0;
}
);
function onKeyDown(event: KeyboardEvent): boolean {
if (event.key === "ArrowUp") {
event.preventDefault();
selectedIndex.value =
(selectedIndex.value - 1 + props.items.length) % props.items.length;
return true;
}
if (event.key === "ArrowDown") {
event.preventDefault();
selectedIndex.value = (selectedIndex.value + 1) % props.items.length;
return true;
}
if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
selectItem(selectedIndex.value);
return true;
}
return false;
}
function selectItem(index: number) {
const item = props.items[index];
if (item) {
props.command(item);
}
}
defineExpose({ onKeyDown });
</script>
<template>
<ul v-if="items.length" class="ac-dropdown suggestion-dropdown">
<li
v-for="(item, i) in items"
:key="item.value"
:class="['ac-item', { active: i === selectedIndex }]"
@mousedown.prevent="selectItem(i)"
>
{{ item.label }}
</li>
</ul>
</template>
<style scoped>
.suggestion-dropdown {
position: relative;
z-index: 100;
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
box-shadow: 0 4px 12px var(--color-shadow);
max-height: 200px;
overflow-y: auto;
min-width: 160px;
}
.ac-item {
padding: 0.4rem 0.75rem;
cursor: pointer;
font-size: 0.9rem;
}
.ac-item:hover,
.ac-item.active {
background: var(--color-bg-secondary);
}
</style>
+141
View File
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { watch, onBeforeUnmount } from "vue";
import { Editor, EditorContent } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { serializeToMarkdown } from "@/utils/markdownSerializer";
import { TagDecoration } from "@/extensions/TagDecoration";
import { WikilinkDecoration } from "@/extensions/WikilinkDecoration";
import { TagSuggestion } from "@/extensions/TagSuggestion";
import { WikilinkSuggestion } from "@/extensions/WikilinkSuggestion";
const props = withDefaults(
defineProps<{
modelValue: string;
placeholder?: string;
fetchTags?: (query: string) => Promise<string[]>;
}>(),
{
placeholder: "",
fetchTags: undefined,
}
);
const emit = defineEmits<{
"update:modelValue": [value: string];
selectionChange: [payload: { text: string; start: number; end: number }];
}>();
let updatingFromProp = false;
let lastEmittedMarkdown = props.modelValue;
function markdownToHtml(md: string): string {
const html = marked(md) as string;
return DOMPurify.sanitize(html);
}
let editor: Editor | null = null;
try {
editor = new Editor({
content: markdownToHtml(props.modelValue),
editable: true,
editorProps: {
handlePaste: (_view, event) => {
const text = event.clipboardData?.getData("text/plain");
if (!text || !editor) return false;
// If clipboard already has HTML (e.g. copying from a webpage), let Tiptap handle it
const html = event.clipboardData?.getData("text/html");
if (html) return false;
// Check if the pasted text looks like it contains markdown formatting
const hasMd = /(?:^#{1,6}\s|^\s*[-*+]\s|^\s*\d+\.\s|^\s*>|```|\*\*|__|~~|\[.+\]\(.+\))/m.test(text);
if (!hasMd) return false;
// Convert markdown to HTML and insert as formatted content
const converted = markdownToHtml(text);
editor.chain().focus().deleteSelection().insertContent(converted).run();
return true;
},
},
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3, 4, 5, 6] },
}),
Link.configure({
openOnClick: false,
autolink: true,
}),
Placeholder.configure({
placeholder: props.placeholder,
}),
TagDecoration,
WikilinkDecoration,
TagSuggestion.configure({
fetchTags: props.fetchTags ?? (async () => []),
}),
WikilinkSuggestion,
],
onUpdate({ editor: ed }) {
if (updatingFromProp) return;
const md = serializeToMarkdown(ed.getJSON());
lastEmittedMarkdown = md;
emit("update:modelValue", md);
},
onSelectionUpdate({ editor: ed }) {
const { from, to } = ed.state.selection;
if (from === to) return;
const selectedText = ed.state.doc.textBetween(from, to, "\n");
if (!selectedText) return;
const md = lastEmittedMarkdown;
const idx = md.indexOf(selectedText);
if (idx !== -1) {
emit("selectionChange", {
text: selectedText,
start: idx,
end: idx + selectedText.length,
});
}
},
});
} catch (e) {
console.error("Tiptap editor creation failed:", e);
}
onBeforeUnmount(() => {
editor?.destroy();
});
// Watch for external modelValue changes (e.g. AI assist accept)
watch(
() => props.modelValue,
(newVal) => {
if (!editor) return;
if (newVal === lastEmittedMarkdown) return;
updatingFromProp = true;
editor.commands.setContent(markdownToHtml(newVal));
lastEmittedMarkdown = newVal;
updatingFromProp = false;
}
);
defineExpose({ editor });
</script>
<template>
<div class="tiptap-wrapper tiptap-editor">
<EditorContent v-if="editor" :editor="editor" class="prose" />
<div v-else class="editor-error">Editor failed to load. Check console.</div>
</div>
</template>
<style scoped>
.editor-error {
padding: 1rem;
color: var(--color-danger);
font-size: 0.9rem;
}
</style>
+204
View File
@@ -0,0 +1,204 @@
import { ref, computed, watch, type Ref } from "vue";
import { apiStreamPost } from "@/api/client";
import { useChatStore } from "@/stores/chat";
import {
parseMarkdownSections,
type MarkdownSection,
} from "@/utils/sectionParser";
export type AssistState = "idle" | "streaming" | "review";
export interface AssistTarget {
text: string;
startOffset: number;
endOffset: number;
}
export function useAssist(body: Ref<string>) {
const chatStore = useChatStore();
const state = ref<AssistState>("idle");
const sections = ref<MarkdownSection[]>([]);
const selectedSection = ref<MarkdownSection | null>(null);
const customSelection = ref<{ start: number; end: number; text: string } | null>(null);
const instruction = ref("");
const streamingText = ref("");
const proposedText = ref("");
const error = ref("");
// Snapshot of body at the time a section/selection was chosen
let bodySnapshot = "";
const target = computed<AssistTarget | null>(() => {
if (customSelection.value) {
return {
text: customSelection.value.text,
startOffset: customSelection.value.start,
endOffset: customSelection.value.end,
};
}
if (selectedSection.value) {
return {
text: selectedSection.value.content,
startOffset: selectedSection.value.startOffset,
endOffset: selectedSection.value.endOffset,
};
}
return null;
});
const canSubmit = computed(
() =>
target.value !== null &&
instruction.value.trim().length > 0 &&
chatStore.chatReady &&
state.value !== "streaming"
);
function refreshSections() {
sections.value = parseMarkdownSections(body.value);
}
function selectSection(section: MarkdownSection) {
customSelection.value = null;
selectedSection.value = section;
bodySnapshot = body.value;
error.value = "";
}
function selectTextRange(start: number, end: number) {
if (start === end) return;
selectedSection.value = null;
customSelection.value = {
start,
end,
text: body.value.slice(start, end),
};
bodySnapshot = body.value;
error.value = "";
}
function clearSelection() {
selectedSection.value = null;
customSelection.value = null;
instruction.value = "";
streamingText.value = "";
proposedText.value = "";
error.value = "";
state.value = "idle";
}
async function submit() {
if (!canSubmit.value || !target.value) return;
state.value = "streaming";
streamingText.value = "";
proposedText.value = "";
error.value = "";
try {
await apiStreamPost(
"/api/notes/assist",
{
body: body.value,
target_section: target.value.text,
instruction: instruction.value,
},
(data) => {
if (data.chunk) {
streamingText.value += data.chunk as string;
}
if (data.done) {
proposedText.value = (data.full_text as string) || streamingText.value;
state.value = "review";
}
if (data.error) {
error.value = data.error as string;
state.value = "idle";
}
}
);
// If stream ended without a done event, use accumulated text
if (state.value === "streaming") {
if (streamingText.value) {
proposedText.value = streamingText.value;
state.value = "review";
} else {
state.value = "idle";
}
}
} catch (e) {
error.value = e instanceof Error ? e.message : "Request failed";
state.value = "idle";
}
}
function accept(): string {
if (!target.value || !proposedText.value) return body.value;
const t = target.value;
// Validate that the body hasn't changed at the target offsets
if (body.value !== bodySnapshot) {
const currentSlice = body.value.slice(t.startOffset, t.endOffset);
if (currentSlice !== t.text) {
error.value = "The document changed since this suggestion was made. Please clear and try again.";
return body.value;
}
}
// Ensure proposed text ends with a newline for clean separation
let text = proposedText.value;
if (!text.endsWith("\n")) {
text += "\n";
}
const newBody =
body.value.slice(0, t.startOffset) +
text +
body.value.slice(t.endOffset);
// Reset state
selectedSection.value = null;
customSelection.value = null;
streamingText.value = "";
proposedText.value = "";
error.value = "";
state.value = "idle";
return newBody;
}
function reject() {
proposedText.value = "";
streamingText.value = "";
error.value = "";
state.value = "idle";
// Keep selection + instruction for retry
}
// Keep sections in sync with body automatically
watch(body, () => {
refreshSections();
}, { immediate: true });
return {
state,
sections,
selectedSection,
customSelection,
target,
instruction,
streamingText,
proposedText,
error,
canSubmit,
refreshSections,
selectSection,
selectTextRange,
clearSelection,
submit,
accept,
reject,
};
}
+35
View File
@@ -0,0 +1,35 @@
import { Extension } from "@tiptap/vue-3";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
const TAG_RE = /(?<=\s|^)#([\w]+(?:\/[\w]+)*)/g;
export const TagDecoration = Extension.create({
name: "tagDecoration",
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("tagDecoration"),
props: {
decorations(state) {
const decorations: Decoration[] = [];
state.doc.descendants((node, pos) => {
if (!node.isText || !node.text) return;
TAG_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = TAG_RE.exec(node.text)) !== null) {
const from = pos + match.index;
const to = from + match[0].length;
decorations.push(
Decoration.inline(from, to, { class: "inline-tag" })
);
}
});
return DecorationSet.create(state.doc, decorations);
},
},
}),
];
},
});
+61
View File
@@ -0,0 +1,61 @@
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";
export interface TagSuggestionOptions {
fetchTags: (query: string) => Promise<string[]>;
}
const tagSuggestionPluginKey = new PluginKey("tagSuggestion");
export const TagSuggestion = Extension.create<TagSuggestionOptions>({
name: "tagSuggestion",
addOptions() {
return {
fetchTags: async () => [],
};
},
addProseMirrorPlugins() {
return [
Suggestion<SuggestionItem>({
editor: this.editor,
pluginKey: tagSuggestionPluginKey,
char: "#",
allowSpaces: false,
startOfLine: false,
allow: ({ state, range }) => {
// Don't trigger at the start of a text block — the user is
// likely typing a heading (# / ## / ### …), not a tag.
const $from = state.doc.resolve(range.from);
const textBefore = $from.parent.textContent.slice(0, $from.parentOffset);
if (/^#*$/.test(textBefore)) return false;
return true;
},
items: async ({ query }): Promise<SuggestionItem[]> => {
try {
const tags = await this.options.fetchTags(query);
return tags.slice(0, 8).map((t) => ({
label: `#${t}`,
value: t,
}));
} catch {
return [];
}
},
command: ({ editor, range, props: item }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent(`#${item.value} `)
.run();
},
render: createSuggestionRenderer,
}),
];
},
});
@@ -0,0 +1,35 @@
import { Extension } from "@tiptap/vue-3";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
const WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
export const WikilinkDecoration = Extension.create({
name: "wikilinkDecoration",
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("wikilinkDecoration"),
props: {
decorations(state) {
const decorations: Decoration[] = [];
state.doc.descendants((node, pos) => {
if (!node.isText || !node.text) return;
WIKILINK_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = WIKILINK_RE.exec(node.text)) !== null) {
const from = pos + match.index;
const to = from + match[0].length;
decorations.push(
Decoration.inline(from, to, { class: "wikilink" })
);
}
});
return DecorationSet.create(state.doc, decorations);
},
},
}),
];
},
});
@@ -0,0 +1,49 @@
import { Extension } from "@tiptap/vue-3";
import { PluginKey } from "@tiptap/pm/state";
import Suggestion from "@tiptap/suggestion";
import { createSuggestionRenderer } from "./suggestionRenderer";
import { apiGet } from "@/api/client";
import type { SuggestionItem } from "@/components/SuggestionDropdown.vue";
interface NoteListResponse {
notes: Array<{ id: number; title: string }>;
}
const wikilinkSuggestionPluginKey = new PluginKey("wikilinkSuggestion");
export const WikilinkSuggestion = Extension.create({
name: "wikilinkSuggestion",
addProseMirrorPlugins() {
return [
Suggestion<SuggestionItem>({
editor: this.editor,
pluginKey: wikilinkSuggestionPluginKey,
char: "[[",
allowSpaces: true,
items: async ({ query }): Promise<SuggestionItem[]> => {
try {
const data = await apiGet<NoteListResponse>(
`/api/notes?q=${encodeURIComponent(query)}&limit=8`
);
return data.notes.map((n) => ({
label: n.title || "Untitled",
value: n.title || "Untitled",
}));
} catch {
return [];
}
},
command: ({ editor, range, props: item }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent(`[[${item.value}]]`)
.run();
},
render: createSuggestionRenderer,
}),
];
},
});
@@ -0,0 +1,79 @@
import { createApp, type App } from "vue";
import SuggestionDropdown from "@/components/SuggestionDropdown.vue";
import type { SuggestionItem } from "@/components/SuggestionDropdown.vue";
import type { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion";
interface DropdownExposed {
onKeyDown: (e: KeyboardEvent) => boolean;
}
export function createSuggestionRenderer() {
let app: App | null = null;
let el: HTMLElement | null = null;
let componentRef: DropdownExposed | null = null;
let commandFn: ((item: SuggestionItem) => void) | null = null;
function mountDropdown(items: SuggestionItem[]) {
if (!el) return;
if (app) app.unmount();
app = createApp(SuggestionDropdown, {
items,
command: (item: SuggestionItem) => commandFn?.(item),
});
const instance = app.mount(el);
componentRef = instance as unknown as DropdownExposed;
}
return {
onStart(props: SuggestionProps) {
commandFn = (item: SuggestionItem) => props.command(item);
el = document.createElement("div");
el.style.position = "fixed";
el.style.zIndex = "100";
document.body.appendChild(el);
mountDropdown(props.items as SuggestionItem[]);
updatePosition(props);
},
onUpdate(props: SuggestionProps) {
commandFn = (item: SuggestionItem) => props.command(item);
mountDropdown(props.items as SuggestionItem[]);
updatePosition(props);
},
onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === "Escape") {
cleanup();
return true;
}
return componentRef?.onKeyDown(props.event) ?? false;
},
onExit() {
cleanup();
},
};
function updatePosition(props: SuggestionProps) {
if (!el || !props.clientRect) return;
const rect = props.clientRect();
if (!rect) return;
el.style.top = rect.bottom + "px";
el.style.left = rect.left + "px";
}
function cleanup() {
if (app) {
app.unmount();
app = null;
}
if (el) {
el.remove();
el = null;
}
componentRef = null;
commandFn = null;
}
}
+183
View File
@@ -0,0 +1,183 @@
import type { JSONContent } from "@tiptap/vue-3";
export function serializeToMarkdown(doc: JSONContent): string {
if (doc.type !== "doc") return "";
return serializeNodes(doc.content || []).replace(/\n{3,}/g, "\n\n").trimEnd();
}
function serializeNodes(nodes: JSONContent[], listIndent = 0): string {
let out = "";
for (let i = 0; i < nodes.length; i++) {
out += serializeNode(nodes[i], listIndent);
}
return out;
}
function serializeNode(node: JSONContent, listIndent = 0): string {
switch (node.type) {
case "paragraph":
return serializeInline(node.content || []) + "\n\n";
case "heading": {
const level = node.attrs?.level ?? 1;
const prefix = "#".repeat(level) + " ";
return prefix + serializeInline(node.content || []) + "\n\n";
}
case "bulletList":
return (
serializeBulletList(node.content || [], listIndent) +
(listIndent === 0 ? "\n" : "")
);
case "orderedList":
return (
serializeOrderedList(node.content || [], listIndent, node.attrs?.start ?? 1) +
(listIndent === 0 ? "\n" : "")
);
case "listItem":
// Handled by bulletList/orderedList
return "";
case "codeBlock": {
const lang = node.attrs?.language || "";
const code = getTextContent(node);
return "```" + lang + "\n" + code + "\n```\n\n";
}
case "blockquote": {
const inner = serializeNodes(node.content || []);
return (
inner
.trimEnd()
.split("\n")
.map((line) => "> " + line)
.join("\n") + "\n\n"
);
}
case "horizontalRule":
return "---\n\n";
case "hardBreak":
return "\n";
case "text":
return applyMarks(node.text || "", node.marks || []);
default:
// Unknown node — try to serialize children
if (node.content) {
return serializeNodes(node.content);
}
return node.text || "";
}
}
function serializeBulletList(
items: JSONContent[],
indent: number
): string {
let out = "";
for (const item of items) {
if (item.type !== "listItem") continue;
const children = item.content || [];
const prefix = " ".repeat(indent) + "- ";
out += serializeListItem(children, prefix, indent);
}
return out;
}
function serializeOrderedList(
items: JSONContent[],
indent: number,
start: number
): string {
let out = "";
let num = start;
for (const item of items) {
if (item.type !== "listItem") continue;
const children = item.content || [];
const prefix = " ".repeat(indent) + num + ". ";
out += serializeListItem(children, prefix, indent);
num++;
}
return out;
}
function serializeListItem(
children: JSONContent[],
prefix: string,
indent: number
): string {
let out = "";
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (child.type === "paragraph") {
if (i === 0) {
out += prefix + serializeInline(child.content || []) + "\n";
} else {
out +=
" ".repeat(indent + 1) +
serializeInline(child.content || []) +
"\n";
}
} else if (child.type === "bulletList") {
out += serializeBulletList(child.content || [], indent + 1);
} else if (child.type === "orderedList") {
out += serializeOrderedList(
child.content || [],
indent + 1,
child.attrs?.start ?? 1
);
} else {
out += serializeNode(child, indent + 1);
}
}
return out;
}
function serializeInline(nodes: JSONContent[]): string {
let out = "";
for (const node of nodes) {
if (node.type === "text") {
out += applyMarks(node.text || "", node.marks || []);
} else if (node.type === "hardBreak") {
out += "\n";
} else if (node.content) {
out += serializeInline(node.content);
}
}
return out;
}
function applyMarks(text: string, marks: NonNullable<JSONContent["marks"]>): string {
let result = text;
for (const mark of marks) {
switch (mark.type) {
case "bold":
result = `**${result}**`;
break;
case "italic":
result = `_${result}_`;
break;
case "strike":
result = `~~${result}~~`;
break;
case "code":
result = "`" + result + "`";
break;
case "link":
result = `[${result}](${mark.attrs?.href || ""})`;
break;
}
}
return result;
}
function getTextContent(node: JSONContent): string {
if (node.text) return node.text;
if (!node.content) return "";
return node.content.map(getTextContent).join("");
}
+62
View File
@@ -0,0 +1,62 @@
export interface MarkdownSection {
heading: string;
level: number;
content: string;
startOffset: number;
endOffset: number;
}
const HEADING_RE = /^(#{1,6})\s+(.*)$/gm;
export function parseMarkdownSections(body: string): MarkdownSection[] {
const sections: MarkdownSection[] = [];
const matches: { index: number; level: number; heading: string }[] = [];
let match: RegExpExecArray | null;
while ((match = HEADING_RE.exec(body)) !== null) {
matches.push({
index: match.index,
level: match[1].length,
heading: match[0],
});
}
// Preamble: text before the first heading
if (matches.length === 0) {
// Entire body is a single preamble section
if (body.length > 0) {
sections.push({
heading: "",
level: 0,
content: body,
startOffset: 0,
endOffset: body.length,
});
}
return sections;
}
if (matches[0].index > 0) {
sections.push({
heading: "",
level: 0,
content: body.slice(0, matches[0].index),
startOffset: 0,
endOffset: matches[0].index,
});
}
for (let i = 0; i < matches.length; i++) {
const start = matches[i].index;
const end = i + 1 < matches.length ? matches[i + 1].index : body.length;
sections.push({
heading: matches[i].heading,
level: matches[i].level,
content: body.slice(start, end),
startOffset: start,
endOffset: end,
});
}
return sections;
}
+3 -3
View File
@@ -479,7 +479,7 @@ function excludeAutoNote(noteId: number) {
<style scoped>
.chat-page {
display: flex;
height: calc(100vh - 49px);
height: 100%;
overflow: hidden;
}
@@ -612,7 +612,7 @@ function excludeAutoNote(noteId: number) {
padding: 1rem 1.5rem;
display: flex;
flex-direction: column;
max-width: 848px;
max-width: 960px;
margin: 0 auto;
width: 100%;
}
@@ -732,7 +732,7 @@ function excludeAutoNote(noteId: number) {
/* Input wrapper */
.input-wrapper {
max-width: 848px;
max-width: 960px;
margin: 0 auto 2rem;
padding: 0 1rem;
width: 100%;
+1 -1
View File
@@ -137,7 +137,7 @@ async function newChat() {
<style scoped>
.home {
max-width: 720px;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
}
+349 -157
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, nextTick } from "vue";
import { ref, onMounted, onUnmounted, computed } from "vue";
import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router";
import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
import { useAutocomplete } from "@/composables/useAutocomplete";
import { useAssist } from "@/composables/useAssist";
import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import TiptapEditor from "@/components/TiptapEditor.vue";
const route = useRoute();
const router = useRouter();
@@ -17,7 +19,10 @@ const body = ref("");
const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
const tiptapEditor = computed<Editor | null>(() => {
return (editorRef.value?.editor as Editor | undefined) ?? null;
});
const noteId = computed(() =>
route.params.id ? Number(route.params.id) : null
@@ -26,18 +31,29 @@ const isEditing = computed(() => noteId.value !== null);
const renderedPreview = computed(() => renderMarkdown(body.value));
// Autocomplete
const {
acItems,
acVisible,
acIndex,
acTop,
acLeft,
detectTrigger,
accept: acAccept,
dismiss: acDismiss,
onKeydown: acOnKeydown,
} = useAutocomplete(textareaRef, body, (q) => store.fetchAllTags(q));
// AI Assist
const assist = useAssist(body);
const renderedStreaming = computed(() => renderMarkdown(assist.streamingText.value));
const renderedProposal = computed(() => renderMarkdown(assist.proposedText.value));
function onSelectionChange(payload: { text: string; start: number; end: number }) {
assist.selectTextRange(payload.start, payload.end);
}
function handleAssistAccept() {
const newBody = assist.accept();
if (newBody !== body.value) {
body.value = newBody;
markDirty();
toast.show("Section updated");
}
}
function truncateTarget(text: string, max = 60): string {
const first = text.split("\n")[0];
return first.length > max ? first.slice(0, max) + "..." : first;
}
// Track saved state for dirty detection
let savedTitle = "";
@@ -47,48 +63,9 @@ function markDirty() {
dirty.value = title.value !== savedTitle || body.value !== savedBody;
}
function autoGrow() {
const el = textareaRef.value;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.max(el.scrollHeight, 200) + "px";
}
function onBodyInput() {
function onBodyUpdate(newVal: string) {
body.value = newVal;
markDirty();
autoGrow();
detectTrigger();
}
function onTextareaKeydown(e: KeyboardEvent) {
acOnKeydown(e);
}
function onTextareaBlur() {
setTimeout(acDismiss, 150);
}
function insertAtCursor(before: string, after: string, placeholder: string) {
const el = textareaRef.value;
if (!el) return;
showPreview.value = false;
nextTick(() => {
el.focus();
const start = el.selectionStart;
const end = el.selectionEnd;
const selected = body.value.slice(start, end);
const insert = selected || placeholder;
body.value =
body.value.slice(0, start) + before + insert + after + body.value.slice(end);
markDirty();
nextTick(() => {
const newStart = start + before.length;
const newEnd = newStart + insert.length;
el.setSelectionRange(newStart, newEnd);
el.focus();
autoGrow();
});
});
}
onMounted(async () => {
@@ -101,7 +78,6 @@ onMounted(async () => {
savedBody = body.value;
}
}
nextTick(autoGrow);
});
async function save() {
@@ -183,74 +159,124 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</script>
<template>
<main class="editor">
<div class="toolbar">
<router-link to="/notes" class="btn-back">Back</router-link>
<button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}
</button>
<button v-if="isEditing" class="btn-delete" @click="remove">
Delete
</button>
</div>
<input
v-model="title"
type="text"
placeholder="Title"
class="title-input"
@input="markDirty"
/>
<main class="editor-layout">
<div class="editor-header">
<div class="toolbar">
<router-link to="/notes" class="btn-back">Back</router-link>
<button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}
</button>
<button v-if="isEditing" class="btn-delete" @click="remove">
Delete
</button>
</div>
<input
v-model="title"
type="text"
placeholder="Title"
class="title-input"
@input="markDirty"
/>
<div class="editor-tabs">
<button
:class="['tab', { active: !showPreview }]"
@click="showPreview = false"
>
Write
</button>
<button
:class="['tab', { active: showPreview }]"
@click="showPreview = true"
>
Preview
</button>
</div>
<MarkdownToolbar v-show="!showPreview" @insert="insertAtCursor" />
<div class="textarea-wrapper" v-show="!showPreview">
<textarea
ref="textareaRef"
v-model="body"
placeholder="Write your note in Markdown... Use #tags inline"
class="body-input"
@input="onBodyInput"
@keydown="onTextareaKeydown"
@blur="onTextareaBlur"
></textarea>
<!-- Autocomplete dropdown -->
<ul
v-if="acVisible"
class="ac-dropdown"
:style="{ top: acTop + 'px', left: acLeft + 'px' }"
>
<li
v-for="(item, i) in acItems"
:key="item.value"
:class="['ac-item', { active: i === acIndex }]"
@mousedown.prevent="acAccept(i)"
<div class="editor-tabs">
<button
:class="['tab', { active: !showPreview }]"
@click="showPreview = false"
>
{{ item.label }}
</li>
</ul>
Write
</button>
<button
:class="['tab', { active: showPreview }]"
@click="showPreview = true"
>
Preview
</button>
</div>
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
</div>
<div
v-show="showPreview"
class="preview-pane prose"
v-html="renderedPreview"
></div>
<div class="editor-scroll">
<div v-show="!showPreview">
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Write your note in Markdown... Use #tags inline"
:fetchTags="(q: string) => store.fetchAllTags(q)"
@update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange"
/>
</div>
<div
v-show="showPreview"
class="preview-pane prose"
v-html="renderedPreview"
></div>
</div>
<!-- AI Assist -->
<aside class="assist-panel">
<div class="assist-panel-header">
<h3 class="assist-panel-title">AI Assist</h3>
</div>
<div class="assist-panel-body">
<div v-if="assist.state.value === 'idle'" class="assist-controls">
<div class="assist-sections">
<div
v-for="(section, i) in assist.sections.value"
:key="i"
:class="['assist-section-item', { selected: assist.selectedSection.value === section }]"
@click="assist.selectSection(section)"
>
{{ section.heading || '(preamble)' }}
</div>
<div v-if="assist.sections.value.length === 0" class="assist-empty">
No sections found. Add headings (## ...) or select text.
</div>
</div>
<div class="assist-input" v-if="assist.target.value">
<div class="assist-target-preview">
Editing: {{ truncateTarget(assist.target.value.text) }}
</div>
<textarea
v-model="assist.instruction.value"
placeholder="What should I do with this section?"
class="assist-instruction"
rows="2"
></textarea>
<div class="assist-input-actions">
<button
class="btn-generate"
@click="assist.submit()"
:disabled="!assist.canSubmit.value"
>
Generate
</button>
<button class="btn-clear" @click="assist.clearSelection()">Clear</button>
</div>
</div>
</div>
<div v-if="assist.error.value" class="assist-error">
{{ assist.error.value }}
</div>
<div v-if="assist.state.value === 'streaming'" class="assist-preview">
<div class="assist-proposed prose" v-html="renderedStreaming"></div>
<span class="typing-indicator">...</span>
</div>
<div v-if="assist.state.value === 'review'" class="assist-review">
<div class="assist-proposed prose" v-html="renderedProposal"></div>
<div class="assist-actions">
<button class="btn-accept" @click="handleAssistAccept">Accept</button>
<button class="btn-reject" @click="assist.reject()">Reject</button>
</div>
</div>
</div>
</aside>
<!-- Delete confirmation -->
<teleport to="body">
@@ -269,14 +295,31 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</template>
<style scoped>
.editor {
max-width: 720px;
margin: 2rem auto;
/* ── Layout ── */
.editor-layout {
max-width: 960px;
margin: 0 auto;
padding: 0 1rem;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.editor-header {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 0 0.5rem;
}
.editor-scroll {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
padding: 0.5rem 0 1rem;
}
/* ── Toolbar & inputs ── */
.toolbar {
display: flex;
gap: 0.5rem;
@@ -334,23 +377,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.textarea-wrapper {
position: relative;
}
.body-input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
font-size: 1rem;
font-family: monospace;
min-height: 200px;
resize: none;
overflow: hidden;
background: var(--color-bg-card);
color: var(--color-text);
box-sizing: border-box;
}
.preview-pane {
padding: 0.75rem;
border: 1px solid var(--color-input-border);
@@ -358,29 +384,182 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
min-height: 200px;
background: var(--color-bg-card);
}
.ac-dropdown {
position: fixed;
z-index: 100;
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
/* ── Assist panel ── */
.assist-panel {
flex: 0 0 33.33%;
min-height: 0;
display: flex;
flex-direction: column;
border-top: 1px solid var(--color-border);
box-shadow: 0 -4px 16px var(--color-shadow);
background: var(--color-bg-card);
box-shadow: 0 4px 12px var(--color-shadow);
max-height: 200px;
border-radius: var(--radius-md) var(--radius-md) 0 0;
overflow: hidden;
}
.assist-panel-header {
flex-shrink: 0;
padding: 0.75rem 1rem 0;
}
.assist-panel-title {
margin: 0;
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.assist-panel-body {
flex: 1;
min-height: 0;
overflow-y: auto;
min-width: 160px;
padding: 0.75rem 1rem 1rem;
display: flex;
flex-direction: column;
}
.ac-item {
padding: 0.4rem 0.75rem;
.assist-controls {
display: flex;
gap: 0.75rem;
flex: 1;
min-height: 0;
}
.assist-sections {
flex: 0 0 40%;
overflow-y: auto;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
}
.assist-section-item {
padding: 0.35rem 0.75rem;
cursor: pointer;
font-size: 0.9rem;
font-size: 0.85rem;
border-left: 3px solid transparent;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ac-item:hover,
.ac-item.active {
.assist-section-item:hover {
background: var(--color-bg-secondary);
}
.assist-section-item.selected {
border-left-color: var(--color-primary);
background: var(--color-bg-secondary);
font-weight: 500;
}
.assist-empty {
padding: 0.75rem;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.assist-input {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.assist-target-preview {
font-size: 0.8rem;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.assist-instruction {
width: 100%;
flex: 1;
min-height: 2.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
font-size: 0.9rem;
font-family: inherit;
resize: none;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.assist-input-actions {
display: flex;
gap: 0.5rem;
}
.btn-generate {
padding: 0.4rem 0.9rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
}
.btn-generate:disabled {
opacity: 0.5;
cursor: default;
}
.btn-clear {
padding: 0.4rem 0.9rem;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.assist-error {
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border: 1px solid var(--color-danger);
border-radius: var(--radius-sm);
font-size: 0.85rem;
color: var(--color-danger);
}
.assist-preview,
.assist-review {
margin-top: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
}
.assist-proposed {
font-size: 0.95rem;
}
.typing-indicator {
display: inline-block;
color: var(--color-text-secondary);
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
.assist-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.btn-accept {
padding: 0.4rem 1rem;
background: var(--color-success);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
}
.btn-reject {
padding: 0.4rem 1rem;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
/* ── Modal ── */
.modal-overlay {
position: fixed;
inset: 0;
@@ -426,4 +605,17 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
color: #fff;
border-color: var(--color-danger);
}
/* ── Mobile ── */
@media (max-width: 768px) {
.assist-panel {
flex-basis: 45%;
}
.assist-controls {
flex-direction: column;
}
.assist-sections {
flex: none;
}
}
</style>
+1 -1
View File
@@ -162,7 +162,7 @@ async function convertToTask() {
<style scoped>
.viewer {
max-width: 720px;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
}
+1 -1
View File
@@ -133,7 +133,7 @@ function onOffsetUpdate(offset: number) {
<style scoped>
.notes-list {
max-width: 720px;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
}
+12 -5
View File
@@ -128,10 +128,17 @@ const MODEL_CATALOG: ModelInfo[] = [
category: "Uncensored / Creative Writing",
},
{
name: "mythomist",
description: "A 7B model specifically tuned for creative and narrative writing including mature themes.",
size: "4.1 GB",
bestFor: "Creative fiction, narrative writing",
name: "nollama/mythomax-l2-13b",
description: "MythoMax L2 13B — a merge of multiple fine-tunes for creative and narrative writing with strong coherence.",
size: "7.4 GB",
bestFor: "Creative fiction, roleplay, narrative writing",
category: "Uncensored / Creative Writing",
},
{
name: "mattw/mythalion",
description: "Mythalion 13B — a Gryphe merge combining Mythologic and Pygmalion for expressive creative output.",
size: "7.4 GB",
bestFor: "Creative writing, character dialogue",
category: "Uncensored / Creative Writing",
},
{
@@ -453,7 +460,7 @@ async function handleRestoreFile(event: Event) {
<style scoped>
.settings-page {
max-width: 720px;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
}
+381 -189
View File
@@ -1,13 +1,15 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, nextTick } from "vue";
import { ref, onMounted, onUnmounted, computed } from "vue";
import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
import { useAutocomplete } from "@/composables/useAutocomplete";
import { useAssist } from "@/composables/useAssist";
import type { TaskStatus, TaskPriority } from "@/types/task";
import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import TiptapEditor from "@/components/TiptapEditor.vue";
const route = useRoute();
const router = useRouter();
@@ -23,7 +25,10 @@ const dueDate = ref("");
const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
const tiptapEditor = computed<Editor | null>(() => {
return (editorRef.value?.editor as Editor | undefined) ?? null;
});
const taskId = computed(() =>
route.params.id ? Number(route.params.id) : null
@@ -32,18 +37,29 @@ const isEditing = computed(() => taskId.value !== null);
const renderedPreview = computed(() => renderMarkdown(body.value));
// Autocomplete
const {
acItems,
acVisible,
acIndex,
acTop,
acLeft,
detectTrigger,
accept: acAccept,
dismiss: acDismiss,
onKeydown: acOnKeydown,
} = useAutocomplete(textareaRef, body, (q) => notesStore.fetchAllTags(q));
// AI Assist
const assist = useAssist(body);
const renderedStreaming = computed(() => renderMarkdown(assist.streamingText.value));
const renderedProposal = computed(() => renderMarkdown(assist.proposedText.value));
function onSelectionChange(payload: { text: string; start: number; end: number }) {
assist.selectTextRange(payload.start, payload.end);
}
function handleAssistAccept() {
const newBody = assist.accept();
if (newBody !== body.value) {
body.value = newBody;
markDirty();
toast.show("Section updated");
}
}
function truncateTarget(text: string, max = 60): string {
const first = text.split("\n")[0];
return first.length > max ? first.slice(0, max) + "..." : first;
}
let savedTitle = "";
let savedBody = "";
@@ -60,48 +76,9 @@ function markDirty() {
dueDate.value !== savedDueDate;
}
function autoGrow() {
const el = textareaRef.value;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.max(el.scrollHeight, 200) + "px";
}
function onBodyInput() {
function onBodyUpdate(newVal: string) {
body.value = newVal;
markDirty();
autoGrow();
detectTrigger();
}
function onTextareaKeydown(e: KeyboardEvent) {
acOnKeydown(e);
}
function onTextareaBlur() {
setTimeout(acDismiss, 150);
}
function insertAtCursor(before: string, after: string, placeholder: string) {
const el = textareaRef.value;
if (!el) return;
showPreview.value = false;
nextTick(() => {
el.focus();
const start = el.selectionStart;
const end = el.selectionEnd;
const selected = body.value.slice(start, end);
const insert = selected || placeholder;
body.value =
body.value.slice(0, start) + before + insert + after + body.value.slice(end);
markDirty();
nextTick(() => {
const newStart = start + before.length;
const newEnd = newStart + insert.length;
el.setSelectionRange(newStart, newEnd);
el.focus();
autoGrow();
});
});
}
onMounted(async () => {
@@ -120,7 +97,6 @@ onMounted(async () => {
savedDueDate = dueDate.value;
}
}
nextTick(autoGrow);
});
async function save() {
@@ -203,104 +179,154 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</script>
<template>
<main class="editor">
<div class="toolbar">
<router-link to="/tasks" class="btn-back">Back</router-link>
<button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}
</button>
<button v-if="isEditing" class="btn-delete" @click="remove">
Delete
</button>
</div>
<input
v-model="title"
type="text"
placeholder="Task title"
class="title-input"
@input="markDirty"
/>
<main class="editor-layout">
<div class="editor-header">
<div class="toolbar">
<router-link to="/tasks" class="btn-back">Back</router-link>
<button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}
</button>
<button v-if="isEditing" class="btn-delete" @click="remove">
Delete
</button>
</div>
<input
v-model="title"
type="text"
placeholder="Task title"
class="title-input"
@input="markDirty"
/>
<div class="editor-tabs">
<button
:class="['tab', { active: !showPreview }]"
@click="showPreview = false"
>
Write
</button>
<button
:class="['tab', { active: showPreview }]"
@click="showPreview = true"
>
Preview
</button>
</div>
<div class="field-row">
<div class="field">
<label>Status</label>
<select v-model="status" @change="markDirty" class="field-select">
<option value="todo">Todo</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
</select>
</div>
<div class="field">
<label>Priority</label>
<select v-model="priority" @change="markDirty" class="field-select">
<option value="none">None</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div class="field">
<label>Due Date</label>
<input
v-model="dueDate"
type="date"
class="field-input"
@input="markDirty"
/>
</div>
</div>
<MarkdownToolbar v-show="!showPreview" @insert="insertAtCursor" />
<div class="textarea-wrapper" v-show="!showPreview">
<textarea
ref="textareaRef"
v-model="body"
placeholder="Describe this task... Use #tags inline"
class="body-input"
@input="onBodyInput"
@keydown="onTextareaKeydown"
@blur="onTextareaBlur"
></textarea>
<!-- Autocomplete dropdown -->
<ul
v-if="acVisible"
class="ac-dropdown"
:style="{ top: acTop + 'px', left: acLeft + 'px' }"
>
<li
v-for="(item, i) in acItems"
:key="item.value"
:class="['ac-item', { active: i === acIndex }]"
@mousedown.prevent="acAccept(i)"
<div class="editor-tabs">
<button
:class="['tab', { active: !showPreview }]"
@click="showPreview = false"
>
{{ item.label }}
</li>
</ul>
Write
</button>
<button
:class="['tab', { active: showPreview }]"
@click="showPreview = true"
>
Preview
</button>
</div>
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
</div>
<div
v-show="showPreview"
class="preview-pane prose"
v-html="renderedPreview"
></div>
<div class="field-row">
<div class="field">
<label>Status</label>
<select v-model="status" @change="markDirty" class="field-select">
<option value="todo">Todo</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
</select>
</div>
<div class="field">
<label>Priority</label>
<select v-model="priority" @change="markDirty" class="field-select">
<option value="none">None</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div class="field">
<label>Due Date</label>
<input
v-model="dueDate"
type="date"
class="field-input"
@input="markDirty"
<div class="editor-scroll">
<div v-show="!showPreview">
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Describe this task... Use #tags inline"
:fetchTags="(q: string) => notesStore.fetchAllTags(q)"
@update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange"
/>
</div>
<div
v-show="showPreview"
class="preview-pane prose"
v-html="renderedPreview"
></div>
</div>
<!-- AI Assist -->
<aside class="assist-panel">
<div class="assist-panel-header">
<h3 class="assist-panel-title">AI Assist</h3>
</div>
<div class="assist-panel-body">
<div v-if="assist.state.value === 'idle'" class="assist-controls">
<div class="assist-sections">
<div
v-for="(section, i) in assist.sections.value"
:key="i"
:class="['assist-section-item', { selected: assist.selectedSection.value === section }]"
@click="assist.selectSection(section)"
>
{{ section.heading || '(preamble)' }}
</div>
<div v-if="assist.sections.value.length === 0" class="assist-empty">
No sections found. Add headings (## ...) or select text.
</div>
</div>
<div class="assist-input" v-if="assist.target.value">
<div class="assist-target-preview">
Editing: {{ truncateTarget(assist.target.value.text) }}
</div>
<textarea
v-model="assist.instruction.value"
placeholder="What should I do with this section?"
class="assist-instruction"
rows="2"
></textarea>
<div class="assist-input-actions">
<button
class="btn-generate"
@click="assist.submit()"
:disabled="!assist.canSubmit.value"
>
Generate
</button>
<button class="btn-clear" @click="assist.clearSelection()">Clear</button>
</div>
</div>
</div>
<div v-if="assist.error.value" class="assist-error">
{{ assist.error.value }}
</div>
<div v-if="assist.state.value === 'streaming'" class="assist-preview">
<div class="assist-proposed prose" v-html="renderedStreaming"></div>
<span class="typing-indicator">...</span>
</div>
<div v-if="assist.state.value === 'review'" class="assist-review">
<div class="assist-proposed prose" v-html="renderedProposal"></div>
<div class="assist-actions">
<button class="btn-accept" @click="handleAssistAccept">Accept</button>
<button class="btn-reject" @click="assist.reject()">Reject</button>
</div>
</div>
</div>
</aside>
<!-- Delete confirmation -->
<teleport to="body">
<div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false">
@@ -318,14 +344,31 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</template>
<style scoped>
.editor {
max-width: 720px;
margin: 2rem auto;
/* ── Layout ── */
.editor-layout {
max-width: 960px;
margin: 0 auto;
padding: 0 1rem;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.editor-header {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 0 0.5rem;
}
.editor-scroll {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
padding: 0.5rem 0 1rem;
}
/* ── Toolbar & inputs ── */
.toolbar {
display: flex;
gap: 0.5rem;
@@ -383,23 +426,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.textarea-wrapper {
position: relative;
}
.body-input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
font-size: 1rem;
font-family: monospace;
min-height: 200px;
resize: none;
overflow: hidden;
background: var(--color-bg-card);
color: var(--color-text);
box-sizing: border-box;
}
.preview-pane {
padding: 0.75rem;
border: 1px solid var(--color-input-border);
@@ -407,29 +433,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
min-height: 200px;
background: var(--color-bg-card);
}
.ac-dropdown {
position: fixed;
z-index: 100;
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
box-shadow: 0 4px 12px var(--color-shadow);
max-height: 200px;
overflow-y: auto;
min-width: 160px;
}
.ac-item {
padding: 0.4rem 0.75rem;
cursor: pointer;
font-size: 0.9rem;
}
.ac-item:hover,
.ac-item.active {
background: var(--color-bg-secondary);
}
.field-row {
display: flex;
gap: 1rem;
@@ -456,6 +459,182 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
color: var(--color-text);
font-size: 0.9rem;
}
/* ── Assist panel ── */
.assist-panel {
flex: 0 0 33.33%;
min-height: 0;
display: flex;
flex-direction: column;
border-top: 1px solid var(--color-border);
box-shadow: 0 -4px 16px var(--color-shadow);
background: var(--color-bg-card);
border-radius: var(--radius-md) var(--radius-md) 0 0;
overflow: hidden;
}
.assist-panel-header {
flex-shrink: 0;
padding: 0.75rem 1rem 0;
}
.assist-panel-title {
margin: 0;
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.assist-panel-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0.75rem 1rem 1rem;
display: flex;
flex-direction: column;
}
.assist-controls {
display: flex;
gap: 0.75rem;
flex: 1;
min-height: 0;
}
.assist-sections {
flex: 0 0 40%;
overflow-y: auto;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
}
.assist-section-item {
padding: 0.35rem 0.75rem;
cursor: pointer;
font-size: 0.85rem;
border-left: 3px solid transparent;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.assist-section-item:hover {
background: var(--color-bg-secondary);
}
.assist-section-item.selected {
border-left-color: var(--color-primary);
background: var(--color-bg-secondary);
font-weight: 500;
}
.assist-empty {
padding: 0.75rem;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.assist-input {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.assist-target-preview {
font-size: 0.8rem;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.assist-instruction {
width: 100%;
flex: 1;
min-height: 2.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
font-size: 0.9rem;
font-family: inherit;
resize: none;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.assist-input-actions {
display: flex;
gap: 0.5rem;
}
.btn-generate {
padding: 0.4rem 0.9rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
}
.btn-generate:disabled {
opacity: 0.5;
cursor: default;
}
.btn-clear {
padding: 0.4rem 0.9rem;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.assist-error {
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border: 1px solid var(--color-danger);
border-radius: var(--radius-sm);
font-size: 0.85rem;
color: var(--color-danger);
}
.assist-preview,
.assist-review {
margin-top: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
}
.assist-proposed {
font-size: 0.95rem;
}
.typing-indicator {
display: inline-block;
color: var(--color-text-secondary);
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
.assist-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.btn-accept {
padding: 0.4rem 1rem;
background: var(--color-success);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
}
.btn-reject {
padding: 0.4rem 1rem;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
/* ── Modal ── */
.modal-overlay {
position: fixed;
inset: 0;
@@ -501,4 +680,17 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
color: #fff;
border-color: var(--color-danger);
}
/* ── Mobile ── */
@media (max-width: 768px) {
.assist-panel {
flex-basis: 45%;
}
.assist-controls {
flex-direction: column;
}
.assist-sections {
flex: none;
}
}
</style>
+1 -1
View File
@@ -192,7 +192,7 @@ function onTagClick(tag: string) {
<style scoped>
.viewer {
max-width: 720px;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
}
+1 -1
View File
@@ -170,7 +170,7 @@ function onOffsetUpdate(offset: number) {
<style scoped>
.tasks-list {
max-width: 720px;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
}
+47 -1
View File
@@ -1,8 +1,13 @@
import json
import logging
from datetime import date
from quart import Blueprint, jsonify, request
from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.config import Config
from fabledassistant.services.assist import build_assist_messages
from fabledassistant.services.llm import stream_chat
from fabledassistant.services.notes import (
convert_note_to_task,
convert_task_to_note,
@@ -16,8 +21,11 @@ from fabledassistant.services.notes import (
list_notes,
update_note,
)
from fabledassistant.services.settings import get_setting
from fabledassistant.utils.tags import extract_tags
logger = logging.getLogger(__name__)
notes_bp = Blueprint("notes", __name__, url_prefix="/api/notes")
@@ -178,3 +186,41 @@ async def get_backlinks_route(note_id: int):
uid = get_current_user_id()
links = await get_backlinks(uid, note_id)
return jsonify({"backlinks": links})
@notes_bp.route("/assist", methods=["POST"])
@login_required
async def assist_route():
"""Stream an AI-assisted section edit via SSE."""
uid = get_current_user_id()
data = await request.get_json()
body = data.get("body", "")
target_section = data.get("target_section", "")
instruction = data.get("instruction", "")
if not target_section or not instruction:
return jsonify({"error": "target_section and instruction are required"}), 400
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL)
messages = build_assist_messages(body, target_section, instruction)
async def generate():
full_text = ""
try:
async for chunk in stream_chat(messages, model):
full_text += chunk
yield f"data: {json.dumps({'chunk': chunk})}\n\n"
yield f"data: {json.dumps({'done': True, 'full_text': full_text})}\n\n"
except Exception as e:
logger.exception("Assist generation failed")
yield f"data: {json.dumps({'error': str(e)})}\n\n"
return Response(
generate(),
content_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
+35
View File
@@ -0,0 +1,35 @@
MAX_BODY_CHARS = 3000
def build_assist_messages(
body: str, target_section: str, instruction: str
) -> list[dict]:
"""Build Ollama messages for section-level assist.
The full note body (truncated) is provided as read-only context in the
system prompt. The target section + user instruction go in the user
message. The model outputs only the replacement for the target section.
"""
truncated_body = body[:MAX_BODY_CHARS]
if len(body) > MAX_BODY_CHARS:
truncated_body += "\n... (truncated)"
system_content = (
"You are an AI writing assistant integrated into a note-taking app. "
"The user is editing a document. The full document is shown below for context.\n\n"
f"--- Full Document ---\n{truncated_body}\n--- End Document ---\n\n"
"The user will give you a specific section of the document and an instruction. "
"Output ONLY the replacement text for that section. "
"Do not include other sections, explanatory text, or markdown code fences around the output. "
"Match the document's existing tone and style."
)
user_content = (
f"--- Target Section ---\n{target_section}\n--- End Target Section ---\n\n"
f"Instruction: {instruction}"
)
return [
{"role": "system", "content": system_content},
{"role": "user", "content": user_content},
]
+5 -1
View File
@@ -6,8 +6,10 @@ from sqlalchemy.orm import selectinload
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation, Message
from fabledassistant.config import Config
from fabledassistant.services.llm import generate_completion
from fabledassistant.services.notes import create_note
from fabledassistant.services.settings import get_setting
logger = logging.getLogger(__name__)
@@ -166,7 +168,9 @@ async def save_response_as_note(user_id: int, message_id: int) -> dict:
# Generate title via LLM using the assistant message content
title = ""
if conv:
model = conv.model or "llama3.2"
model = conv.model or await get_setting(
user_id, "default_model", Config.OLLAMA_MODEL
)
try:
prompt_messages = [
{
+38 -16
View File
@@ -12,7 +12,7 @@
> Include file-level details in the commit body when the change is non-trivial.
## Last Updated
2026-02-11 — Phase 5.1: Chat UX Improvements
2026-02-11 — Phase 5.2: Tiptap Inline-Preview Editor + Layout Improvements
## Project Overview
Fabled Assistant is a self-hosted note-taking and task-tracking application with
@@ -233,7 +233,7 @@ fabledassistant/
├── vite.config.ts
├── tsconfig.json
├── src/
│ ├── App.vue # Shell: AppHeader + router-view + ChatPanel (slide-out) + ToastNotification; starts status polling + loads settings on mount
│ ├── App.vue # Shell: .app-shell (100dvh flex column) with AppHeader + .app-content (flex:1, scrollable) wrapping router-view; starts status polling + loads settings on mount
│ ├── main.ts # App init, imports theme.css
│ ├── assets/
│ │ └── theme.css # CSS custom properties: light/dark themes, body reset, design tokens, responsive breakpoints (480/768/1024), utility classes (.hide-mobile/.hide-desktop), mobile touch targets
@@ -241,7 +241,8 @@ fabledassistant/
│ │ └── client.ts # ApiError class, apiGet/apiPost/apiPut/apiPatch/apiDelete + apiSSEStream (EventSource-based), auto 401→login redirect
│ ├── composables/
│ │ ├── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme
│ │ ── useAutocomplete.ts # #tag + [[wikilink]] autocomplete: Tab cycling, debounced search
│ │ ── useAutocomplete.ts # Legacy textarea autocomplete (replaced by Tiptap suggestion extensions)
│ │ └── useAssist.ts # AI Assist composable: section parsing, target selection, LLM streaming, accept/reject; watches body ref for auto-sync
│ ├── stores/
│ │ ├── auth.ts # Auth state: user, isAuthenticated, isAdmin, login/register/logout/checkAuth
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags (with toast errors)
@@ -255,9 +256,16 @@ fabledassistant/
│ │ ├── chat.ts # Message, Conversation, ConversationDetail, ContextMeta, OllamaModel, OllamaStatus interfaces
│ │ ├── settings.ts # AppSettings interface, ModelInfo interface (name, description, size, bestFor, category)
│ │ └── task.ts # Task = re-export of Note; TaskListResponse
│ ├── extensions/
│ │ ├── TagDecoration.ts # ProseMirror decoration plugin: highlights #tags with .inline-tag class
│ │ ├── WikilinkDecoration.ts # ProseMirror decoration plugin: highlights [[wikilinks]] with .wikilink class
│ │ ├── TagSuggestion.ts # @tiptap/suggestion extension for #tag autocomplete (suppressed at line start for headings)
│ │ ├── WikilinkSuggestion.ts # @tiptap/suggestion extension for [[wikilink]] autocomplete
│ │ └── suggestionRenderer.ts # Shared Vue renderer for suggestion dropdowns (creates/positions SuggestionDropdown)
│ ├── utils/
│ │ ├── tags.ts # extractTags(), linkifyTags() (with (?<!&) to skip HTML entities), linkifyWikilinks()
│ │ ── markdown.ts # renderMarkdown() (full) + renderPreview() (strips links/images) — decodes HTML entities before marked, replaces &#39; after sanitization
│ │ ── markdown.ts # renderMarkdown() (full) + renderPreview() (strips links/images) — decodes HTML entities before marked, replaces &#39; after sanitization
│ │ └── markdownSerializer.ts # Tiptap JSON → markdown serializer: handles all StarterKit nodes + marks
│ ├── views/
│ │ ├── LoginView.vue # Login form with error display, link to register
│ │ ├── RegisterView.vue # Register form (username, password, email), first-user setup
@@ -265,10 +273,10 @@ fabledassistant/
│ │ ├── HomeView.vue # Landing page: recent notes + tasks + recent chats
│ │ ├── SettingsView.vue # Settings page: assistant name, model catalog, data export/restore (admin)
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
│ │ ├── NoteEditorView.vue # Create/edit: Ctrl+S, unsaved guard, toasts, autocomplete
│ │ ├── NoteEditorView.vue # Create/edit: Tiptap editor, sticky toolbar, AI Assist panel (bottom 1/3), Ctrl+S, unsaved guard
│ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, convert-to-task, backlinks
│ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination
│ │ ├── TaskEditorView.vue # Create/edit task: Ctrl+S, dirty guard, autocomplete
│ │ ├── TaskEditorView.vue # Create/edit task: Tiptap editor, sticky toolbar, AI Assist panel, Ctrl+S, dirty guard
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note, backlinks
│ ├── components/
│ │ ├── AppHeader.vue # Nav bar: brand, nav links, status indicator, theme toggle, user info + logout, hamburger menu (mobile)
@@ -278,7 +286,9 @@ fabledassistant/
│ │ ├── TaskCard.vue # Card with rendered preview (body not description), StatusBadge (clickable), PriorityBadge, due date, tags
│ │ ├── StatusBadge.vue # Color-coded status badge, optional clickable cycling
│ │ ├── PriorityBadge.vue # Color-coded priority indicator (hidden for "none")
│ │ ├── MarkdownToolbar.vue # Bold/italic/link/list/heading toolbar for editor
│ │ ├── MarkdownToolbar.vue # Tiptap command-based toolbar: bold/italic/link/list/heading with active state highlighting
│ │ ├── TiptapEditor.vue # Tiptap wrapper: markdown↔HTML round-trip, paste handling, selection change emit, expose editor
│ │ ├── SuggestionDropdown.vue # Shared autocomplete dropdown for tag/wikilink suggestions
│ │ ├── SearchBar.vue # Debounced search input
│ │ ├── TagPill.vue # Clickable/dismissible tag pill
│ │ ├── PaginationBar.vue # Prev/next + page numbers
@@ -558,6 +568,21 @@ When adding a new migration, follow these conventions:
- [x] **Save-as-note LLM titles:** `save_response_as_note` generates title via LLM (same pattern as chat titles); falls back to first-line extraction on failure
- [x] **Chat tag on saved notes:** Both `save_response_as_note` and `summarize_conversation_as_note` tag created notes with `chat`
### Phase 5.2 — Tiptap Inline-Preview Editor + Layout Improvements ✓
- [x] **Tiptap WYSIWYG editor:** Replaced plain textarea with Tiptap (ProseMirror-based) inline-preview editor — headings, bold, italic, lists, code blocks render inline while editing
- [x] **Markdown round-trip:** Load markdown → HTML (via `marked`) → Tiptap editing → markdown (via custom `serializeToMarkdown()`) on every change
- [x] **Tag/wikilink decorations:** ProseMirror decoration plugins visually highlight `#tag` and `[[wikilink]]` as colored badges without custom node types
- [x] **Autocomplete migration:** Moved from textarea-based `useAutocomplete` to `@tiptap/suggestion` framework with shared `SuggestionDropdown` component
- [x] **Heading vs tag disambiguation:** Tag suggestion suppressed at start of paragraphs (where `#` is likely a heading prefix)
- [x] **Markdown paste handling:** Pasted markdown text auto-detected and converted to formatted content
- [x] **Toolbar refactor:** `MarkdownToolbar` uses Tiptap commands with active state highlighting instead of textarea text insertion
- [x] **Sticky toolbar:** Toolbar, title, tabs pinned above scrolling editor content
- [x] **App shell layout:** Navbar always visible — `App.vue` uses flex column at 100dvh with header + content areas; all views fit below header
- [x] **AI Assist panel:** Fixed at bottom 1/3 of viewport; section selector and prompt fill the panel; controls hidden during streaming/review (only output + accept/reject shown)
- [x] **Auto-syncing sections:** `useAssist` watches `body` ref to keep section list in sync on load, typing, and AI accept
- [x] **Accept trailing newline:** Accepted AI suggestions always end with a newline for clean separation
- [x] **Wider content area:** Max-width increased from 720px to 960px across all views
### Future / Stretch
- Tagging/labeling system with LLM-suggested tags
- Calendar/timeline view for tasks
@@ -573,24 +598,21 @@ When adding a new migration, follow these conventions:
- To reset database: `docker compose down -v && docker compose up --build`
## Current Status
**Phase:** Phase 5.1 complete. Chat UX Improvements.
**Phase:** Phase 5.2 complete. Tiptap Inline-Preview Editor + Layout Improvements.
- Full note-taking and task-tracking CRUD with markdown, wikilinks, backlinks, tags
- **Tiptap WYSIWYG editor** with inline formatting preview, markdown round-trip, paste handling
- **Tag/wikilink autocomplete** via `@tiptap/suggestion` with heading disambiguation
- Unified note/task model (a task is a note with `status IS NOT NULL`)
- **Multi-user authentication** with session cookies, bcrypt passwords, admin role
- **Per-user data isolation** across notes, conversations, and settings
- **First-user-is-admin** pattern; orphaned pre-auth data claimed by first user
- LLM chat via Ollama with background generation task + SSE streaming
- **LLM-generated titles** on first exchange and every 10th message
- **Stop generation** button with partial content preservation
- **Relative timestamps** in sidebar (5m ago, 3h ago, then dates)
- **Empty chat cleanup** on navigation away
- **AI Assist panel** pinned to bottom 1/3 of editor viewport with auto-syncing sections
- Note-aware context: auto-includes current note + searches related notes by keyword
- Context pills with promote/exclude controls; note picker in chat input
- Save assistant messages as notes (LLM-titled, chat-tagged); summarize conversations as notes
- Dedicated `/chat` page with responsive sidebar (overlay on mobile)
- Settings page: assistant name, model catalog, **data export/restore (admin)**
- **Request logging** with timing, sanitized 500 errors, configurable LOG_LEVEL
- Settings page: assistant name, model catalog, data export/restore (admin)
- **App-wide layout fix**: navbar always visible, all views fit within viewport
- **Responsive design**: hamburger menu, mobile touch targets, responsive breakpoints
- **Toast notifications** with success/error/warning types and dismiss button
- **Docker Swarm production stack** with secrets, network isolation, health checks, resource limits
- Dark/light theme with CSS custom properties and design tokens