diff --git a/frontend/src/assets/theme.css b/frontend/src/assets/theme.css index f809ee1..7f763e9 100644 --- a/frontend/src/assets/theme.css +++ b/frontend/src/assets/theme.css @@ -128,3 +128,22 @@ button:focus-visible { display: none !important; } } + +/* Floating inline assist button (teleported to body, cannot be scoped) */ +.inline-assist-btn { + position: fixed; + z-index: 150; + transform: translateX(-50%); + background: var(--color-primary); + color: #fff; + border: none; + border-radius: 999px; + padding: 0.3rem 0.8rem; + font-size: 0.8rem; + cursor: pointer; + box-shadow: 0 2px 8px var(--color-shadow); + white-space: nowrap; +} +.inline-assist-btn:hover { + filter: brightness(1.1); +} diff --git a/frontend/src/composables/useAssist.ts b/frontend/src/composables/useAssist.ts index 532ca25..4fbfdf1 100644 --- a/frontend/src/composables/useAssist.ts +++ b/frontend/src/composables/useAssist.ts @@ -14,6 +14,33 @@ export interface AssistTarget { endOffset: number; } +export interface DiffLine { + type: 'equal' | 'delete' | 'insert'; + text: string; +} + +function computeDiff(a: string, b: string): DiffLine[] { + const aLines = a.split('\n'); + const bLines = b.split('\n'); + const m = aLines.length, n = bLines.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); + for (let i = m - 1; i >= 0; i--) + for (let j = n - 1; j >= 0; j--) + dp[i][j] = aLines[i] === bLines[j] + ? dp[i+1][j+1] + 1 + : Math.max(dp[i+1][j], dp[i][j+1]); + const result: DiffLine[] = []; + let i = 0, j = 0; + while (i < m && j < n) { + if (aLines[i] === bLines[j]) { result.push({ type: 'equal', text: aLines[i++] }); j++; } + else if (dp[i+1][j] >= dp[i][j+1]) result.push({ type: 'delete', text: aLines[i++] }); + else result.push({ type: 'insert', text: bLines[j++] }); + } + while (i < m) result.push({ type: 'delete', text: aLines[i++] }); + while (j < n) result.push({ type: 'insert', text: bLines[j++] }); + return result; +} + export function useAssist(body: Ref) { const chatStore = useChatStore(); @@ -25,6 +52,7 @@ export function useAssist(body: Ref) { const streamingText = ref(""); const proposedText = ref(""); const error = ref(""); + const isProofreading = ref(false); // Snapshot of body at the time a section/selection was chosen let bodySnapshot = ""; @@ -56,6 +84,11 @@ export function useAssist(body: Ref) { state.value !== "streaming" ); + const diff = computed(() => { + if (state.value !== 'review' || !target.value || !proposedText.value) return []; + return computeDiff(target.value.text, proposedText.value); + }); + function refreshSections() { sections.value = parseMarkdownSections(body.value); } @@ -145,6 +178,20 @@ export function useAssist(body: Ref) { } } + async function proofread() { + if (state.value === 'streaming') return; + isProofreading.value = true; + selectedSection.value = null; + customSelection.value = { start: 0, end: body.value.length, text: body.value }; + bodySnapshot = body.value; + error.value = ''; + instruction.value = + "Proofread for clarity, grammar, spelling, and flow. " + + "Preserve the author's voice and all factual content. " + + "Return only the improved text without explanation."; + await submit(); + } + function accept(): string { if (!target.value || !proposedText.value) return body.value; @@ -177,6 +224,7 @@ export function useAssist(body: Ref) { proposedText.value = ""; error.value = ""; state.value = "idle"; + isProofreading.value = false; return newBody; } @@ -186,6 +234,7 @@ export function useAssist(body: Ref) { streamingText.value = ""; error.value = ""; state.value = "idle"; + isProofreading.value = false; // Keep selection + instruction for retry } @@ -205,11 +254,14 @@ export function useAssist(body: Ref) { proposedText, error, canSubmit, + diff, + isProofreading, refreshSections, selectSection, selectTextRange, clearSelection, submit, + proofread, accept, reject, }; diff --git a/frontend/src/utils/sectionParser.ts b/frontend/src/utils/sectionParser.ts index 9a23424..1690297 100644 --- a/frontend/src/utils/sectionParser.ts +++ b/frontend/src/utils/sectionParser.ts @@ -8,6 +8,73 @@ export interface MarkdownSection { const HEADING_RE = /^(#{1,6})\s+(.*)$/gm; +function parseFallbackSections(body: string): MarkdownSection[] { + if (!body.trim()) + return []; + + const isPseudoHeading = (text: string): boolean => { + const t = text.trim(); + return ( + t.length > 0 && t.length <= 120 && + !t.includes('\n') && + !/^[-*+>]/.test(t) && + !/^\d+\./.test(t) && + !/^`{3,}/.test(t) + ); + }; + + // Build paragraph list with exact start/end offsets + const separatorRe = /\n{2,}/g; + const paragraphs: Array<{ text: string; start: number; end: number }> = []; + let lastEnd = 0; + let sep: RegExpExecArray | null; + while ((sep = separatorRe.exec(body)) !== null) { + if (sep.index > lastEnd) + paragraphs.push({ text: body.slice(lastEnd, sep.index), start: lastEnd, end: sep.index }); + lastEnd = sep.index + sep[0].length; + } + if (lastEnd < body.length) + paragraphs.push({ text: body.slice(lastEnd), start: lastEnd, end: body.length }); + + if (paragraphs.length === 0) + return [{ heading: '', level: 0, content: body, startOffset: 0, endOffset: body.length }]; + + const headingIndices = new Set( + paragraphs.map((p, i) => isPseudoHeading(p.text) ? i : -1).filter(i => i >= 0) + ); + if (headingIndices.size === 0) + return [{ heading: '', level: 0, content: body, startOffset: 0, endOffset: body.length }]; + + const sections: MarkdownSection[] = []; + const headingIdxArray = [...headingIndices].sort((a, b) => a - b); + const firstHi = headingIdxArray[0]; + + // Preamble (content before first pseudo-heading) + if (firstHi > 0) { + sections.push({ + heading: '', level: 0, + content: body.slice(paragraphs[0].start, paragraphs[firstHi].start), + startOffset: paragraphs[0].start, + endOffset: paragraphs[firstHi].start, + }); + } + + // One section per pseudo-heading + for (let h = 0; h < headingIdxArray.length; h++) { + const hi = headingIdxArray[h]; + const nextHi = h + 1 < headingIdxArray.length ? headingIdxArray[h + 1] : null; + const sectionEnd = nextHi !== null ? paragraphs[nextHi].start : body.length; + sections.push({ + heading: paragraphs[hi].text.trim(), + level: 0, + content: body.slice(paragraphs[hi].start, sectionEnd), + startOffset: paragraphs[hi].start, + endOffset: sectionEnd, + }); + } + return sections; +} + export function parseMarkdownSections(body: string): MarkdownSection[] { const sections: MarkdownSection[] = []; const matches: { index: number; level: number; heading: string }[] = []; @@ -23,17 +90,7 @@ export function parseMarkdownSections(body: string): MarkdownSection[] { // 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; + return parseFallbackSections(body); } if (matches[0].index > 0) { diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue index feb2489..46e74c8 100644 --- a/frontend/src/views/ChatView.vue +++ b/frontend/src/views/ChatView.vue @@ -676,7 +676,7 @@ onUnmounted(() => { padding: 1rem 1.5rem; display: flex; flex-direction: column; - max-width: 960px; + max-width: 1200px; margin: 0 auto; width: 100%; } @@ -821,7 +821,7 @@ onUnmounted(() => { /* Input wrapper */ .input-wrapper { - max-width: 960px; + max-width: 1200px; margin: 0 auto 2rem; padding: 0 1rem; width: 100%; diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 30ec2c7..ea46b9e 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -424,7 +424,7 @@ function clearDashboardResponse() { diff --git a/frontend/src/views/NoteViewerView.vue b/frontend/src/views/NoteViewerView.vue index b642578..11eb71a 100644 --- a/frontend/src/views/NoteViewerView.vue +++ b/frontend/src/views/NoteViewerView.vue @@ -171,14 +171,14 @@ async function convertToTask() { diff --git a/frontend/src/views/TaskViewerView.vue b/frontend/src/views/TaskViewerView.vue index e007ff1..418688e 100644 --- a/frontend/src/views/TaskViewerView.vue +++ b/frontend/src/views/TaskViewerView.vue @@ -201,14 +201,14 @@ function onTagClick(tag: string) {