Project-aware assist, link suggestions, project-scoped RAG, semantic search tool, SSE race fix
- Writing assistant: inject project notes as context (definition-tagged first), wikilink suggestions - Link suggestions: server-side endpoint finds unlinked term occurrences, NoteEditorView sidebar panel - Project-scoped RAG: ChatView ProjectSelector filters semantic+keyword search to selected project - Semantic search tool: LLM search_notes upgraded to hybrid semantic (0.40 threshold) + keyword merge - SSE race condition fix: drain remaining events after stream loop exits in chat.py and notes.py - RAG_AUTO_SNIPPET raised 800→4000; sidebar include uses full note body; MAX_BODY_CHARS 8000→24000 - Enter-to-submit on writing assistant instruction textareas (note and task editors) - DiffView: equal-line collapsing with 3-line context around changes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,9 +6,52 @@ const props = defineProps<{
|
||||
diff: DiffLine[];
|
||||
}>();
|
||||
|
||||
const CONTEXT_LINES = 3;
|
||||
const MIN_COLLAPSE = 6; // Only collapse if there are more than this many consecutive equal lines
|
||||
|
||||
type CollapseMarker = { type: 'collapse'; count: number };
|
||||
type DisplayItem = DiffLine | CollapseMarker;
|
||||
|
||||
const insertions = computed(() => props.diff.filter(l => l.type === 'insert').length);
|
||||
const deletions = computed(() => props.diff.filter(l => l.type === 'delete').length);
|
||||
|
||||
const displayItems = computed<DisplayItem[]>(() => {
|
||||
const d = props.diff;
|
||||
if (!d.length) return [];
|
||||
|
||||
// Mark which indices are within CONTEXT_LINES of a change
|
||||
const shown = new Set<number>();
|
||||
for (let i = 0; i < d.length; i++) {
|
||||
if (d[i].type !== 'equal') {
|
||||
for (let c = Math.max(0, i - CONTEXT_LINES); c <= Math.min(d.length - 1, i + CONTEXT_LINES); c++) {
|
||||
shown.add(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing changed, show all
|
||||
if (shown.size === 0) return d;
|
||||
|
||||
const result: DisplayItem[] = [];
|
||||
let i = 0;
|
||||
while (i < d.length) {
|
||||
if (shown.has(i)) {
|
||||
result.push(d[i]);
|
||||
i++;
|
||||
} else {
|
||||
let count = 0;
|
||||
while (i < d.length && !shown.has(i)) { count++; i++; }
|
||||
if (count >= MIN_COLLAPSE) {
|
||||
result.push({ type: 'collapse', count });
|
||||
} else {
|
||||
// Too short to collapse — show as-is
|
||||
for (let k = i - count; k < i; k++) result.push(d[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
function markerFor(type: DiffLine['type']): string {
|
||||
if (type === 'insert') return '+';
|
||||
if (type === 'delete') return '−';
|
||||
@@ -24,14 +67,19 @@ function markerFor(type: DiffLine['type']): string {
|
||||
</div>
|
||||
<div class="diff-scroll">
|
||||
<div v-if="!diff.length" class="diff-empty">No changes.</div>
|
||||
<div
|
||||
v-for="(line, i) in diff"
|
||||
:key="i"
|
||||
:class="['diff-line', `diff-${line.type}`]"
|
||||
>
|
||||
<span class="diff-marker">{{ markerFor(line.type) }}</span>
|
||||
<span class="diff-text">{{ line.text }}</span>
|
||||
</div>
|
||||
<template v-for="(item, i) in displayItems" :key="i">
|
||||
<div v-if="item.type === 'collapse'" class="diff-line diff-collapse">
|
||||
<span class="diff-marker">⋯</span>
|
||||
<span class="diff-text">{{ item.count }} unchanged line{{ item.count !== 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
:class="['diff-line', `diff-${item.type}`]"
|
||||
>
|
||||
<span class="diff-marker">{{ markerFor(item.type) }}</span>
|
||||
<span class="diff-text">{{ item.text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -102,6 +150,16 @@ function markerFor(type: DiffLine['type']): string {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.diff-collapse {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
border-top: 1px dashed var(--color-border);
|
||||
border-bottom: 1px dashed var(--color-border);
|
||||
padding-top: 0.2rem;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.diff-marker {
|
||||
flex-shrink: 0;
|
||||
width: 1rem;
|
||||
|
||||
@@ -8,6 +8,7 @@ interface NoteVersion {
|
||||
id: number;
|
||||
note_id: number;
|
||||
title: string;
|
||||
tags: string[];
|
||||
body?: string;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -18,7 +19,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'restore', body: string): void;
|
||||
(e: 'restore', body: string, tags: string[]): void;
|
||||
(e: 'close'): void;
|
||||
}>();
|
||||
|
||||
@@ -101,7 +102,7 @@ async function selectVersionItem(v: NoteVersion) {
|
||||
|
||||
function restore() {
|
||||
if (!selectedVersion.value?.body) return;
|
||||
emit('restore', selectedVersion.value.body);
|
||||
emit('restore', selectedVersion.value.body, selectedVersion.value.tags ?? []);
|
||||
}
|
||||
|
||||
onMounted(loadVersions);
|
||||
|
||||
@@ -53,7 +53,7 @@ function computeDiff(a: string, b: string): DiffLine[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function useAssist(body: Ref<string>, noteId?: Ref<number | null>) {
|
||||
export function useAssist(body: Ref<string>, noteId?: Ref<number | null>, projectId?: Ref<number | null>) {
|
||||
const toast = useToastStore();
|
||||
|
||||
const state = ref<AssistState>("idle");
|
||||
@@ -166,6 +166,17 @@ export function useAssist(body: Ref<string>, noteId?: Ref<number | null>) {
|
||||
}
|
||||
}
|
||||
|
||||
function _buildProposedFullBody(fullText: string, wholeDoc: boolean): string {
|
||||
if (wholeDoc) return fullText;
|
||||
const t = target.value;
|
||||
if (t) {
|
||||
let text = fullText;
|
||||
if (!text.endsWith("\n")) text += "\n";
|
||||
return bodySnapshot.slice(0, t.startOffset) + text + bodySnapshot.slice(t.endOffset);
|
||||
}
|
||||
return fullText;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit.value) return;
|
||||
|
||||
@@ -185,71 +196,64 @@ export function useAssist(body: Ref<string>, noteId?: Ref<number | null>) {
|
||||
target_section: targetSection,
|
||||
instruction: instruction.value,
|
||||
whole_doc: wholeDoc,
|
||||
...(noteId?.value ? { note_id: noteId.value } : {}),
|
||||
...(projectId?.value ? { project_id: projectId.value } : {}),
|
||||
});
|
||||
|
||||
streamHandle = apiSSEStream("/api/notes/assist/stream", async (evt) => {
|
||||
if (evt.event === "chunk") {
|
||||
streamingText.value += evt.data.chunk as string;
|
||||
}
|
||||
if (evt.event === "done") {
|
||||
const fullText = (evt.data.full_text as string) || streamingText.value;
|
||||
proposedText.value = fullText;
|
||||
// Stream with automatic reconnection on dropped connections
|
||||
const MAX_RETRIES = 3;
|
||||
let lastEventId = -1;
|
||||
let gotDone = false;
|
||||
|
||||
if (wholeDoc) {
|
||||
proposedFullBody.value = fullText;
|
||||
} else {
|
||||
// Reconstruct full body with section replaced
|
||||
const t = target.value;
|
||||
if (t) {
|
||||
let text = fullText;
|
||||
if (!text.endsWith("\n")) text += "\n";
|
||||
proposedFullBody.value =
|
||||
bodySnapshot.slice(0, t.startOffset) +
|
||||
text +
|
||||
bodySnapshot.slice(t.endOffset);
|
||||
} else {
|
||||
proposedFullBody.value = fullText;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
if (attempt > 0) {
|
||||
if (state.value !== "streaming") break;
|
||||
await new Promise<void>((r) => setTimeout(r, Math.min(1000 * 2 ** (attempt - 1), 5000)));
|
||||
if (state.value !== "streaming") break;
|
||||
}
|
||||
|
||||
gotDone = false;
|
||||
streamHandle = apiSSEStream(
|
||||
`/api/notes/assist/stream${lastEventId >= 0 ? `?last_event_id=${lastEventId}` : ""}`,
|
||||
(evt) => {
|
||||
lastEventId = evt.id;
|
||||
if (evt.event === "chunk") {
|
||||
streamingText.value += evt.data.chunk as string;
|
||||
}
|
||||
}
|
||||
if (evt.event === "done") {
|
||||
gotDone = true;
|
||||
const fullText = (evt.data.full_text as string) || streamingText.value;
|
||||
proposedText.value = fullText;
|
||||
proposedFullBody.value = _buildProposedFullBody(fullText, wholeDoc);
|
||||
state.value = "review";
|
||||
streamHandle = null;
|
||||
_saveDraft();
|
||||
}
|
||||
if (evt.event === "error") {
|
||||
gotDone = true;
|
||||
error.value = evt.data.error as string;
|
||||
toast.show("Assist failed: " + error.value, "error");
|
||||
state.value = "idle";
|
||||
streamHandle = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
state.value = "review";
|
||||
streamHandle = null;
|
||||
await _saveDraft();
|
||||
}
|
||||
if (evt.event === "error") {
|
||||
error.value = evt.data.error as string;
|
||||
toast.show("Assist failed: " + error.value, "error");
|
||||
state.value = "idle";
|
||||
streamHandle = null;
|
||||
}
|
||||
});
|
||||
|
||||
await streamHandle.done;
|
||||
await streamHandle.done;
|
||||
streamHandle = null;
|
||||
if (gotDone) break;
|
||||
}
|
||||
|
||||
// If still streaming after all retries, treat accumulated text as result
|
||||
if (state.value === "streaming") {
|
||||
if (streamingText.value) {
|
||||
proposedText.value = streamingText.value;
|
||||
if (wholeDoc) {
|
||||
proposedFullBody.value = streamingText.value;
|
||||
} else {
|
||||
const t = target.value;
|
||||
if (t) {
|
||||
let text = streamingText.value;
|
||||
if (!text.endsWith("\n")) text += "\n";
|
||||
proposedFullBody.value =
|
||||
bodySnapshot.slice(0, t.startOffset) +
|
||||
text +
|
||||
bodySnapshot.slice(t.endOffset);
|
||||
} else {
|
||||
proposedFullBody.value = streamingText.value;
|
||||
}
|
||||
}
|
||||
proposedFullBody.value = _buildProposedFullBody(streamingText.value, wholeDoc);
|
||||
state.value = "review";
|
||||
await _saveDraft();
|
||||
} else {
|
||||
state.value = "idle";
|
||||
}
|
||||
streamHandle = null;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Request failed";
|
||||
|
||||
@@ -155,6 +155,7 @@ export const useChatStore = defineStore("chat", () => {
|
||||
think = false,
|
||||
contextNoteTitle?: string,
|
||||
excludeNoteIds?: number[],
|
||||
ragProjectId?: number | null,
|
||||
) {
|
||||
if (!currentConversation.value) return;
|
||||
|
||||
@@ -204,6 +205,7 @@ export const useChatStore = defineStore("chat", () => {
|
||||
include_note_ids: includeNoteIds?.length ? includeNoteIds : undefined,
|
||||
excluded_note_ids: excludeNoteIds?.length ? excludeNoteIds : undefined,
|
||||
think,
|
||||
rag_project_id: ragProjectId ?? undefined,
|
||||
},
|
||||
);
|
||||
assistantMessageId = resp.assistant_message_id;
|
||||
@@ -348,6 +350,37 @@ export const useChatStore = defineStore("chat", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function reconnectIfGenerating(convId: number): Promise<void> {
|
||||
// Skip if a stream is already active for this conversation
|
||||
if (isStreamingConv(convId)) return;
|
||||
|
||||
const conv = currentConversation.value;
|
||||
if (!conv || conv.id !== convId) return;
|
||||
|
||||
// Find the last assistant message still in generating state
|
||||
const lastMsg = [...conv.messages].reverse().find(
|
||||
(m) => m.role === "assistant" && m.status === "generating"
|
||||
);
|
||||
if (!lastMsg) return;
|
||||
|
||||
// Remove the partial message — the done event will re-add the final version
|
||||
conv.messages = conv.messages.filter((m) => m.id !== lastMsg.id);
|
||||
|
||||
const s = _getOrInitStream(convId);
|
||||
s.streaming = true;
|
||||
s.content = "";
|
||||
s.thinking = "";
|
||||
s.toolCalls = [];
|
||||
s.status = "Reconnecting...";
|
||||
s.pendingTool = null;
|
||||
s.contextMeta = null;
|
||||
|
||||
// Reconnect from the beginning — the buffer replays all buffered events.
|
||||
// If the buffer is gone (>60s stale), _streamGeneration will exhaust retries
|
||||
// then fall back to fetchConversation to show the final saved content.
|
||||
await _streamGeneration(convId, lastMsg.id);
|
||||
}
|
||||
|
||||
async function confirmTool(confirmed: boolean) {
|
||||
const convId = currentConversation.value?.id;
|
||||
if (!convId) return;
|
||||
@@ -461,6 +494,7 @@ export const useChatStore = defineStore("chat", () => {
|
||||
deleteConversation,
|
||||
updateTitle,
|
||||
sendMessage,
|
||||
reconnectIfGenerating,
|
||||
confirmTool,
|
||||
cancelGeneration,
|
||||
saveMessageAsNote,
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface NoteVersion {
|
||||
id: number;
|
||||
note_id: number;
|
||||
title: string;
|
||||
tags: string[];
|
||||
body?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import ChatMessage from "@/components/ChatMessage.vue";
|
||||
import ToolCallCard from "@/components/ToolCallCard.vue";
|
||||
import ToolConfirmCard from "@/components/ToolConfirmCard.vue";
|
||||
import type { Note } from "@/types/note";
|
||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -46,6 +47,9 @@ const autoInjectedNotes = ref<{ id: number; title: string; score?: number | null
|
||||
// Note IDs excluded from auto-injection on next message
|
||||
const excludedNoteIds = ref<number[]>([]);
|
||||
|
||||
// Project scope for RAG — when set, semantic & keyword search is restricted to this project
|
||||
const ragProjectId = ref<number | null>(null);
|
||||
|
||||
let prevConvId: number | null = null;
|
||||
|
||||
const convId = computed(() => {
|
||||
@@ -89,6 +93,8 @@ onMounted(async () => {
|
||||
if (store.currentConversation?.id !== convId.value) {
|
||||
await store.fetchConversation(convId.value);
|
||||
}
|
||||
// Reconnect if the last assistant message is still generating (e.g. after page refresh)
|
||||
store.reconnectIfGenerating(convId.value);
|
||||
} else {
|
||||
await startNewConversation();
|
||||
}
|
||||
@@ -116,6 +122,8 @@ watch(convId, async (newId) => {
|
||||
if (store.currentConversation?.id !== newId && !store.isStreamingConv(newId)) {
|
||||
await store.fetchConversation(newId);
|
||||
}
|
||||
// Reconnect if a prior stream died while the backend was still generating
|
||||
store.reconnectIfGenerating(newId);
|
||||
scrollToBottom();
|
||||
} else {
|
||||
await startNewConversation();
|
||||
@@ -220,6 +228,7 @@ async function sendMessage() {
|
||||
true, // enable thinking in the full chat view
|
||||
undefined,
|
||||
excludedNoteIds.value.length ? excludedNoteIds.value : undefined,
|
||||
ragProjectId.value,
|
||||
);
|
||||
sending.value = false;
|
||||
|
||||
@@ -404,6 +413,14 @@ onUnmounted(() => {
|
||||
<button class="btn-new-conv" @click="newConversation">
|
||||
+ New Chat
|
||||
</button>
|
||||
|
||||
<!-- RAG project scope -->
|
||||
<div class="rag-scope-section">
|
||||
<label class="rag-scope-label">Scope notes to project</label>
|
||||
<ProjectSelector v-model="ragProjectId" />
|
||||
<p v-if="ragProjectId" class="rag-scope-hint">RAG search restricted to this project</p>
|
||||
</div>
|
||||
|
||||
<div class="conv-list">
|
||||
<div
|
||||
v-for="conv in store.conversations"
|
||||
@@ -713,6 +730,28 @@ onUnmounted(() => {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.rag-scope-section {
|
||||
padding: 0.5rem 0.75rem 0.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.rag-scope-label {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.rag-scope-hint {
|
||||
margin: 0.3rem 0 0;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-primary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.conv-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, nextTick } from "vue";
|
||||
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
|
||||
import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router";
|
||||
import { useNotesStore } from "@/stores/notes";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
@@ -43,7 +43,7 @@ const isEditing = computed(() => noteId.value !== null);
|
||||
const renderedPreview = computed(() => renderMarkdown(body.value));
|
||||
|
||||
// AI Assist — pass noteId for draft persistence
|
||||
const assist = useAssist(body, noteId);
|
||||
const assist = useAssist(body, noteId, projectId);
|
||||
|
||||
// Scope selector options: "Whole document" + one per section
|
||||
const scopeOptions = computed(() => {
|
||||
@@ -155,6 +155,74 @@ function dismissTagSuggestions() {
|
||||
appliedTags.value = new Set();
|
||||
}
|
||||
|
||||
// ── Link Suggestions ────────────────────────────────────────────────────────
|
||||
|
||||
interface LinkSuggestion {
|
||||
note_id: number;
|
||||
title: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const linkSuggestions = ref<LinkSuggestion[]>([]);
|
||||
let linkCheckTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function fetchLinkSuggestions() {
|
||||
if (!projectId.value || !body.value || !noteId.value) {
|
||||
linkSuggestions.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await apiPost<{ suggestions: LinkSuggestion[] }>('/api/notes/link-suggestions', {
|
||||
body: body.value,
|
||||
project_id: projectId.value,
|
||||
exclude_note_id: noteId.value,
|
||||
});
|
||||
linkSuggestions.value = res.suggestions;
|
||||
} catch {
|
||||
linkSuggestions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleLinkCheck() {
|
||||
if (linkCheckTimer) clearTimeout(linkCheckTimer);
|
||||
linkCheckTimer = setTimeout(fetchLinkSuggestions, 2000);
|
||||
}
|
||||
|
||||
watch(body, () => {
|
||||
if (projectId.value) scheduleLinkCheck();
|
||||
});
|
||||
|
||||
watch(projectId, (pid) => {
|
||||
if (pid) fetchLinkSuggestions();
|
||||
else linkSuggestions.value = [];
|
||||
});
|
||||
|
||||
/** Replace unlinked occurrences of title in body with [[title]]. */
|
||||
function applyWikilink(text: string, title: string): string {
|
||||
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const parts = text.split(/(\[\[[^\]]*\]\])/);
|
||||
const pattern = new RegExp(`\\b${escaped}\\b`, 'gi');
|
||||
return parts.map((part, i) =>
|
||||
i % 2 === 0 ? part.replace(pattern, `[[${title}]]`) : part
|
||||
).join('');
|
||||
}
|
||||
|
||||
function applyLink(title: string) {
|
||||
body.value = applyWikilink(body.value, title);
|
||||
markDirty();
|
||||
linkSuggestions.value = linkSuggestions.value.filter(s => s.title !== title);
|
||||
}
|
||||
|
||||
function applyAllLinks() {
|
||||
let text = body.value;
|
||||
for (const s of linkSuggestions.value) {
|
||||
text = applyWikilink(text, s.title);
|
||||
}
|
||||
body.value = text;
|
||||
markDirty();
|
||||
linkSuggestions.value = [];
|
||||
}
|
||||
|
||||
// Track saved state for dirty detection
|
||||
let savedTitle = "";
|
||||
let savedBody = "";
|
||||
@@ -199,6 +267,9 @@ onMounted(async () => {
|
||||
} catch {
|
||||
// No draft — normal
|
||||
}
|
||||
|
||||
// Initial link suggestions
|
||||
fetchLinkSuggestions();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -306,6 +377,9 @@ function onBeforeUnload(e: BeforeUnloadEvent) {
|
||||
}
|
||||
onMounted(() => window.addEventListener("beforeunload", onBeforeUnload));
|
||||
onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
|
||||
// Close any in-flight assist SSE stream when navigating away
|
||||
onUnmounted(() => assist.clearSelection());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -425,6 +499,21 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Link Suggestions -->
|
||||
<div v-if="linkSuggestions.length > 0" class="sb-field link-suggest-field">
|
||||
<div class="sb-label-row">
|
||||
<span class="sb-label">Link Suggestions</span>
|
||||
<button class="btn-link-all" @click="applyAllLinks">Link all</button>
|
||||
</div>
|
||||
<div class="link-suggest-list">
|
||||
<div v-for="s in linkSuggestions" :key="s.note_id" class="link-suggest-item">
|
||||
<span class="link-suggest-title">[[{{ s.title }}]]</span>
|
||||
<span class="link-suggest-count">×{{ s.count }}</span>
|
||||
<button class="btn-apply-link" @click="applyLink(s.title)">Link</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sb-divider"></div>
|
||||
|
||||
<!-- Writing Assistant (always visible) -->
|
||||
@@ -451,6 +540,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
placeholder="What should I do?"
|
||||
class="assist-instruction"
|
||||
rows="3"
|
||||
@keydown.enter.exact.prevent="assist.canSubmit.value && assist.submit()"
|
||||
></textarea>
|
||||
<div class="assist-input-actions">
|
||||
<button
|
||||
@@ -500,7 +590,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
v-if="showHistory && noteId"
|
||||
:note-id="noteId"
|
||||
:current-body="body"
|
||||
@restore="body = $event; markDirty()"
|
||||
@restore="(b: string, t: string[]) => { body = b; tags = t; markDirty(); }"
|
||||
@close="showHistory = false"
|
||||
/>
|
||||
|
||||
@@ -648,6 +738,68 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Link Suggestions */
|
||||
.link-suggest-field { gap: 0.4rem; }
|
||||
|
||||
.sb-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.btn-link-all {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-link-all:hover { background: var(--color-primary); color: #fff; }
|
||||
|
||||
.link-suggest-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.link-suggest-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.link-suggest-title {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
color: var(--color-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link-suggest-count {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-apply-link {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.btn-apply-link:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
/* Writing Assistant section */
|
||||
.assist-section {
|
||||
display: flex;
|
||||
|
||||
@@ -107,7 +107,7 @@ const isEditing = computed(() => taskId.value !== null);
|
||||
const renderedPreview = computed(() => renderMarkdown(body.value));
|
||||
|
||||
// AI Assist
|
||||
const assist = useAssist(body);
|
||||
const assist = useAssist(body, taskId, projectId);
|
||||
|
||||
const assistLabel = computed(() => {
|
||||
if (assist.isProofreading.value) return "Proofreading document...";
|
||||
@@ -681,6 +681,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
placeholder="What should I do with this section?"
|
||||
class="assist-instruction"
|
||||
rows="3"
|
||||
@keydown.enter.exact.prevent="assist.canSubmit.value && assist.submit()"
|
||||
></textarea>
|
||||
<div class="assist-input-actions">
|
||||
<button class="btn-generate" @click="assist.submit()" :disabled="!assist.canSubmit.value">Generate</button>
|
||||
|
||||
Reference in New Issue
Block a user