From eda898b7cb31c78cea3e57990d97c7042ad977f4 Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Sat, 31 Oct 2026 16:23:35 -0400 Subject: [PATCH 01/13] Refactor: extract my-words tool logic to a shared `docTools` --- docs/my-words-voice-scratchpad.md | 4 +- .../__tests__/corpus.test.ts | 0 .../__tests__/ops.test.ts | 4 +- .../__tests__/spliceAdapter.test.ts | 4 +- .../{pages/my-words => docTools}/corpus.ts | Bin .../interaction => docTools}/editor.ts | 0 .../my-words/interaction => docTools}/ops.ts | 2 +- frontend/src/docTools/report.ts | 180 +++++++++++++++++ frontend/src/docTools/types.ts | 28 +++ .../src/editor/__tests__/applySplice.test.ts | 2 +- frontend/src/editor/index.tsx | 4 +- .../my-words/__tests__/voiceControl.test.ts | 2 +- frontend/src/pages/my-words/demo/DemoApp.tsx | 2 +- .../src/pages/my-words/demo/mockEditor.ts | 2 +- frontend/src/pages/my-words/index.tsx | 2 +- .../src/pages/my-words/interaction/shared.ts | 186 +----------------- .../interaction/strategies/propose.ts | 6 +- .../interaction/strategies/walkthrough.ts | 7 +- .../src/pages/my-words/interaction/types.ts | 28 +-- .../my-words/interaction/useInteraction.ts | 2 +- .../src/pages/my-words/voice/VoiceSession.tsx | 2 +- frontend/src/pages/my-words/voice/session.ts | 10 +- frontend/src/types.d.ts | 2 +- 23 files changed, 246 insertions(+), 233 deletions(-) rename frontend/src/{pages/my-words => docTools}/__tests__/corpus.test.ts (100%) rename frontend/src/{pages/my-words => docTools}/__tests__/ops.test.ts (98%) rename frontend/src/{pages/my-words => docTools}/__tests__/spliceAdapter.test.ts (97%) rename frontend/src/{pages/my-words => docTools}/corpus.ts (100%) rename frontend/src/{pages/my-words/interaction => docTools}/editor.ts (100%) rename frontend/src/{pages/my-words/interaction => docTools}/ops.ts (99%) create mode 100644 frontend/src/docTools/report.ts create mode 100644 frontend/src/docTools/types.ts diff --git a/docs/my-words-voice-scratchpad.md b/docs/my-words-voice-scratchpad.md index 5cf3af8c..300d533e 100644 --- a/docs/my-words-voice-scratchpad.md +++ b/docs/my-words-voice-scratchpad.md @@ -183,8 +183,8 @@ from scratch. The first two are design work and are tracked as issues #555 and | Piece | Where | | --- | --- | -| Splice lowering, inversion, freshness | `frontend/src/pages/my-words/interaction/ops.ts` | -| Reveal/veto + splice adapter (per-host) | `frontend/src/pages/my-words/interaction/editor.ts` | +| Splice lowering, inversion, freshness | `frontend/src/docTools/ops.ts` | +| Reveal/veto + splice adapter (per-host) | `frontend/src/docTools/editor.ts` | | Lexical node-incremental `applySplice` | `frontend/src/editor/editor.tsx` (+ `index.tsx`) | | Word `delete_paragraph` | `frontend/src/api/wordEditorAPI.ts` | | Voice tool routing, undo stack, control invariants | `frontend/src/pages/my-words/voice/session.ts` | diff --git a/frontend/src/pages/my-words/__tests__/corpus.test.ts b/frontend/src/docTools/__tests__/corpus.test.ts similarity index 100% rename from frontend/src/pages/my-words/__tests__/corpus.test.ts rename to frontend/src/docTools/__tests__/corpus.test.ts diff --git a/frontend/src/pages/my-words/__tests__/ops.test.ts b/frontend/src/docTools/__tests__/ops.test.ts similarity index 98% rename from frontend/src/pages/my-words/__tests__/ops.test.ts rename to frontend/src/docTools/__tests__/ops.test.ts index 27d1fa77..3fe62a82 100644 --- a/frontend/src/pages/my-words/__tests__/ops.test.ts +++ b/frontend/src/docTools/__tests__/ops.test.ts @@ -9,8 +9,8 @@ import { lowerOp, previewOp, spliceIsFresh, -} from '../interaction/ops'; -import type { EditOp } from '../interaction/types'; +} from '../ops'; +import type { EditOp } from '../types'; describe('applyOp', () => { it('str_replace replaces the first occurrence only', () => { diff --git a/frontend/src/pages/my-words/__tests__/spliceAdapter.test.ts b/frontend/src/docTools/__tests__/spliceAdapter.test.ts similarity index 97% rename from frontend/src/pages/my-words/__tests__/spliceAdapter.test.ts rename to frontend/src/docTools/__tests__/spliceAdapter.test.ts index 3b703277..f68a4087 100644 --- a/frontend/src/pages/my-words/__tests__/spliceAdapter.test.ts +++ b/frontend/src/docTools/__tests__/spliceAdapter.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { applySpliceToEditor, minimalReplace } from '../interaction/editor'; -import { applyOp, applySplice, lowerOp } from '../interaction/ops'; +import { applySpliceToEditor, minimalReplace } from '../editor'; +import { applyOp, applySplice, lowerOp } from '../ops'; /** * A host WITHOUT native applySplice: applies DocEdit primitives through the diff --git a/frontend/src/pages/my-words/corpus.ts b/frontend/src/docTools/corpus.ts similarity index 100% rename from frontend/src/pages/my-words/corpus.ts rename to frontend/src/docTools/corpus.ts diff --git a/frontend/src/pages/my-words/interaction/editor.ts b/frontend/src/docTools/editor.ts similarity index 100% rename from frontend/src/pages/my-words/interaction/editor.ts rename to frontend/src/docTools/editor.ts diff --git a/frontend/src/pages/my-words/interaction/ops.ts b/frontend/src/docTools/ops.ts similarity index 99% rename from frontend/src/pages/my-words/interaction/ops.ts rename to frontend/src/docTools/ops.ts index 9f638e92..724d5021 100644 --- a/frontend/src/pages/my-words/interaction/ops.ts +++ b/frontend/src/docTools/ops.ts @@ -22,7 +22,7 @@ import type { EditOp } from './types'; // `ParagraphSplice` — the canonical mutation every op lowers to — is declared // ambiently in types.d.ts so editor hosts can implement `applySplice` without -// importing from this page. +// importing from here. /** Paragraph-break normalization: runs of newlines are one break. */ const normalizeBreaks = (s: string) => diff --git a/frontend/src/docTools/report.ts b/frontend/src/docTools/report.ts new file mode 100644 index 00000000..e42ffdde --- /dev/null +++ b/frontend/src/docTools/report.ts @@ -0,0 +1,180 @@ +/** + * What the model reads back from the document tools: the numbered `view`, the + * post-apply report, the word-bank check, and the human-readable description + * of a proposed edit. + */ + +import { validateText, type Corpus } from './corpus'; +import { applyEditOp } from './editor'; +import { describeOp } from './ops'; +import type { EditOp } from './types'; + +const clip = (s: string, n = 40) => + s.length > n ? `${s.slice(0, n - 1)}…` : s; + +/** + * For a staged edit, decide what to point at in the document and how to describe + * the change so the writer can see *where* before deciding. We highlight one end + * (the change site, or the destination for inserts/moves) and name the other end + * in the summary, so source and destination are both concrete. + */ +export async function locateProposal( + editor: EditorAPI, + op: EditOp, +): Promise<{ highlight?: string; summary: string }> { + if (op.kind === 'str_replace') { + // The site IS the source span — point there; the summary shows old → new. + return { highlight: op.oldStr, summary: describeOp(op) }; + } + + const where = (op.position ?? 'after') === 'before' ? 'before' : 'after'; + + if (op.kind === 'insert') { + if (op.after !== undefined) { + return { + highlight: op.after, + summary: `Add “${clip(op.text)}” after “${clip(op.after)}”`, + }; + } + if (op.paragraph !== undefined) { + const target = + (await editor.getParagraphs())[op.paragraph - 1] ?? ''; + return { + highlight: target || undefined, + summary: target + ? `Add “${clip(op.text)}” ${where} “${clip(target)}”` + : `Add “${clip(op.text)}”`, + }; + } + return { summary: `Add “${clip(op.text)}” at the end` }; + } + + // move: point at the destination (the "new spot"); name the source phrase. + const target = (await editor.getParagraphs())[op.paragraph - 1] ?? ''; + return { + highlight: target || undefined, + summary: target + ? `Move “${clip(op.phrase)}” ${where} “${clip(target)}”` + : `Move “${clip(op.phrase)}”`, + }; +} + +/** + * Render paragraphs for the `view` tool, numbered [1], [2], … Pure so the + * scratchpad (an array of lines) can use it too. + */ +export function viewParagraphs( + paragraphs: string[], + emptyLabel = '(the document is empty)', +): string { + if (!paragraphs.some((p) => p.trim().length > 0)) return emptyLabel; + // Skip blank paragraphs (they burn tokens), but keep each kept paragraph's + // real 1-based number so `insert`/`move` targeting still lines up. + return paragraphs + .map((p, i) => ({ n: i + 1, text: p })) + .filter((x) => x.text.trim().length > 0) + .map((x) => `[${x.n}] ${x.text}`) + .join('\n'); +} + +/** Render the document for the `view` tool: paragraphs numbered [1], [2], … */ +export async function viewText(editor: EditorAPI): Promise { + return viewParagraphs(await editor.getParagraphs()); +} + +/** + * A numbered window of paragraphs (each clipped) around a center paragraph, or + * everything if no center is given. Used to re-orient the model after an edit + * lands or fails — cheaper than a full `view`. Pure; see `windowAround` for the + * editor-backed wrapper. + */ +export function numberedWindow( + paragraphs: string[], + center?: number, + radius = 1, + clipLen = 80, +): string { + const total = paragraphs.length; + let lo = 0; + let hi = total - 1; + if (center !== undefined) { + lo = Math.max(0, center - 1 - radius); + hi = Math.min(total - 1, center - 1 + radius); + } + return paragraphs + .slice(lo, hi + 1) + .map((p, i) => `[${lo + i + 1}] ${clip(p, clipLen)}`) + .join('\n'); +} + +/** The text an op introduces, used to locate where the change landed. */ +function probeFor(op: EditOp): string { + if (op.kind === 'str_replace') return op.newStr; + if (op.kind === 'insert') return op.text; + return op.phrase; +} + +/** + * The post-apply report the model reads: a brief confirmation plus a clipped + * window around where the change landed — never the full text (token cost). + * `surface` names the document or the scratchpad. + */ +export function appliedReport( + paragraphs: string[], + op: EditOp, + surface = 'document', +): string { + const probe = probeFor(op).trim().slice(0, 40); + const center = + paragraphs.findIndex((p) => probe && p.includes(probe)) + 1 || + undefined; + return `Applied. The ${surface} now has ${paragraphs.length} paragraph(s); numbers may have shifted. Around the change:\n${numberedWindow(paragraphs, center)}`; +} + +/** The paragraph an op targets, if it carries one. */ +function targetParagraph(op: EditOp): number | undefined { + if (op.kind === 'str_replace') return op.paragraph; + return 'paragraph' in op ? op.paragraph : undefined; +} + +/** + * Apply an edit and report back to the model. On success, a brief confirmation + * plus a window around where it landed (so it can track number shifts). On + * failure — e.g. the writer moved a paragraph and the cached number is stale — + * the error plus a window of the current nearby paragraphs, so the model can + * re-orient and retry instead of flying blind. + */ +export async function applyOpAndReport( + editor: EditorAPI, + op: EditOp, +): Promise<{ ok: boolean; report: string }> { + try { + await applyEditOp(editor, op); + return { + ok: true, + report: appliedReport(await editor.getParagraphs(), op), + }; + } catch (e) { + const window = numberedWindow( + await editor.getParagraphs(), + targetParagraph(op), + 2, + ); + return { + ok: false, + report: `Could not apply that: ${(e as Error).message} The document may have changed since you last looked. Current paragraphs near there:\n${window}\nRe-check the numbers (or \`view\`) and try again.`, + }; + } +} + +/** Is the text an edit would introduce drawn entirely from the writer's words? */ +export function validateOp( + op: EditOp, + corpus: Corpus, +): { ok: boolean; offending?: string } { + // `move` relocates existing words, so it introduces nothing new. + if (op.kind === 'move') return { ok: true }; + const text = op.kind === 'str_replace' ? op.newStr : op.text; + const res = validateText(text, corpus); + return { ok: res.ok, offending: res.offending }; +} diff --git a/frontend/src/docTools/types.ts b/frontend/src/docTools/types.ts new file mode 100644 index 00000000..8d320989 --- /dev/null +++ b/frontend/src/docTools/types.ts @@ -0,0 +1,28 @@ +/** + * Shared vocabulary for the document tools (`view` and the own-words edit + * tools) used by Chat and the My Words prototypes. + */ + +/** A single document operation. `move` relocates the writer's own words. */ +export type EditOp = + | { + kind: 'str_replace'; + oldStr: string; + newStr: string; + /** Optional 1-based paragraph (from `view`) to scope the search to. */ + paragraph?: number; + } + | { + kind: 'insert'; + text: string; + after?: string; + paragraph?: number; + position?: 'before' | 'after'; + } + | { + kind: 'move'; + /** Existing text to relocate (lifted verbatim — adds no words). */ + phrase: string; + paragraph: number; + position?: 'before' | 'after'; + }; diff --git a/frontend/src/editor/__tests__/applySplice.test.ts b/frontend/src/editor/__tests__/applySplice.test.ts index ab6ec671..7be0b5ef 100644 --- a/frontend/src/editor/__tests__/applySplice.test.ts +++ b/frontend/src/editor/__tests__/applySplice.test.ts @@ -13,7 +13,7 @@ import { } from 'lexical'; import { describe, expect, it } from 'vitest'; -import { lowerOp } from '@/pages/my-words/interaction/ops'; +import { lowerOp } from '@/docTools/ops'; import { $applySplice } from '../editor'; function makeEditor(paras: string[]): LexicalEditor { diff --git a/frontend/src/editor/index.tsx b/frontend/src/editor/index.tsx index f1f6caab..8b5853a7 100644 --- a/frontend/src/editor/index.tsx +++ b/frontend/src/editor/index.tsx @@ -9,8 +9,8 @@ import LexicalEditor, { type EditorControls } from './editor'; import './styles.css'; import classes from './styles.module.css'; import { EditorContext } from '@/contexts/editorContext'; -import { lowerOp } from '@/pages/my-words/interaction/ops'; -import type { EditOp } from '@/pages/my-words/interaction/types'; +import { lowerOp } from '@/docTools/ops'; +import type { EditOp } from '@/docTools/types'; function Sidebar() { return ; diff --git a/frontend/src/pages/my-words/__tests__/voiceControl.test.ts b/frontend/src/pages/my-words/__tests__/voiceControl.test.ts index a339c869..8d727d80 100644 --- a/frontend/src/pages/my-words/__tests__/voiceControl.test.ts +++ b/frontend/src/pages/my-words/__tests__/voiceControl.test.ts @@ -13,7 +13,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { buildCorpus } from '../corpus'; +import { buildCorpus } from '@/docTools/corpus'; import { MockEditor } from '../demo/mockEditor'; import { startVoiceSession } from '../voice/session'; import type { VoiceTransport, VoiceTransportOptions } from '../voice/transport'; diff --git a/frontend/src/pages/my-words/demo/DemoApp.tsx b/frontend/src/pages/my-words/demo/DemoApp.tsx index 0ef31087..3a5a4fba 100644 --- a/frontend/src/pages/my-words/demo/DemoApp.tsx +++ b/frontend/src/pages/my-words/demo/DemoApp.tsx @@ -10,7 +10,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { buildCorpus } from '../corpus'; +import { buildCorpus } from '@/docTools/corpus'; import { InteractionPanel } from '../InteractionPanel'; import { useInteraction } from '../interaction/useInteraction'; import { MockEditor } from './mockEditor'; diff --git a/frontend/src/pages/my-words/demo/mockEditor.ts b/frontend/src/pages/my-words/demo/mockEditor.ts index b5e63e05..9062c2c5 100644 --- a/frontend/src/pages/my-words/demo/mockEditor.ts +++ b/frontend/src/pages/my-words/demo/mockEditor.ts @@ -5,7 +5,7 @@ * and `snapshot` so a document panel can render it live. */ -import { applyOp, applySplice } from '../interaction/ops'; +import { applyOp, applySplice } from '@/docTools/ops'; export class MockEditor implements EditorAPI { private paragraphs: string[]; diff --git a/frontend/src/pages/my-words/index.tsx b/frontend/src/pages/my-words/index.tsx index c9bd6f5d..fb6c6991 100644 --- a/frontend/src/pages/my-words/index.tsx +++ b/frontend/src/pages/my-words/index.tsx @@ -3,7 +3,7 @@ import { useCallback, useContext, useEffect, useRef, useState } from 'react'; import { OPENAI_MODEL, openai } from '@/api/openai'; import { EditorContext } from '@/contexts/editorContext'; -import { buildCorpus } from './corpus'; +import { buildCorpus } from '@/docTools/corpus'; import { InteractionPanel } from './InteractionPanel'; import { createLiveResponder, MODE_PROMPTS } from './interaction/liveResponder'; import { createProposeStrategy } from './interaction/strategies/propose'; diff --git a/frontend/src/pages/my-words/interaction/shared.ts b/frontend/src/pages/my-words/interaction/shared.ts index 0bb6e91c..c0180810 100644 --- a/frontend/src/pages/my-words/interaction/shared.ts +++ b/frontend/src/pages/my-words/interaction/shared.ts @@ -2,189 +2,13 @@ * Turn primitives shared by the interaction strategies. * * The strategies differ in *what they do with an edit* (apply now vs. stage for - * consent); everything before that — pumping the model through read-only moves - * like `view` and `highlight`, validating against the word-bank, describing the - * result — is common and lives here. + * consent); pumping the model through read-only moves like `view` and + * `highlight` is common and lives here. The document-tool primitives + * (validation, reports) are in `@/docTools/report`. */ -import { validateText, type Corpus } from '../corpus'; -import { applyEditOp } from './editor'; -import { describeOp } from './ops'; -import type { - AssistantMove, - EditOp, - StrategyContext, -} from './types'; - -const clip = (s: string, n = 40) => - s.length > n ? `${s.slice(0, n - 1)}…` : s; - -/** - * For a staged edit, decide what to point at in the document and how to describe - * the change so the writer can see *where* before deciding. We highlight one end - * (the change site, or the destination for inserts/moves) and name the other end - * in the summary, so source and destination are both concrete. - */ -export async function locateProposal( - editor: EditorAPI, - op: EditOp, -): Promise<{ highlight?: string; summary: string }> { - if (op.kind === 'str_replace') { - // The site IS the source span — point there; the summary shows old → new. - return { highlight: op.oldStr, summary: describeOp(op) }; - } - - const where = (op.position ?? 'after') === 'before' ? 'before' : 'after'; - - if (op.kind === 'insert') { - if (op.after !== undefined) { - return { - highlight: op.after, - summary: `Add “${clip(op.text)}” after “${clip(op.after)}”`, - }; - } - if (op.paragraph !== undefined) { - const target = - (await editor.getParagraphs())[op.paragraph - 1] ?? ''; - return { - highlight: target || undefined, - summary: target - ? `Add “${clip(op.text)}” ${where} “${clip(target)}”` - : `Add “${clip(op.text)}”`, - }; - } - return { summary: `Add “${clip(op.text)}” at the end` }; - } - - // move: point at the destination (the "new spot"); name the source phrase. - const target = (await editor.getParagraphs())[op.paragraph - 1] ?? ''; - return { - highlight: target || undefined, - summary: target - ? `Move “${clip(op.phrase)}” ${where} “${clip(target)}”` - : `Move “${clip(op.phrase)}”`, - }; -} - -/** - * Render paragraphs for the `view` tool, numbered [1], [2], … Pure so the - * scratchpad (an array of lines) can use it too. - */ -export function viewParagraphs( - paragraphs: string[], - emptyLabel = '(the document is empty)', -): string { - if (!paragraphs.some((p) => p.trim().length > 0)) return emptyLabel; - // Skip blank paragraphs (they burn tokens), but keep each kept paragraph's - // real 1-based number so `insert`/`move` targeting still lines up. - return paragraphs - .map((p, i) => ({ n: i + 1, text: p })) - .filter((x) => x.text.trim().length > 0) - .map((x) => `[${x.n}] ${x.text}`) - .join('\n'); -} - -/** Render the document for the `view` tool: paragraphs numbered [1], [2], … */ -export async function viewText(editor: EditorAPI): Promise { - return viewParagraphs(await editor.getParagraphs()); -} - -/** - * A numbered window of paragraphs (each clipped) around a center paragraph, or - * everything if no center is given. Used to re-orient the model after an edit - * lands or fails — cheaper than a full `view`. Pure; see `windowAround` for the - * editor-backed wrapper. - */ -export function numberedWindow( - paragraphs: string[], - center?: number, - radius = 1, - clipLen = 80, -): string { - const total = paragraphs.length; - let lo = 0; - let hi = total - 1; - if (center !== undefined) { - lo = Math.max(0, center - 1 - radius); - hi = Math.min(total - 1, center - 1 + radius); - } - return paragraphs - .slice(lo, hi + 1) - .map((p, i) => `[${lo + i + 1}] ${clip(p, clipLen)}`) - .join('\n'); -} - -/** The text an op introduces, used to locate where the change landed. */ -function probeFor(op: EditOp): string { - if (op.kind === 'str_replace') return op.newStr; - if (op.kind === 'insert') return op.text; - return op.phrase; -} - -/** - * The post-apply report the model reads: a brief confirmation plus a clipped - * window around where the change landed — never the full text (token cost). - * `surface` names the document or the scratchpad. - */ -export function appliedReport( - paragraphs: string[], - op: EditOp, - surface = 'document', -): string { - const probe = probeFor(op).trim().slice(0, 40); - const center = - paragraphs.findIndex((p) => probe && p.includes(probe)) + 1 || - undefined; - return `Applied. The ${surface} now has ${paragraphs.length} paragraph(s); numbers may have shifted. Around the change:\n${numberedWindow(paragraphs, center)}`; -} - -/** The paragraph an op targets, if it carries one. */ -function targetParagraph(op: EditOp): number | undefined { - if (op.kind === 'str_replace') return op.paragraph; - return 'paragraph' in op ? op.paragraph : undefined; -} - -/** - * Apply an edit and report back to the model. On success, a brief confirmation - * plus a window around where it landed (so it can track number shifts). On - * failure — e.g. the writer moved a paragraph and the cached number is stale — - * the error plus a window of the current nearby paragraphs, so the model can - * re-orient and retry instead of flying blind. - */ -export async function applyOpAndReport( - ctx: StrategyContext, - op: EditOp, -): Promise<{ ok: boolean; report: string }> { - try { - await applyEditOp(ctx.editor, op); - return { - ok: true, - report: appliedReport(await ctx.editor.getParagraphs(), op), - }; - } catch (e) { - const window = numberedWindow( - await ctx.editor.getParagraphs(), - targetParagraph(op), - 2, - ); - return { - ok: false, - report: `Could not apply that: ${(e as Error).message} The document may have changed since you last looked. Current paragraphs near there:\n${window}\nRe-check the numbers (or \`view\`) and try again.`, - }; - } -} - -/** Is the text an edit would introduce drawn entirely from the writer's words? */ -export function validateOp( - op: EditOp, - corpus: Corpus, -): { ok: boolean; offending?: string } { - // `move` relocates existing words, so it introduces nothing new. - if (op.kind === 'move') return { ok: true }; - const text = op.kind === 'str_replace' ? op.newStr : op.text; - const res = validateText(text, corpus); - return { ok: res.ok, offending: res.offending }; -} +import { viewText } from '@/docTools/report'; +import type { AssistantMove, StrategyContext } from './types'; /** * Pump the model through read-only moves (`view`, `highlight`) — which are not diff --git a/frontend/src/pages/my-words/interaction/strategies/propose.ts b/frontend/src/pages/my-words/interaction/strategies/propose.ts index 6bfd261d..52f2c882 100644 --- a/frontend/src/pages/my-words/interaction/strategies/propose.ts +++ b/frontend/src/pages/my-words/interaction/strategies/propose.ts @@ -13,11 +13,11 @@ */ import { - advanceToDecision, applyOpAndReport, locateProposal, validateOp, -} from '../shared'; +} from '@/docTools/report'; +import { advanceToDecision } from '../shared'; import type { InteractionStrategy, PendingProposal } from '../types'; export function createProposeStrategy(): InteractionStrategy { @@ -33,7 +33,7 @@ export function createProposeStrategy(): InteractionStrategy { // as the open edit's tool result, so the model can react to it. if (pending) { if (input.type === 'accept') { - const result = await applyOpAndReport(ctx, pending.op); + const result = await applyOpAndReport(ctx.editor, pending.op); ctx.responder.recordToolResult( result.ok ? `The writer ACCEPTED. ${result.report}` diff --git a/frontend/src/pages/my-words/interaction/strategies/walkthrough.ts b/frontend/src/pages/my-words/interaction/strategies/walkthrough.ts index 9438d0db..bb270cea 100644 --- a/frontend/src/pages/my-words/interaction/strategies/walkthrough.ts +++ b/frontend/src/pages/my-words/interaction/strategies/walkthrough.ts @@ -12,8 +12,9 @@ * docs/my-words-interaction-design.md. */ -import { describeOp } from '../ops'; -import { advanceToDecision, applyOpAndReport, validateOp } from '../shared'; +import { describeOp } from '@/docTools/ops'; +import { applyOpAndReport, validateOp } from '@/docTools/report'; +import { advanceToDecision } from '../shared'; import type { InteractionStrategy } from '../types'; export function createWalkthroughStrategy(): InteractionStrategy { @@ -44,7 +45,7 @@ export function createWalkthroughStrategy(): InteractionStrategy { continue; } - const result = await applyOpAndReport(ctx, action.op); + const result = await applyOpAndReport(ctx.editor, action.op); ctx.responder.recordToolResult(result.report); if (!result.ok) continue; return { diff --git a/frontend/src/pages/my-words/interaction/types.ts b/frontend/src/pages/my-words/interaction/types.ts index 6a9857bf..7e8e9756 100644 --- a/frontend/src/pages/my-words/interaction/types.ts +++ b/frontend/src/pages/my-words/interaction/types.ts @@ -15,29 +15,9 @@ * the locus of commitment, not the number of steps. */ -/** A single document operation. `move` relocates the writer's own words. */ -export type EditOp = - | { - kind: 'str_replace'; - oldStr: string; - newStr: string; - /** Optional 1-based paragraph (from `view`) to scope the search to. */ - paragraph?: number; - } - | { - kind: 'insert'; - text: string; - after?: string; - paragraph?: number; - position?: 'before' | 'after'; - } - | { - kind: 'move'; - /** Existing text to relocate (lifted verbatim — adds no words). */ - phrase: string; - paragraph: number; - position?: 'before' | 'after'; - }; +import type { EditOp } from '@/docTools/types'; + +export type { EditOp }; /** At most one externally-visible thing the model does in a step. */ export type Action = @@ -104,7 +84,7 @@ export interface StrategyContext { editor: EditorAPI; responder: Responder; /** Build the live word-bank to validate edits against. */ - corpus: () => Promise; + corpus: () => Promise; /** Push an interim caption mid-turn (feedthrough while acting). */ setCaption: (text: string) => void; } diff --git a/frontend/src/pages/my-words/interaction/useInteraction.ts b/frontend/src/pages/my-words/interaction/useInteraction.ts index 74e44db4..4d481005 100644 --- a/frontend/src/pages/my-words/interaction/useInteraction.ts +++ b/frontend/src/pages/my-words/interaction/useInteraction.ts @@ -8,7 +8,7 @@ import { useCallback, useRef, useState } from 'react'; -import type { Corpus } from '../corpus'; +import type { Corpus } from '@/docTools/corpus'; import type { Awaiting, InteractionStrategy, diff --git a/frontend/src/pages/my-words/voice/VoiceSession.tsx b/frontend/src/pages/my-words/voice/VoiceSession.tsx index 97d86fcc..6706a671 100644 --- a/frontend/src/pages/my-words/voice/VoiceSession.tsx +++ b/frontend/src/pages/my-words/voice/VoiceSession.tsx @@ -23,7 +23,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { buildCorpus } from '../corpus'; +import { buildCorpus } from '@/docTools/corpus'; import { startVoiceSession, type TranscriptSpeaker, diff --git a/frontend/src/pages/my-words/voice/session.ts b/frontend/src/pages/my-words/voice/session.ts index 4d928380..b012d0fb 100644 --- a/frontend/src/pages/my-words/voice/session.ts +++ b/frontend/src/pages/my-words/voice/session.ts @@ -55,7 +55,7 @@ * objection and does not touch `pendingVeto`. */ -import type { Corpus } from '../corpus'; +import type { Corpus } from '@/docTools/corpus'; import { applyEditOp, applySpliceToEditor, @@ -64,21 +64,21 @@ import { revealAnchorFor, vetoWindow, type ApplyEditOptions, -} from '../interaction/editor'; +} from '@/docTools/editor'; import { applyOpLogged, applySplice, describeOp, invertSplices, spliceIsFresh, -} from '../interaction/ops'; +} from '@/docTools/ops'; import { appliedReport, numberedWindow, validateOp, viewParagraphs, -} from '../interaction/shared'; -import type { EditOp } from '../interaction/types'; +} from '@/docTools/report'; +import type { EditOp } from '@/docTools/types'; import { GREETING_INSTRUCTIONS, VOICE_INSTRUCTIONS } from './prompt'; import { startRealtimeTransport } from './realtime'; import { buildVoiceTools, type ToolName } from './tools'; diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts index 40b2ddeb..729ff9bd 100644 --- a/frontend/src/types.d.ts +++ b/frontend/src/types.d.ts @@ -66,7 +66,7 @@ type DocEdit = /** * The canonical paragraph-range mutation every `EditOp` lowers to: replace * `remove.length` paragraphs at `index` with `insert`. See - * `pages/my-words/interaction/ops.ts` for lowering/inversion. + * `docTools/ops.ts` for lowering/inversion. */ interface ParagraphSplice { /** First affected paragraph (0-based). */ From 65f4a132b2b570499c4ab649c1890adbb8dd69ad Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Sat, 31 Oct 2026 16:24:06 -0400 Subject: [PATCH 02/13] Fix: use the shared language model object instead of a custom old-style ChatCompletions API --- frontend/src/pages/my-words/index.tsx | 4 ++-- frontend/src/pages/my-words/interaction/liveResponder.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/my-words/index.tsx b/frontend/src/pages/my-words/index.tsx index fb6c6991..b707d447 100644 --- a/frontend/src/pages/my-words/index.tsx +++ b/frontend/src/pages/my-words/index.tsx @@ -1,6 +1,6 @@ import { useCallback, useContext, useEffect, useRef, useState } from 'react'; -import { OPENAI_MODEL, openai } from '@/api/openai'; +import { languageModel } from '@/api/openai'; import { EditorContext } from '@/contexts/editorContext'; import { buildCorpus } from '@/docTools/corpus'; @@ -185,7 +185,7 @@ function LiveSession(props: { sessionRef.current = { strategy: STRATEGIES[strategyKey](), responder: createLiveResponder({ - model: openai.chat(OPENAI_MODEL), + model: languageModel, modePrompt: MODE_PROMPTS[strategyKey], }), }; diff --git a/frontend/src/pages/my-words/interaction/liveResponder.ts b/frontend/src/pages/my-words/interaction/liveResponder.ts index 22f71946..d963cd4d 100644 --- a/frontend/src/pages/my-words/interaction/liveResponder.ts +++ b/frontend/src/pages/my-words/interaction/liveResponder.ts @@ -111,7 +111,7 @@ function toAction(toolName: string, input: unknown): Action | null { } export interface LiveResponderOptions { - /** The AI SDK model (e.g. `openai.chat('gpt-5.5')`). */ + /** The AI SDK model (normally the shared `languageModel`). */ model: Parameters[0]['model']; /** Strategy-specific contract appended to the base prompt. */ modePrompt: string; From dfb361f1c9e5f88b963e0696c89e307e663b1705 Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Sat, 31 Oct 2026 16:34:43 -0400 Subject: [PATCH 03/13] Make editing ops optional, since they vary by host. --- docs/my-words-voice-scratchpad.md | 5 +++-- frontend/src/api/googleDocsEditorAPI.ts | 12 ++++-------- frontend/src/contexts/editorContext.tsx | 4 ---- .../src/docTools/__tests__/spliceAdapter.test.ts | 2 +- frontend/src/docTools/editor.ts | 9 +++++++-- frontend/src/docTools/report.ts | 2 +- frontend/src/pages/my-words/index.tsx | 9 +++++++-- frontend/src/pages/my-words/interaction/types.ts | 2 +- .../src/pages/my-words/interaction/useInteraction.ts | 2 +- frontend/src/pages/my-words/voice/VoiceSession.tsx | 2 +- frontend/src/pages/my-words/voice/session.ts | 2 +- frontend/src/types.d.ts | 11 +++++++++-- 12 files changed, 36 insertions(+), 26 deletions(-) diff --git a/docs/my-words-voice-scratchpad.md b/docs/my-words-voice-scratchpad.md index 300d533e..150c682c 100644 --- a/docs/my-words-voice-scratchpad.md +++ b/docs/my-words-voice-scratchpad.md @@ -127,8 +127,9 @@ open to promote a convention into structure later if usage proves it out. ## Limitations / next -- Google Docs still has no `applyEdit`, so voice edits (and undo) don't work - on that host; `selectPhrase`-based reveals do. +- Google Docs still has no `applyEdit` (it's optional on `EditorAPI`; check + with `canEdit`), so My Words shows a "can't edit here" note on that host + instead of the session. - Paragraph identity is positional. Stable IDs (Word's newer `Paragraph.uniqueLocalId`) would end paragraph-number drift; numbers + text anchors are good enough for the prototype. diff --git a/frontend/src/api/googleDocsEditorAPI.ts b/frontend/src/api/googleDocsEditorAPI.ts index ec8d8452..a8d79cae 100644 --- a/frontend/src/api/googleDocsEditorAPI.ts +++ b/frontend/src/api/googleDocsEditorAPI.ts @@ -330,14 +330,10 @@ export const googleDocsEditorAPI: EditorAPI = { return text.split('\n'); }, - // TODO(my-words): bridge to Apps Script (selectPhrase + replaceSelection for - // str_replace; insertTextAtCursor for insert). The GDocs multi-tab corpus - // (getAllTabs) is the exciting follow-up. Deferred — v1 targets standalone. - applyEdit(_edit: DocEdit): Promise { - return Promise.reject( - new Error('applyEdit is not implemented for Google Docs yet'), - ); - }, + // No `applyEdit` yet, so edit tools are not offered here (#590). TODO: + // bridge to Apps Script (selectPhrase + replaceSelection for str_replace; + // insertTextAtCursor for insert). The GDocs multi-tab corpus (getAllTabs) + // is the exciting follow-up. }; /** diff --git a/frontend/src/contexts/editorContext.tsx b/frontend/src/contexts/editorContext.tsx index 7a2409d0..d57357c1 100644 --- a/frontend/src/contexts/editorContext.tsx +++ b/frontend/src/contexts/editorContext.tsx @@ -19,10 +19,6 @@ export const EditorContext = createContext({ }, getDocText: () => Promise.resolve(''), getParagraphs: () => Promise.resolve([]), - applyEdit: () => { - console.warn('applyEdit is not implemented yet'); - return Promise.resolve(); - }, // No host document to write into without a provider, so document settings // fall back to this browser. Real surfaces override this whole object. ...localStorageDocumentSettings('default'), diff --git a/frontend/src/docTools/__tests__/spliceAdapter.test.ts b/frontend/src/docTools/__tests__/spliceAdapter.test.ts index f68a4087..519a22e5 100644 --- a/frontend/src/docTools/__tests__/spliceAdapter.test.ts +++ b/frontend/src/docTools/__tests__/spliceAdapter.test.ts @@ -46,7 +46,7 @@ function primitiveHost(initial: string[]) { }, getParagraphs: (): Promise => Promise.resolve([...paragraphs]), - } as unknown as EditorAPI; + } as unknown as EditableEditorAPI; return { editor, current: () => paragraphs }; } diff --git a/frontend/src/docTools/editor.ts b/frontend/src/docTools/editor.ts index 7a447c6b..d6b65715 100644 --- a/frontend/src/docTools/editor.ts +++ b/frontend/src/docTools/editor.ts @@ -17,6 +17,11 @@ import { lowerOp } from './ops'; import type { EditOp } from './types'; +/** Does this host apply edits? Hosts that can't leave `applyEdit` out. */ +export function canEdit(editor: EditorAPI): editor is EditableEditorAPI { + return editor.applyEdit !== undefined; +} + /** Thrown when the writer cancels an edit during the reveal beat. */ export class EditVetoed extends Error { constructor() { @@ -91,7 +96,7 @@ export async function vetoWindow( * that were applied, so callers can capture their inverses for undo. */ export async function applyEditOp( - editor: EditorAPI, + editor: EditableEditorAPI, op: EditOp, opts: ApplyEditOptions = {}, ): Promise { @@ -167,7 +172,7 @@ export function minimalReplace( * shrinkage via delete_paragraph). */ export async function applySpliceToEditor( - editor: EditorAPI, + editor: EditableEditorAPI, splice: ParagraphSplice, ): Promise { if (editor.applySplice) { diff --git a/frontend/src/docTools/report.ts b/frontend/src/docTools/report.ts index e42ffdde..3487a1ba 100644 --- a/frontend/src/docTools/report.ts +++ b/frontend/src/docTools/report.ts @@ -145,7 +145,7 @@ function targetParagraph(op: EditOp): number | undefined { * re-orient and retry instead of flying blind. */ export async function applyOpAndReport( - editor: EditorAPI, + editor: EditableEditorAPI, op: EditOp, ): Promise<{ ok: boolean; report: string }> { try { diff --git a/frontend/src/pages/my-words/index.tsx b/frontend/src/pages/my-words/index.tsx index b707d447..f9a834c4 100644 --- a/frontend/src/pages/my-words/index.tsx +++ b/frontend/src/pages/my-words/index.tsx @@ -2,6 +2,7 @@ import { useCallback, useContext, useEffect, useRef, useState } from 'react'; import { languageModel } from '@/api/openai'; import { EditorContext } from '@/contexts/editorContext'; +import { canEdit } from '@/docTools/editor'; import { buildCorpus } from '@/docTools/corpus'; import { InteractionPanel } from './InteractionPanel'; @@ -131,7 +132,11 @@ export default function MyWords() { ))}
- {tab === 'voice' ? ( + {!canEdit(editor) ? ( +

+ My Words can’t edit documents in this editor yet. +

+ ) : tab === 'voice' ? ( void; }) { diff --git a/frontend/src/pages/my-words/interaction/types.ts b/frontend/src/pages/my-words/interaction/types.ts index 7e8e9756..18956e2b 100644 --- a/frontend/src/pages/my-words/interaction/types.ts +++ b/frontend/src/pages/my-words/interaction/types.ts @@ -81,7 +81,7 @@ export type TurnInput = /** Everything a strategy needs to run a turn, without owning React state. */ export interface StrategyContext { - editor: EditorAPI; + editor: EditableEditorAPI; responder: Responder; /** Build the live word-bank to validate edits against. */ corpus: () => Promise; diff --git a/frontend/src/pages/my-words/interaction/useInteraction.ts b/frontend/src/pages/my-words/interaction/useInteraction.ts index 4d481005..48433f76 100644 --- a/frontend/src/pages/my-words/interaction/useInteraction.ts +++ b/frontend/src/pages/my-words/interaction/useInteraction.ts @@ -19,7 +19,7 @@ import type { } from './types'; export interface UseInteractionOptions { - editor: EditorAPI; + editor: EditableEditorAPI; strategy: InteractionStrategy; responder: Responder; corpus: () => Promise; diff --git a/frontend/src/pages/my-words/voice/VoiceSession.tsx b/frontend/src/pages/my-words/voice/VoiceSession.tsx index 6706a671..584f8a67 100644 --- a/frontend/src/pages/my-words/voice/VoiceSession.tsx +++ b/frontend/src/pages/my-words/voice/VoiceSession.tsx @@ -45,7 +45,7 @@ const clipLabel = (s: string, n = 30) => s.length > n ? `${s.slice(0, n - 1)}…` : s; export function VoiceSession(props: { - editor: EditorAPI; + editor: EditableEditorAPI; scratchpad: string; onScratchpadChange: (v: string) => void; }) { diff --git a/frontend/src/pages/my-words/voice/session.ts b/frontend/src/pages/my-words/voice/session.ts index b012d0fb..1a54581d 100644 --- a/frontend/src/pages/my-words/voice/session.ts +++ b/frontend/src/pages/my-words/voice/session.ts @@ -94,7 +94,7 @@ type Target = 'document' | 'scratchpad'; export interface VoiceSessionOptions { /** The real editor host (from EditorContext). */ - editor: EditorAPI; + editor: EditableEditorAPI; /** Builds the live word-bank; must include the writer's rolling transcript. */ corpus: () => Promise; /** diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts index 729ff9bd..a1024ae9 100644 --- a/frontend/src/types.d.ts +++ b/frontend/src/types.d.ts @@ -89,8 +89,12 @@ interface EditorAPI { * system the `view` tool numbers and paragraph-targeted inserts index into. */ getParagraphs(this: void): Promise; - /** Apply a validated edit to the document. */ - applyEdit(this: void, edit: DocEdit): Promise; + /** + * Apply a validated edit to the document. Absent on hosts that can't edit + * (Google Docs, until #590), so callers offer edit tools only when it's there — + * see `canEdit` in `docTools/editor.ts`. + */ + applyEdit?(this: void, edit: DocEdit): Promise; /** * Apply a paragraph-range splice natively, in one atomic step (one undo * entry, cursor preserved). Hosts without it get splices expressed as a @@ -107,6 +111,9 @@ interface EditorAPI { setDocumentSetting: (key: string, value: string) => Promise; } +/** An editor host that can apply edits. Narrow to it with `canEdit`. */ +type EditableEditorAPI = EditorAPI & Required>; + interface ReflectionResponseItem { reflection: string; } From 87beae5b826cb7d36af6f9bb23629c95f8972de6 Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Sat, 31 Oct 2026 16:41:10 -0400 Subject: [PATCH 04/13] extend our custom stream functions to handle tools It was yucky that we had to have these custom wrappers, but we already litigated that and ended up with https://github.com/vercel/ai/issues/17974 --- frontend/src/api/__tests__/generate.test.ts | 74 ++++++++++++++++++++- frontend/src/api/generate.ts | 47 +++++++++---- 2 files changed, 108 insertions(+), 13 deletions(-) diff --git a/frontend/src/api/__tests__/generate.test.ts b/frontend/src/api/__tests__/generate.test.ts index 0966d6a3..6f003640 100644 --- a/frontend/src/api/__tests__/generate.test.ts +++ b/frontend/src/api/__tests__/generate.test.ts @@ -1,8 +1,10 @@ import { MockLanguageModelV4 } from 'ai/test'; import { convertArrayToReadableStream } from '@ai-sdk/provider-utils/test'; +import { tool } from 'ai'; import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; import { GenerationError } from '../errors'; -import { generateFullText, streamTextDeltas } from '../generate'; +import { generateFullText, streamParts, streamTextDeltas } from '../generate'; /** * The stream-part shape the installed `ai` expects, read off the mock instead of @@ -71,6 +73,11 @@ const FINISH: StreamPart = { }, }; +const TOOL_CALL_FINISH: StreamPart = { + ...FINISH, + finishReason: { unified: 'tool-calls', raw: undefined }, +}; + async function collect(deltas: AsyncIterable): Promise { const out: string[] = []; for await (const delta of deltas) out.push(delta); @@ -182,3 +189,68 @@ describe('generateFullText', () => { consoleError.mockRestore(); }); }); + +describe('streamParts', () => { + it('yields tool calls and returns the response messages', async () => { + const parts = streamParts({ + model: modelStreaming([ + ...textParts('Let me look.'), + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'view', + input: '{}', + }, + TOOL_CALL_FINISH, + ]), + // No `execute`: the loop stops at the call, as a staged edit would. + tools: { view: tool({ inputSchema: z.object({}) }) }, + prompt: 'hi', + }); + + const types: string[] = []; + let next = await parts.next(); + while (!next.done) { + types.push(next.value.type); + next = await parts.next(); + } + + expect(types).toContain('tool-call'); + expect(next.value).toEqual([ + { + role: 'assistant', + content: [ + { type: 'text', text: 'Let me look.' }, + expect.objectContaining({ + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'view', + }), + ], + }, + ]); + }); + + it('throws a GenerationError instead of yielding an error part', async () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const parts = streamParts({ + model: modelStreaming([ + { type: 'stream-start', warnings: [] }, + { type: 'error', error: QUOTA_ERROR }, + FINISH, + ]), + prompt: 'hi', + }); + + const types: string[] = []; + await expect( + (async () => { + for await (const part of parts) types.push(part.type); + })(), + ).rejects.toThrow(GenerationError); + expect(types).not.toContain('error'); + consoleError.mockRestore(); + }); +}); diff --git a/frontend/src/api/generate.ts b/frontend/src/api/generate.ts index 8503d835..ff7d5dab 100644 --- a/frontend/src/api/generate.ts +++ b/frontend/src/api/generate.ts @@ -9,15 +9,22 @@ * surrounding `try/catch` never runs. That is how a quota failure reached the * writer as a blank panel. * - * {@link streamTextDeltas} reads `fullStream` instead, so it sees the `error` + * {@link streamParts} reads the full `stream` instead, so it sees the `error` * part and throws a {@link GenerationError} the moment one arrives. Pages should * use these helpers rather than calling `streamText` directly; a page that calls * `streamText` itself silently reintroduces the blank-panel bug. */ -import { streamText } from 'ai'; +import { + streamText, + type ModelMessage, + type TextStreamPart, + type ToolSet, +} from 'ai'; import { describeGenerationError, GenerationError } from './errors'; -type StreamTextOptions = Parameters[0]; +type StreamTextOptions = Parameters< + typeof streamText +>[0]; /** Wrap whatever the stream (or the transport) failed with. */ function asGenerationError(raw: unknown): GenerationError { @@ -27,28 +34,44 @@ function asGenerationError(raw: unknown): GenerationError { } /** - * Stream a generation, yielding text deltas as they arrive. + * Stream a generation, yielding every part as it arrives — text deltas, tool + * calls and results, approval requests, step boundaries; never an `error` + * part, which is thrown instead — and returning the response messages + * (assistant and tool messages, across all steps) for the caller to keep as + * transcript. For callers that only want text, see {@link streamTextDeltas}. * * Throws a {@link GenerationError} as soon as the model or transport fails — - * including mid-stream, after deltas have already been yielded, so a caller that - * renders partial text keeps what arrived and can show the error alongside it. + * including mid-stream, after parts have already been yielded, so a caller that + * renders partial output keeps what arrived and can show the error alongside it. * Aborting via `abortSignal` ends the iteration; callers that abort on purpose * should check their own signal before treating a throw as a real failure. */ -export async function* streamTextDeltas( - options: StreamTextOptions, -): AsyncGenerator { +export async function* streamParts( + options: StreamTextOptions, +): AsyncGenerator, ModelMessage[], void> { const result = streamText(options); // `stream`, not `textStream`: the latter drops `error` parts on the floor. // (`stream` is ai@7's name for what used to be `fullStream`, which is still // there as a deprecated alias.) for await (const part of result.stream) { - if (part.type === 'text-delta') { - yield part.text; - } else if (part.type === 'error') { + if (part.type === 'error') { // Breaking out of the loop cancels the underlying stream. throw asGenerationError(part.error); } + yield part; + } + return await result.responseMessages; +} + +/** + * Stream a generation, yielding text deltas as they arrive. Same error + * behaviour as {@link streamParts}, which it filters. + */ +export async function* streamTextDeltas( + options: StreamTextOptions, +): AsyncGenerator { + for await (const part of streamParts(options)) { + if (part.type === 'text-delta') yield part.text; } } From f0bbf3483aa6c7533907c34bf084be8dfef0df89 Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Sat, 31 Oct 2026 16:55:39 -0400 Subject: [PATCH 05/13] Refactor the Chat page to have a conversation with tools, and stop overwriting the initial message. --- frontend/CLAUDE.md | 6 +- frontend/src/contexts/chatContext.tsx | 12 +- .../chat/__tests__/docContextMessage.test.ts | 94 --------- .../pages/chat/__tests__/transcript.test.ts | 150 ++++++++++++++ frontend/src/pages/chat/index.tsx | 189 ++++++++---------- frontend/src/pages/chat/transcript.ts | 120 +++++++++++ 6 files changed, 359 insertions(+), 212 deletions(-) delete mode 100644 frontend/src/pages/chat/__tests__/docContextMessage.test.ts create mode 100644 frontend/src/pages/chat/__tests__/transcript.test.ts create mode 100644 frontend/src/pages/chat/transcript.ts diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 4d44ad0a..2c13a389 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -126,9 +126,9 @@ prompt or messages fields. Use the instructions option instead.` — which reach the writer as a generation error on every request. Pass the system prompt as the `instructions` option (a string) alongside `messages`; the SDK puts it back at the head of the prompt itself. This bites hardest where a page keeps a -conversation in state: the chat transcript (`pages/chat`) holds only the -doc-context, greeting, user, and assistant messages, with `CHAT_INSTRUCTIONS` -kept outside it. +conversation in state: the chat transcript (`pages/chat`, see +`transcript.ts`) holds only user, assistant, and tool messages, with +`CHAT_INSTRUCTIONS` kept outside it. ### Rendering model output diff --git a/frontend/src/contexts/chatContext.tsx b/frontend/src/contexts/chatContext.tsx index c06d780d..67f80d4a 100644 --- a/frontend/src/contexts/chatContext.tsx +++ b/frontend/src/contexts/chatContext.tsx @@ -1,3 +1,4 @@ +import type { ModelMessage } from 'ai'; import { type PropsWithChildren, createContext, @@ -5,17 +6,18 @@ import { useMemo, } from 'react'; -// Stores chat messages in a context so that they are saved when a user switches between tabs +// Stores the chat transcript in a context so that it survives switching tabs. +// It's the model's own history (tool calls included); see pages/chat/transcript.ts. export const ChatContext = createContext<{ - chatMessages: ChatMessage[]; - updateChatMessages: (chatMessages: ChatMessage[]) => void; + chatMessages: ModelMessage[]; + updateChatMessages: (chatMessages: ModelMessage[]) => void; }>({ chatMessages: [], - updateChatMessages: (_chatMessages: ChatMessage[]) => {}, + updateChatMessages: (_chatMessages: ModelMessage[]) => {}, }); export default function ChatContextWrapper({ children }: PropsWithChildren) { - const [chatMessages, updateChatMessages] = useState([]); + const [chatMessages, updateChatMessages] = useState([]); const contextValue = useMemo( () => ({ diff --git a/frontend/src/pages/chat/__tests__/docContextMessage.test.ts b/frontend/src/pages/chat/__tests__/docContextMessage.test.ts deleted file mode 100644 index e4fbdb54..00000000 --- a/frontend/src/pages/chat/__tests__/docContextMessage.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { docContextMessageContent, withCurrentDocContext } from '../index'; - -describe('chat document-context message', () => { - describe('docContextMessageContent', () => { - it('marks the cursor position when nothing is selected', () => { - const content = docContextMessageContent({ - beforeCursor: 'Hello ', - selectedText: '', - afterCursor: 'world', - }); - - expect(content).toContain('<>'); - expect(content).not.toContain('<>'); - expect(content).toContain('Hello <>world'); - }); - - it('wraps the selection when text is selected', () => { - const content = docContextMessageContent({ - beforeCursor: 'Hello ', - selectedText: 'big', - afterCursor: ' world', - }); - - expect(content).toContain('<>big<>'); - expect(content).not.toContain('<>'); - }); - }); - - describe('withCurrentDocContext', () => { - const docContext: DocContext = { - beforeCursor: 'a', - selectedText: '', - afterCursor: 'b', - }; - - it('seeds doc-context + greeting when the chat is empty', () => { - const result = withCurrentDocContext([], docContext); - - expect(result).toHaveLength(2); - expect(result[0].role).toBe('user'); - expect(result[0].content).toBe( - docContextMessageContent(docContext), - ); - expect(result[1].role).toBe('assistant'); - }); - - it('replaces only the doc-context message (index 0) on an existing chat', () => { - const existing: ChatMessage[] = [ - { role: 'user', content: 'STALE CONTEXT' }, - { role: 'assistant', content: 'greeting' }, - { role: 'user', content: 'a real question' }, - ]; - - const result = withCurrentDocContext(existing, docContext); - - // Fresh context injected at index 0... - expect(result[0].content).toBe( - docContextMessageContent(docContext), - ); - // ...without disturbing the rest of the conversation. - expect(result[1]).toEqual(existing[1]); - expect(result[2]).toEqual(existing[2]); - }); - - // ai@7 throws InvalidPromptError ("System messages are not allowed in - // the prompt or messages fields") when a system message reaches - // `messages`; the chat's system prompt goes in `instructions` instead. - it('never puts a system message in the transcript', () => { - const seeded = withCurrentDocContext([], docContext); - const continued = withCurrentDocContext( - [...seeded, { role: 'user', content: 'a real question' }], - docContext, - ); - - expect( - [...seeded, ...continued].some((m) => m.role === 'system'), - ).toBe(false); - }); - - it('does not mutate the input array', () => { - const existing: ChatMessage[] = [ - { role: 'user', content: 'STALE CONTEXT' }, - { role: 'assistant', content: 'greeting' }, - ]; - const snapshot = structuredClone(existing); - - withCurrentDocContext(existing, docContext); - - expect(existing).toEqual(snapshot); - }); - }); -}); diff --git a/frontend/src/pages/chat/__tests__/transcript.test.ts b/frontend/src/pages/chat/__tests__/transcript.test.ts new file mode 100644 index 00000000..d725d6ef --- /dev/null +++ b/frontend/src/pages/chat/__tests__/transcript.test.ts @@ -0,0 +1,150 @@ +import type { ModelMessage } from 'ai'; +import { describe, expect, it } from 'vitest'; + +import { + cursorWindow, + displayTranscript, + documentContext, + writerTurn, +} from '../transcript'; + +describe('cursorWindow', () => { + it('marks the cursor within its paragraph only', () => { + expect( + cursorWindow({ + beforeCursor: 'First paragraph.\nHello ', + selectedText: '', + afterCursor: 'world.\nThird paragraph.', + }), + ).toBe('Hello <>world.'); + }); + + it('wraps the selection', () => { + expect( + cursorWindow({ + beforeCursor: 'Hello ', + selectedText: 'big', + afterCursor: ' world', + }), + ).toBe('Hello <>big<> world'); + }); + + it('clips a long paragraph on each side', () => { + const window = cursorWindow( + { + beforeCursor: 'a'.repeat(50), + selectedText: '', + afterCursor: 'b'.repeat(50), + }, + 10, + ); + expect(window).toBe(`…${'a'.repeat(10)}<>${'b'.repeat(10)}…`); + }); +}); + +describe('documentContext', () => { + const docContext: DocContext = { + beforeCursor: 'One.\nTw', + selectedText: '', + afterCursor: 'o.', + }; + + it('includes the numbered document only when given paragraphs', () => { + const first = documentContext({ + docContext, + brief: null, + paragraphs: ['One.', 'Two.'], + }); + expect(first).toContain('[1] One.\n[2] Two.'); + expect(first).toContain('Tw<>o.'); + + const later = documentContext({ + docContext, + brief: null, + paragraphs: null, + }); + expect(later).not.toContain('[1]'); + expect(later).toContain('Tw<>o.'); + }); + + it('leads with the brief when there is one', () => { + const context = documentContext({ + docContext, + brief: 'Audience: reviewers', + paragraphs: null, + }); + expect(context).toMatch(/^\nAudience: reviewers/); + }); + + it('omits the cursor window for an empty document', () => { + const context = documentContext({ + docContext: { beforeCursor: '', selectedText: '', afterCursor: '' }, + brief: null, + paragraphs: [''], + }); + expect(context).not.toContain('<>'); + expect(context).toContain('(the document is empty)'); + }); +}); + +describe('displayTranscript', () => { + it('shows what the writer typed, not the attached context', () => { + expect( + displayTranscript([writerTurn('…', 'Hi')]), + ).toEqual([{ role: 'user', text: 'Hi' }]); + }); + + it('joins one reply across a tool call into one bubble', () => { + const messages: ModelMessage[] = [ + writerTurn('…', 'Is my intro clear?'), + { + role: 'assistant', + content: [ + { type: 'text', text: 'Let me look.' }, + { + type: 'tool-call', + toolCallId: 'c1', + toolName: 'view', + input: {}, + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'c1', + toolName: 'view', + output: { type: 'text', value: '[1] Intro.' }, + }, + ], + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Mostly, yes.' }], + }, + ]; + expect(displayTranscript(messages)).toEqual([ + { role: 'user', text: 'Is my intro clear?' }, + { role: 'assistant', text: 'Let me look.\n\nMostly, yes.' }, + ]); + }); + + it('skips an assistant step that only called a tool', () => { + const messages: ModelMessage[] = [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'c1', + toolName: 'view', + input: {}, + }, + ], + }, + ]; + expect(displayTranscript(messages)).toEqual([]); + }); +}); diff --git a/frontend/src/pages/chat/index.tsx b/frontend/src/pages/chat/index.tsx index e773e86d..f1bab464 100644 --- a/frontend/src/pages/chat/index.tsx +++ b/frontend/src/pages/chat/index.tsx @@ -1,4 +1,4 @@ -import { type ModelMessage } from 'ai'; +import { isStepCount, tool } from 'ai'; import { useCallback, useContext, @@ -17,7 +17,7 @@ import { describeGenerationError, type GenerationErrorInfo, } from '@/api/errors'; -import { streamTextDeltas } from '@/api/generate'; +import { streamParts } from '@/api/generate'; import { chatLog } from '@/api/logging'; import { languageModel, openaiProviderOptions } from '@/api/openai'; import { GenerationErrorNotice } from '@/components/errorNotice'; @@ -33,9 +33,13 @@ import { useDocBrief, } from '@/contexts/docBriefContext'; import { EditorContext } from '@/contexts/editorContext'; +import { viewText } from '@/docTools/report'; import { useLog } from '@/hooks/useLog'; import { useDocContext } from '@/utilities'; +import { z } from 'zod'; + import classes from './styles.module.css'; +import { displayTranscript, documentContext, writerTurn } from './transcript'; const suggestionPrompts = [ 'What is my main argument?', @@ -51,64 +55,14 @@ const suggestionPrompts = [ * in the prompt or messages fields." */ const CHAT_INSTRUCTIONS = `\ -Help the user improve their writing. Encourage the user towards critical thinking and self-reflection. Be concise. If the user mentions "here" or "this", assume they are referring to the area near the cursor or selection. +Help the user improve their writing. Encourage the user towards critical thinking and self-reflection. Be concise. -When referring to a specific part of the document, link to it so the user can jump straight there, using a Markdown link whose target is a doctext URL: [second paragraph of the intro](doctext:A%20short%20quote). The link target must start with "doctext:" and be a URL-component-encoded verbatim quote from the document — from a single line, at most 240 characters, with no surrounding quotation marks. The link text should be a short description of the place, not the quote itself. Only link to text that is actually in the document; quote it exactly.`; +Each of the user's messages starts with a block that the app attaches; the user doesn't see it and didn't write it. On their first message it holds the whole document, with paragraphs numbered like [3]; on every message it shows the text around their cursor or selection, marked with <> or <> tags. If the user mentions "here" or "this", assume they mean that area. The document may change between messages: call \`view\` to re-read it when you need the current text. The paragraph numbers are for your tools; the user doesn't see them, so mentioning them might confuse the user. -const CHAT_GREETING_MESSAGE: ChatMessage = { - role: 'assistant', - content: 'What do you think about your document so far?', -}; +When referring to a specific part of the document, link to it so the user can jump straight there, using a Markdown link whose target is a doctext URL: [second paragraph of the intro](doctext:A%20short%20quote). The link target must start with "doctext:" and be a URL-component-encoded verbatim quote from the document — from a single line, at most 240 characters, with no surrounding quotation marks or paragraph numbers. The link text should be a short description of the place, not the quote itself. Only link to text that is actually in the document; quote it exactly.`; -/** - * The messages `withCurrentDocContext` seeds ahead of the real conversation: - * the document context, then the greeting. They're part of what the model - * reads but not part of what the writer sees, so the transcript is rendered - * from this offset on. - */ -const SEEDED_MESSAGE_COUNT = 2; - -/** - * Renders the document context into the message the model reads. - * - * `brief` is the writer's document brief, already prompt-formatted (null when - * they haven't set one). It rides along with the document context because both - * describe the document rather than the turn, and both are refreshed together - * on every send. - */ -export function docContextMessageContent( - docContext: DocContext, - brief: string | null = null, -): string { - const document = - docContext.selectedText === '' - ? `Here is my document, with the current cursor position marked with <>:\n\n${docContext.beforeCursor}${docContext.selectedText}<>${docContext.afterCursor}` - : `Here is my document, with the current selection marked with <> tags:\n\n${docContext.beforeCursor}<>${docContext.selectedText}<>${docContext.afterCursor}`; - return brief ? `${brief}\n\n${document}` : document; -} - -/** - * Builds the base conversation with the freshest document context as its first - * message. When the chat is empty it seeds the doc-context + greeting - * messages; otherwise it replaces the existing doc-context message so the - * model always sees the current document state at send time. - */ -export function withCurrentDocContext( - chatMessages: ChatMessage[], - docContext: DocContext, - brief: string | null = null, -): ChatMessage[] { - const docContextMessage: ChatMessage = { - role: 'user', - content: docContextMessageContent(docContext, brief), - }; - if (chatMessages.length === 0) { - return [docContextMessage, CHAT_GREETING_MESSAGE]; - } - const updated = chatMessages.slice(); - updated[0] = docContextMessage; - return updated; -} +/** Runaway cap on model steps (each tool call is a step) per writer turn. */ +const MAX_STEPS = 6; export default function Chat() { const { chatMessages, updateChatMessages } = useContext(ChatContext); @@ -176,31 +130,20 @@ export default function Chat() { const { refresh: refreshDocContext } = useDocContext(editorAPI); const docJump = useDocJump('chat'); - // Seed the conversation once (system + doc-context + greeting) when empty. - // The document context is pulled here and refreshed again at send time, so - // there's no need to track selection changes continuously. - useEffect(() => { - if (chatMessages.length !== 0) return; - void (async () => { - const docContext = await refreshDocContext(); - updateChatMessages( - withCurrentDocContext( - [], - docContext, - formatDocBriefForPrompt(briefRef.current), - ), - ); - })(); - }, [chatMessages.length, refreshDocContext, updateChatMessages]); - const [isSendingMessage, updateSendingMessage] = useState(false); const [message, updateMessage] = useState(''); - const visibleMessages = - chatMessages.length > SEEDED_MESSAGE_COUNT - ? chatMessages.slice(SEEDED_MESSAGE_COUNT) - : []; + /** + * The reply streaming in, shown as a bubble after the transcript until the + * turn finishes and its messages join the transcript. Null when idle. + */ + const [streamingText, setStreamingText] = useState(null); + + const visibleMessages = displayTranscript(chatMessages); + if (streamingText !== null) { + visibleMessages.push({ role: 'assistant', text: streamingText }); + } const resizeTextarea = useCallback(() => { const textarea = textareaRef.current; @@ -231,41 +174,60 @@ export default function Chat() { setErrorInfo(null); setFailedMessage(null); - // Pull the current document context at send time so the model sees the - // document as it is now, then inject it as the doc-context message. + // Pull the current document context at send time. The first turn also + // carries the whole document, numbered as `view` numbers it. const docContext = await refreshDocContext(); - let newMessages = [ - ...withCurrentDocContext( - chatMessages, - docContext, - formatDocBriefForPrompt(briefRef.current), - ), - { role: 'user', content: text }, - { role: 'assistant', content: '' }, - ]; - - updateChatMessages(newMessages); + const context = documentContext({ + docContext, + brief: formatDocBriefForPrompt(briefRef.current), + paragraphs: + chatMessages.length === 0 + ? await editorAPI.getParagraphs() + : null, + }); + const withTurn = [...chatMessages, writerTurn(context, text)]; + + updateChatMessages(withTurn); + setStreamingText(''); setShowScrollButton(false); updateMessage(''); + let streamed = ''; try { - const deltas = streamTextDeltas({ + const parts = streamParts({ model: languageModel, providerOptions: openaiProviderOptions, instructions: CHAT_INSTRUCTIONS, - messages: newMessages.slice(0, -1) as ModelMessage[], + messages: withTurn, + tools: { + view: tool({ + description: + 'Read the current document, paragraphs numbered like [3].', + inputSchema: z.object({}), + execute: () => viewText(editorAPI), + }), + }, + stopWhen: isStepCount(MAX_STEPS), abortSignal: requestController.signal, }); - for await (const delta of deltas) { - // Need to make a new object to force React to update. - newMessages = newMessages.slice(); - newMessages[newMessages.length - 1].content += delta; - updateChatMessages(newMessages); + let next = await parts.next(); + while (!next.done) { + const part = next.value; + if (part.type === 'text-delta') { + streamed += part.text; + setStreamingText(streamed); + } else if (part.type === 'text-start' && streamed !== '') { + // A new text block after a tool call: keep it a paragraph apart, + // as the finished transcript renders it. + streamed += '\n\n'; + } + next = await parts.next(); } + if (requestController.signal.aborted) return; + updateChatMessages([...withTurn, ...next.value]); chatLog.responseCompleted(log, { - responseLength: - newMessages[newMessages.length - 1].content.length, + responseLength: streamed.length, }); } catch (error) { if (requestController.signal.aborted) { @@ -273,14 +235,19 @@ export default function Chat() { } const info = describeGenerationError(error); console.error('Error while streaming chat response:', error); - // Nothing streamed: roll the turn back (empty assistant bubble + the - // user message it was answering) and hand the text back to the input - // box, so Retry re-sends it once rather than duplicating the turn. - // If part of a reply did arrive, keep it and just show the error under it. - if (newMessages[newMessages.length - 1].content === '') { - updateChatMessages(newMessages.slice(0, -2)); + // Nothing streamed: roll the turn back and hand the text back to the + // input box, so Retry re-sends it once rather than duplicating the + // turn. If part of a reply did arrive, keep it and just show the error + // under it. + if (streamed.trim() === '') { + updateChatMessages(chatMessages); updateMessage(text); setFailedMessage({ text, source }); + } else { + updateChatMessages([ + ...withTurn, + { role: 'assistant', content: streamed }, + ]); } setErrorInfo(info); chatLog.responseError(log, { @@ -292,6 +259,7 @@ export default function Chat() { if (activeRequestControllerRef.current === requestController) { activeRequestControllerRef.current = null; updateSendingMessage(false); + setStreamingText(null); } } } @@ -300,6 +268,7 @@ export default function Chat() { activeRequestControllerRef.current?.abort(); activeRequestControllerRef.current = null; updateSendingMessage(false); + setStreamingText(null); setErrorInfo(null); setFailedMessage(null); updateMessage(''); @@ -378,12 +347,12 @@ export default function Chat() { visibleMessages.map((chatMessage, index) => { const isAssistantTyping = chatMessage.role === 'assistant' && - chatMessage.content === '' && + chatMessage.text === '' && isSendingMessage; return (
{chatMessage.role === 'assistant' ? ( @@ -405,10 +374,10 @@ export default function Chat() { {chatMessage.role === 'assistant' ? ( - {chatMessage.content} + {chatMessage.text} ) : ( - chatMessage.content + chatMessage.text )}
)} diff --git a/frontend/src/pages/chat/transcript.ts b/frontend/src/pages/chat/transcript.ts new file mode 100644 index 00000000..5df89b0b --- /dev/null +++ b/frontend/src/pages/chat/transcript.ts @@ -0,0 +1,120 @@ +/** + * The Chat transcript: what the model reads (`ModelMessage[]`, tool calls and + * all) and how that turns into the bubbles the writer sees. + * + * Each writer turn is one user message with two text parts: first the + * `` block the app attaches (the brief, the document on the + * first turn, the text around the cursor), then what the writer typed. Keeping + * them in one message means the context is part of the history the model + * re-reads, appended rather than rewritten (#249), and the bubble shows only + * the last part. + */ + +import type { ModelMessage, UserModelMessage } from 'ai'; + +import { viewParagraphs } from '@/docTools/report'; + +/** How much of the cursor's paragraph to show on each side. */ +const CURSOR_RADIUS = 600; + +/** + * The paragraph the cursor (or selection) is in, clipped to `radius` + * characters on each side, with the cursor or selection marked. Enough for + * "here" and "this" to resolve; the model calls `view` for the rest. + */ +export function cursorWindow( + { beforeCursor, selectedText, afterCursor }: DocContext, + radius = CURSOR_RADIUS, +): string { + let before = beforeCursor.slice(beforeCursor.lastIndexOf('\n') + 1); + const lineEnd = afterCursor.indexOf('\n'); + let after = lineEnd === -1 ? afterCursor : afterCursor.slice(0, lineEnd); + if (before.length > radius) before = `…${before.slice(-radius)}`; + if (after.length > radius) after = `${after.slice(0, radius)}…`; + return selectedText === '' + ? `${before}<>${after}` + : `${before}<>${selectedText}<>${after}`; +} + +/** + * The `` block for one writer turn. `paragraphs` is the + * document snapshot, passed on the first turn only; later turns rely on the + * history and `view`. + */ +export function documentContext({ + docContext, + brief, + paragraphs, +}: { + docContext: DocContext; + /** Prompt-formatted brief, or null when the writer hasn't set one. */ + brief: string | null; + paragraphs: string[] | null; +}): string { + const parts: string[] = []; + if (brief) parts.push(brief); + if (paragraphs) { + parts.push( + `The document (paragraph numbers are for your tools only):\n${viewParagraphs(paragraphs)}`, + ); + } + const { beforeCursor, selectedText, afterCursor } = docContext; + if (`${beforeCursor}${selectedText}${afterCursor}`.trim() !== '') { + parts.push( + selectedText === '' + ? `Around the cursor:\n${cursorWindow(docContext)}` + : `The selection, in context:\n${cursorWindow(docContext)}`, + ); + } + return `\n${parts.join('\n\n')}\n`; +} + +/** A writer turn: the attached context, then what they typed. */ +export function writerTurn(context: string, text: string): UserModelMessage { + return { + role: 'user', + content: [ + { type: 'text', text: context }, + { type: 'text', text }, + ], + }; +} + +/** What the writer typed: the last text part of their turn. */ +function writerText(message: UserModelMessage): string { + if (typeof message.content === 'string') return message.content; + const texts = message.content.filter((p) => p.type === 'text'); + return texts[texts.length - 1]?.text ?? ''; +} + +export interface DisplayMessage { + role: 'user' | 'assistant'; + text: string; +} + +/** + * The bubbles for a transcript. Tool messages are skipped, and the assistant's + * text across the steps of one reply (text, a `view` call, more text) is + * joined into one bubble. + */ +export function displayTranscript(messages: ModelMessage[]): DisplayMessage[] { + const out: DisplayMessage[] = []; + for (const message of messages) { + if (message.role === 'user') { + out.push({ role: 'user', text: writerText(message) }); + } else if (message.role === 'assistant') { + const text = + typeof message.content === 'string' + ? message.content + : message.content + .filter((p) => p.type === 'text') + .map((p) => p.text) + .join(''); + if (text === '') continue; + const last = out[out.length - 1]; + if (last?.role === 'assistant') last.text += `\n\n${text}`; + else out.push({ role: 'assistant', text }); + } + } + return out; +} From 51e5eddc170572b06ebe8932cf40b8812c106f42 Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Sat, 31 Oct 2026 17:03:44 -0400 Subject: [PATCH 06/13] Add turn-by-turn edit notes --- frontend/src/contexts/chatContext.tsx | 14 ++- .../src/docTools/__tests__/editNote.test.ts | 65 ++++++++++ frontend/src/docTools/editNote.ts | 119 ++++++++++++++++++ .../pages/chat/__tests__/transcript.test.ts | 21 +++- frontend/src/pages/chat/index.tsx | 27 ++-- frontend/src/pages/chat/transcript.ts | 18 ++- 6 files changed, 246 insertions(+), 18 deletions(-) create mode 100644 frontend/src/docTools/__tests__/editNote.test.ts create mode 100644 frontend/src/docTools/editNote.ts diff --git a/frontend/src/contexts/chatContext.tsx b/frontend/src/contexts/chatContext.tsx index 67f80d4a..28cb70ce 100644 --- a/frontend/src/contexts/chatContext.tsx +++ b/frontend/src/contexts/chatContext.tsx @@ -11,20 +11,32 @@ import { export const ChatContext = createContext<{ chatMessages: ModelMessage[]; updateChatMessages: (chatMessages: ModelMessage[]) => void; + /** + * The document's paragraphs as the model last saw them (the first-turn + * snapshot, the latest `view`), which the next turn's edit note is diffed + * against. Null before the first turn. + */ + docBaseline: string[] | null; + setDocBaseline: (paragraphs: string[] | null) => void; }>({ chatMessages: [], updateChatMessages: (_chatMessages: ModelMessage[]) => {}, + docBaseline: null, + setDocBaseline: (_paragraphs: string[] | null) => {}, }); export default function ChatContextWrapper({ children }: PropsWithChildren) { const [chatMessages, updateChatMessages] = useState([]); + const [docBaseline, setDocBaseline] = useState(null); const contextValue = useMemo( () => ({ chatMessages, updateChatMessages, + docBaseline, + setDocBaseline, }), - [chatMessages], + [chatMessages, docBaseline], ); return ( diff --git a/frontend/src/docTools/__tests__/editNote.test.ts b/frontend/src/docTools/__tests__/editNote.test.ts new file mode 100644 index 00000000..f334e352 --- /dev/null +++ b/frontend/src/docTools/__tests__/editNote.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { diffParagraphs, editNote } from '../editNote'; + +describe('diffParagraphs', () => { + it('finds nothing when the document is unchanged', () => { + expect(diffParagraphs(['A.', 'B.'], ['A.', 'B.'])).toEqual([]); + }); + + it('pairs a removal and an addition in one place as a rewording', () => { + expect(diffParagraphs(['A.', 'B.', 'C.'], ['A.', 'B!', 'C.'])).toEqual([ + { kind: 'reworded', n: 2, old: 'B.', text: 'B!' }, + ]); + }); + + it('numbers an addition by its place in the current document', () => { + expect(diffParagraphs(['A.', 'C.'], ['A.', 'B.', 'C.'])).toEqual([ + { kind: 'added', n: 2, text: 'B.' }, + ]); + }); + + it('anchors a removal to the paragraph before it', () => { + expect(diffParagraphs(['A.', 'B.', 'C.'], ['A.', 'C.'])).toEqual([ + { kind: 'removed', after: 1, old: 'B.' }, + ]); + expect(diffParagraphs(['A.', 'B.'], ['B.'])).toEqual([ + { kind: 'removed', after: 0, old: 'A.' }, + ]); + }); + + it('ignores blank paragraphs but keeps real numbering', () => { + expect(diffParagraphs(['A.', 'B.'], ['A.', '', 'B.', 'C.'])).toEqual([ + { kind: 'added', n: 4, text: 'C.' }, + ]); + }); + + it('reads a moved paragraph as a removal plus an addition', () => { + const changes = diffParagraphs(['A.', 'B.', 'C.'], ['B.', 'C.', 'A.']); + expect(changes).toEqual([ + { kind: 'removed', after: 0, old: 'A.' }, + { kind: 'added', n: 3, text: 'A.' }, + ]); + }); +}); + +describe('editNote', () => { + it('is null when nothing changed', () => { + expect(editNote(['A.'], ['A.'])).toBeNull(); + }); + + it('lists each change on its own line', () => { + const note = editNote(['A.', 'B.', 'C.'], ['A.', 'B!', 'C.', 'D.']); + expect(note).toContain('[2] reworded: "B!" (was "B.")'); + expect(note).toContain('[4] added: "D."'); + }); + + it('falls back to "call view" past the size cap', () => { + const long = (c: string) => c.repeat(300); + const note = editNote( + ['a', 'b', 'c', 'd', 'e', 'f'].map(long), + ['g', 'h', 'i', 'j', 'k', 'l'].map(long), + ); + expect(note).toMatch(/substantially; call `view`/); + }); +}); diff --git a/frontend/src/docTools/editNote.ts b/frontend/src/docTools/editNote.ts new file mode 100644 index 00000000..cd0a206d --- /dev/null +++ b/frontend/src/docTools/editNote.ts @@ -0,0 +1,119 @@ +/** + * The edit note: what the writer changed since the model last saw the + * document, so it hears about their edits without having to `view` (#583). + * + * A paragraph-level diff, deliberately simple for now: reworded paragraphs + * show their new text (and a clip of the old, which the model has seen + * earlier in the conversation), additions show their text, removals a clip. + * Moved paragraphs read as a removal plus an addition. Paragraph numbers are + * the current document's, the same coordinates `view` and the edit tools use. + */ + +/** How much of a paragraph's new and old text to show. */ +const NEW_CLIP = 400; +const OLD_CLIP = 80; +/** Past this many characters, the note just says to `view`. */ +const NOTE_CAP = 1500; + +const clip = (s: string, n: number) => + s.length > n ? `${s.slice(0, n - 1)}…` : s; + +type Para = { n: number; text: string }; + +/** Non-blank paragraphs with their real 1-based numbers (as `view` shows them). */ +function numbered(paragraphs: string[]): Para[] { + return paragraphs + .map((text, i) => ({ n: i + 1, text })) + .filter((p) => p.text.trim() !== ''); +} + +type Change = + | { kind: 'reworded'; n: number; old: string; text: string } + | { kind: 'added'; n: number; text: string } + | { kind: 'removed'; after: number; old: string }; + +/** Paragraph diff via LCS, with adjacent removals and additions paired up. */ +export function diffParagraphs(before: string[], after: string[]): Change[] { + const a = numbered(before); + const b = numbered(after); + // lcs[i][j] = LCS length of a[i:] and b[j:]. + const lcs = Array.from({ length: a.length + 1 }, () => + new Array(b.length + 1).fill(0), + ); + for (let i = a.length - 1; i >= 0; i--) { + for (let j = b.length - 1; j >= 0; j--) { + lcs[i][j] = + a[i].text === b[j].text + ? lcs[i + 1][j + 1] + 1 + : Math.max(lcs[i + 1][j], lcs[i][j + 1]); + } + } + + const changes: Change[] = []; + let removed: Para[] = []; + let added: Para[] = []; + // The current paragraph a removal sits after (0: the top). + let anchor = 0; + const flush = () => { + const paired = Math.min(removed.length, added.length); + for (let k = 0; k < paired; k++) { + changes.push({ + kind: 'reworded', + n: added[k].n, + old: removed[k].text, + text: added[k].text, + }); + } + for (const p of added.slice(paired)) { + changes.push({ kind: 'added', n: p.n, text: p.text }); + } + for (const p of removed.slice(paired)) { + changes.push({ kind: 'removed', after: anchor, old: p.text }); + } + removed = []; + added = []; + }; + + let i = 0; + let j = 0; + while (i < a.length || j < b.length) { + if (i < a.length && j < b.length && a[i].text === b[j].text) { + flush(); + anchor = b[j].n; + i++; + j++; + } else if ( + j < b.length && + (i === a.length || lcs[i][j + 1] >= lcs[i + 1][j]) + ) { + added.push(b[j++]); + } else { + removed.push(a[i++]); + } + } + flush(); + return changes; +} + +/** + * The note for the model, or null when nothing changed. `before` is what the + * model last saw; `after` is the document now. + */ +export function editNote(before: string[], after: string[]): string | null { + const changes = diffParagraphs(before, after); + if (changes.length === 0) return null; + const lines = changes.map((c) => { + if (c.kind === 'reworded') { + return `[${c.n}] reworded: "${clip(c.text, NEW_CLIP)}" (was "${clip(c.old, OLD_CLIP)}")`; + } + if (c.kind === 'added') { + return `[${c.n}] added: "${clip(c.text, NEW_CLIP)}"`; + } + const where = c.after === 0 ? 'at the top' : `after [${c.after}]`; + return `removed ${where}: "${clip(c.old, OLD_CLIP)}"`; + }); + const note = `Since you last saw the document, the writer changed it (current paragraph numbers):\n${lines.join('\n')}`; + return note.length > NOTE_CAP + ? 'Since you last saw the document, the writer revised it substantially; call `view` to see it, if needed.' + : note; +} diff --git a/frontend/src/pages/chat/__tests__/transcript.test.ts b/frontend/src/pages/chat/__tests__/transcript.test.ts index d725d6ef..7e8bfd10 100644 --- a/frontend/src/pages/chat/__tests__/transcript.test.ts +++ b/frontend/src/pages/chat/__tests__/transcript.test.ts @@ -49,11 +49,12 @@ describe('documentContext', () => { afterCursor: 'o.', }; - it('includes the numbered document only when given paragraphs', () => { + it('carries the numbered document on the first turn only', () => { const first = documentContext({ docContext, brief: null, paragraphs: ['One.', 'Two.'], + baseline: null, }); expect(first).toContain('[1] One.\n[2] Two.'); expect(first).toContain('Tw<>o.'); @@ -61,17 +62,30 @@ describe('documentContext', () => { const later = documentContext({ docContext, brief: null, - paragraphs: null, + paragraphs: ['One.', 'Two.'], + baseline: ['One.', 'Two.'], }); expect(later).not.toContain('[1]'); + expect(later).not.toContain('Since you last saw'); expect(later).toContain('Tw<>o.'); }); + it('notes what the writer changed since the baseline', () => { + const context = documentContext({ + docContext, + brief: null, + paragraphs: ['One.', 'Two!'], + baseline: ['One.', 'Two.'], + }); + expect(context).toContain('[2] reworded: "Two!" (was "Two.")'); + }); + it('leads with the brief when there is one', () => { const context = documentContext({ docContext, brief: 'Audience: reviewers', - paragraphs: null, + paragraphs: ['One.'], + baseline: ['One.'], }); expect(context).toMatch(/^\nAudience: reviewers/); }); @@ -81,6 +95,7 @@ describe('documentContext', () => { docContext: { beforeCursor: '', selectedText: '', afterCursor: '' }, brief: null, paragraphs: [''], + baseline: null, }); expect(context).not.toContain('<>'); expect(context).toContain('(the document is empty)'); diff --git a/frontend/src/pages/chat/index.tsx b/frontend/src/pages/chat/index.tsx index f1bab464..37989c4c 100644 --- a/frontend/src/pages/chat/index.tsx +++ b/frontend/src/pages/chat/index.tsx @@ -33,7 +33,7 @@ import { useDocBrief, } from '@/contexts/docBriefContext'; import { EditorContext } from '@/contexts/editorContext'; -import { viewText } from '@/docTools/report'; +import { viewParagraphs } from '@/docTools/report'; import { useLog } from '@/hooks/useLog'; import { useDocContext } from '@/utilities'; import { z } from 'zod'; @@ -57,7 +57,7 @@ const suggestionPrompts = [ const CHAT_INSTRUCTIONS = `\ Help the user improve their writing. Encourage the user towards critical thinking and self-reflection. Be concise. -Each of the user's messages starts with a block that the app attaches; the user doesn't see it and didn't write it. On their first message it holds the whole document, with paragraphs numbered like [3]; on every message it shows the text around their cursor or selection, marked with <> or <> tags. If the user mentions "here" or "this", assume they mean that area. The document may change between messages: call \`view\` to re-read it when you need the current text. The paragraph numbers are for your tools; the user doesn't see them, so mentioning them might confuse the user. +Each of the user's messages starts with a block that the app attaches; the user doesn't see it and didn't write it. On their first message it holds the whole document, with paragraphs numbered like [3]; on every message it shows the text around their cursor or selection, marked with <> or <> tags. If the user mentions "here" or "this", assume they mean that area. When the user has changed the document since you last saw it, the block says what changed; call \`view\` when you need the whole current text. The paragraph numbers are for your tools; the user doesn't see them, so mentioning them might confuse the user. When referring to a specific part of the document, link to it so the user can jump straight there, using a Markdown link whose target is a doctext URL: [second paragraph of the intro](doctext:A%20short%20quote). The link target must start with "doctext:" and be a URL-component-encoded verbatim quote from the document — from a single line, at most 240 characters, with no surrounding quotation marks or paragraph numbers. The link text should be a short description of the place, not the quote itself. Only link to text that is actually in the document; quote it exactly.`; @@ -65,7 +65,8 @@ When referring to a specific part of the document, link to it so the user can ju const MAX_STEPS = 6; export default function Chat() { - const { chatMessages, updateChatMessages } = useContext(ChatContext); + const { chatMessages, updateChatMessages, docBaseline, setDocBaseline } = + useContext(ChatContext); const editorAPI = useContext(EditorContext); const { brief } = useDocBrief(); const log = useLog(); @@ -175,16 +176,17 @@ export default function Chat() { setFailedMessage(null); // Pull the current document context at send time. The first turn also - // carries the whole document, numbered as `view` numbers it. + // carries the whole document, numbered as `view` numbers it; later ones + // a note of what the writer changed since the model last saw it. const docContext = await refreshDocContext(); + const paragraphs = await editorAPI.getParagraphs(); const context = documentContext({ docContext, brief: formatDocBriefForPrompt(briefRef.current), - paragraphs: - chatMessages.length === 0 - ? await editorAPI.getParagraphs() - : null, + paragraphs, + baseline: chatMessages.length === 0 ? null : docBaseline, }); + setDocBaseline(paragraphs); const withTurn = [...chatMessages, writerTurn(context, text)]; updateChatMessages(withTurn); @@ -204,7 +206,11 @@ export default function Chat() { description: 'Read the current document, paragraphs numbered like [3].', inputSchema: z.object({}), - execute: () => viewText(editorAPI), + execute: async () => { + const seen = await editorAPI.getParagraphs(); + setDocBaseline(seen); + return viewParagraphs(seen); + }, }), }, stopWhen: isStepCount(MAX_STEPS), @@ -241,6 +247,8 @@ export default function Chat() { // under it. if (streamed.trim() === '') { updateChatMessages(chatMessages); + // The model never saw this turn's context either. + setDocBaseline(docBaseline); updateMessage(text); setFailedMessage({ text, source }); } else { @@ -273,6 +281,7 @@ export default function Chat() { setFailedMessage(null); updateMessage(''); updateChatMessages([]); + setDocBaseline(null); chatLog.conversationReset(log); } diff --git a/frontend/src/pages/chat/transcript.ts b/frontend/src/pages/chat/transcript.ts index 5df89b0b..17dd43fb 100644 --- a/frontend/src/pages/chat/transcript.ts +++ b/frontend/src/pages/chat/transcript.ts @@ -12,6 +12,7 @@ import type { ModelMessage, UserModelMessage } from 'ai'; +import { editNote } from '@/docTools/editNote'; import { viewParagraphs } from '@/docTools/report'; /** How much of the cursor's paragraph to show on each side. */ @@ -37,26 +38,33 @@ export function cursorWindow( } /** - * The `` block for one writer turn. `paragraphs` is the - * document snapshot, passed on the first turn only; later turns rely on the - * history and `view`. + * The `` block for one writer turn. On the first turn + * (`baseline` null) it carries the whole document; after that, a note of what + * the writer changed since the model last saw it, if anything. */ export function documentContext({ docContext, brief, paragraphs, + baseline, }: { docContext: DocContext; /** Prompt-formatted brief, or null when the writer hasn't set one. */ brief: string | null; - paragraphs: string[] | null; + /** The document now. */ + paragraphs: string[]; + /** The document as the model last saw it; null on the first turn. */ + baseline: string[] | null; }): string { const parts: string[] = []; if (brief) parts.push(brief); - if (paragraphs) { + if (baseline === null) { parts.push( `The document (paragraph numbers are for your tools only):\n${viewParagraphs(paragraphs)}`, ); + } else { + const note = editNote(baseline, paragraphs); + if (note) parts.push(note); } const { beforeCursor, selectedText, afterCursor } = docContext; if (`${beforeCursor}${selectedText}${afterCursor}`.trim() !== '') { From c2907e271b001b4f409dd69bdb3c2f31dd0fc974 Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Sat, 31 Oct 2026 17:50:56 -0400 Subject: [PATCH 07/13] Implement my-words on Chat --- docs/chat-document-tools.md | 63 ++++ frontend/src/api/logging.ts | 41 ++- frontend/src/consent.ts | 2 + frontend/src/contexts/chatContext.tsx | 66 +++- .../docTools/__tests__/previewUndo.test.ts | 90 ++++++ frontend/src/docTools/editTools.ts | 90 ++++++ frontend/src/docTools/preview.ts | 102 ++++++ frontend/src/docTools/report.ts | 24 +- frontend/src/docTools/undo.ts | 70 ++++ frontend/src/pages/chat/ProposalCard.tsx | 137 ++++++++ .../pages/chat/__tests__/proposals.test.ts | 240 ++++++++++++++ frontend/src/pages/chat/chatTools.ts | 181 +++++++++++ frontend/src/pages/chat/index.tsx | 303 +++++++++++++++--- .../src/pages/chat/proposalCard.module.css | 108 +++++++ frontend/src/pages/chat/transcript.ts | 187 ++++++++++- 15 files changed, 1615 insertions(+), 89 deletions(-) create mode 100644 docs/chat-document-tools.md create mode 100644 frontend/src/docTools/__tests__/previewUndo.test.ts create mode 100644 frontend/src/docTools/editTools.ts create mode 100644 frontend/src/docTools/preview.ts create mode 100644 frontend/src/docTools/undo.ts create mode 100644 frontend/src/pages/chat/ProposalCard.tsx create mode 100644 frontend/src/pages/chat/__tests__/proposals.test.ts create mode 100644 frontend/src/pages/chat/chatTools.ts create mode 100644 frontend/src/pages/chat/proposalCard.module.css diff --git a/docs/chat-document-tools.md b/docs/chat-document-tools.md new file mode 100644 index 00000000..7d58ae93 --- /dev/null +++ b/docs/chat-document-tools.md @@ -0,0 +1,63 @@ +# Chat: document context and own-words edits + +Chat is a coach that can also propose edits to the document, under the My Words +constraint: it may only place the writer's own words. Plan and decisions: +[#583](https://github.com/AIToolsLab/writing-tools/issues/583). Code: +`frontend/src/pages/chat/` and the shared `frontend/src/docTools/`. + +## What the model sees + +The transcript is the model's own history (`ModelMessage[]`, tool calls +included), kept in `ChatContext`. Each writer turn is one user message with two +text parts: a `` block the app attaches, then what the writer +typed (the only part their bubble shows). The block holds: + +- the brief, if set; +- on the first turn, the whole document, paragraphs numbered as `view` numbers + them (numbers are tool coordinates; the model is told not to mention them); +- on later turns, an **edit note**: a paragraph diff (`docTools/editNote.ts`) + between what the model last saw and the document now, omitted when nothing + changed and reduced to "call `view`" past a size cap; +- the paragraph around the cursor or selection, marked. + +"What the model last saw" (`ChatDocState.baseline`) moves on each send, each +`view`, and after an applied edit, so the note reports only the writer's changes. + +## Proposals + +On hosts with `applyEdit` (`canEdit`), Chat offers `str_replace`, `insert` and +`move` (`docTools/editTools.ts`). `chatTools.ts` gates each call in +`toolApproval`: + +1. dry-run against the current document (`lowerOp`); a miss is denied; +2. the word-bank check (`validateOp`); a failure is denied with the offending + words as the reason; +3. otherwise `'user-approval'`: the stream stops and the card appears. + +Denials go back to the model, never to the writer. Word bank: the live +document, every paragraph the model has seen this conversation (so deleted +words can be reinstated), the writer's typed messages, and the brief, but not +the `` blocks or assistant text. + +The app writes every answer itself (`answerProposal`): the approval response +*plus* the tool result. Accept applies the edit first (no reveal beat) and +records the report; Decline and "set aside" (the writer sent a new message +with the card open) record `execution-denied` with a reason. The SDK skips +approvals that already have a result, so it only continues the conversation, +and the history stays valid whatever comes next. That matters because the SDK +resolves approvals only when they're the last message. + +## Undo + +Each applied edit stores its inverse splices (`appliedEdits`, by tool call). +Undo works in any order: `docTools/undo.ts` relocates a stale index to the one +place the edit's text still occurs, and refuses if it changed or occurs more +than once. An undo is not recorded as seen, so the next edit note reports it. + +## Logging + +`chatLog.edit` emits `edit_proposed`, `edit_auto_denied` (`reason`: +`word_bank` | `no_match`), `edit_accepted`, `edit_declined`, `edit_set_aside`, +`edit_failed`, `edit_undone`, `edit_undo_refused`. The op is in `proposal` and +the failing words in `offending`, both document-level in the consent gate, so +at the default (usage) level we see how often each happens but not the text. diff --git a/frontend/src/api/logging.ts b/frontend/src/api/logging.ts index 0911d9a3..ac12b90c 100644 --- a/frontend/src/api/logging.ts +++ b/frontend/src/api/logging.ts @@ -48,8 +48,12 @@ import type { LogFn } from '@/hooks/useLog'; * Chat renders doctext citations too, so `page` is now what says where a * reference event came from. A reader that took those event names to mean * Revise has to read `page` instead. + * 6 — Chat proposes edits: `edit_proposed`, `edit_auto_denied`, + * `edit_accepted`, `edit_declined`, `edit_set_aside`, `edit_failed`, + * `edit_undone`, `edit_undo_refused`. Content rides in `proposal` and + * `offending`, both document-level. */ -export const LOG_SCHEMA_VERSION = 5; +export const LOG_SCHEMA_VERSION = 6; /** Pages that emit events. Matches the user-facing tabs. */ export type LogPage = 'draft' | 'revise' | 'chat' | 'tools'; @@ -188,6 +192,41 @@ export const chatLog = { conversationReset(log: LogFn) { return emit(log, 'chat', 'conversation_reset'); }, + /** + * An edit proposal's lifecycle. `tool` and `kind` are usage-level; the op + * itself goes in `proposal` (document-level, it quotes the document). + * - proposed: shown to the writer for approval + * - auto_denied: failed the word-bank check, never shown; `offending` is + * the first run of words that wasn't the writer's + * - accepted / declined / set_aside: the writer's verdict (set aside = they + * sent a new message with the card still open) + * - failed: accepted, but applying it failed (e.g. the text moved) + * - undone / undo_refused: the writer clicked Undo; refused when the text + * had changed since + */ + edit( + log: LogFn, + outcome: + | 'proposed' + | 'auto_denied' + | 'accepted' + | 'declined' + | 'set_aside' + | 'failed' + | 'undone' + | 'undo_refused', + data: { + tool: string; + kind: string; + toolCallId: string; + proposal?: unknown; + /** auto_denied: why (`word_bank`, or `no_match` for a stale target). */ + reason?: 'word_bank' | 'no_match'; + offending?: string; + }, + ) { + return emit(log, 'chat', `edit_${outcome}`, data); + }, }; /** diff --git a/frontend/src/consent.ts b/frontend/src/consent.ts index b9369f88..a0f58e1a 100644 --- a/frontend/src/consent.ts +++ b/frontend/src/consent.ts @@ -66,6 +66,8 @@ const KEY_MIN_LEVEL: Record = { docContext: 'document', message: 'document', // chat: the user's typed message target: 'document', // revise: a verbatim document quote from a clicked reference + proposal: 'document', // chat: a proposed edit op (quotes the document) + offending: 'document', // chat: words a proposed edit added that weren't the writer's result: 'ai_output', generation: 'ai_output', response: 'ai_output', // revise: the AI-generated visualization text diff --git a/frontend/src/contexts/chatContext.tsx b/frontend/src/contexts/chatContext.tsx index 28cb70ce..3103829b 100644 --- a/frontend/src/contexts/chatContext.tsx +++ b/frontend/src/contexts/chatContext.tsx @@ -1,42 +1,80 @@ import type { ModelMessage } from 'ai'; import { + type Dispatch, type PropsWithChildren, + type SetStateAction, createContext, - useState, useMemo, + useState, } from 'react'; +/** + * What Chat tracks about the document across a conversation. Read and written + * from inside tool calls mid-stream, so it's a mutable object rather than + * React state (which those closures would see stale). + */ +export interface ChatDocState { + /** + * The paragraphs as the model last saw them (the first-turn snapshot, the + * latest `view`, the document right after an applied edit): what the next + * turn's edit note is diffed against. Null before the first turn. + */ + baseline: string[] | null; + /** + * Every paragraph the model has seen this conversation. They were the + * writer's words, so edits may reinstate them even after they're deleted. + */ + seen: Set; +} + +/** Record that the model has seen the document as `paragraphs`. */ +export function recordSeen(state: ChatDocState, paragraphs: string[]): void { + state.baseline = paragraphs; + for (const p of paragraphs) if (p.trim() !== '') state.seen.add(p); +} + +/** An applied edit's undo, by the tool call that made it. */ +export interface AppliedEdit { + undo: ParagraphSplice[]; + undone: boolean; +} + // Stores the chat transcript in a context so that it survives switching tabs. // It's the model's own history (tool calls included); see pages/chat/transcript.ts. export const ChatContext = createContext<{ chatMessages: ModelMessage[]; updateChatMessages: (chatMessages: ModelMessage[]) => void; - /** - * The document's paragraphs as the model last saw them (the first-turn - * snapshot, the latest `view`), which the next turn's edit note is diffed - * against. Null before the first turn. - */ - docBaseline: string[] | null; - setDocBaseline: (paragraphs: string[] | null) => void; + docState: ChatDocState; + appliedEdits: Record; + setAppliedEdits: Dispatch>>; }>({ chatMessages: [], updateChatMessages: (_chatMessages: ModelMessage[]) => {}, - docBaseline: null, - setDocBaseline: (_paragraphs: string[] | null) => {}, + docState: { baseline: null, seen: new Set() }, + appliedEdits: {}, + setAppliedEdits: () => {}, }); export default function ChatContextWrapper({ children }: PropsWithChildren) { const [chatMessages, updateChatMessages] = useState([]); - const [docBaseline, setDocBaseline] = useState(null); + // One object for the provider's lifetime, mutated in place (see ChatDocState). + const [docState] = useState(() => ({ + baseline: null, + seen: new Set(), + })); + const [appliedEdits, setAppliedEdits] = useState< + Record + >({}); const contextValue = useMemo( () => ({ chatMessages, updateChatMessages, - docBaseline, - setDocBaseline, + docState, + appliedEdits, + setAppliedEdits, }), - [chatMessages, docBaseline], + [chatMessages, docState, appliedEdits], ); return ( diff --git a/frontend/src/docTools/__tests__/previewUndo.test.ts b/frontend/src/docTools/__tests__/previewUndo.test.ts new file mode 100644 index 00000000..deab1c9c --- /dev/null +++ b/frontend/src/docTools/__tests__/previewUndo.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; + +import { applyOpLogged, applySplice } from '../ops'; +import { previewChange, previewOpChanges } from '../preview'; +import { planUndo } from '../undo'; + +describe('previewChange', () => { + it('shows the changed words with clipped context', () => { + const p = previewChange( + 'one two three four five six seven eight nine ten', + 'one two three four FIVE six seven eight nine ten', + 2, + ); + expect(p).toMatchObject({ + before: 'three four ', + removed: 'five', + added: 'FIVE', + after: ' six seven', + clippedBefore: true, + clippedAfter: true, + }); + }); + + it('widens a change inside a word to the whole word', () => { + const p = previewChange('the cat sat', 'the cot sat'); + expect(p.removed).toBe('cat'); + expect(p.added).toBe('cot'); + expect(p.clippedBefore).toBe(false); + }); + + it('handles a pure insertion', () => { + const p = previewChange('', 'A new paragraph.'); + expect(p).toMatchObject({ before: '', removed: '', added: 'A new paragraph.' }); + }); +}); + +describe('previewOpChanges', () => { + it('gives a move two previews: where it leaves and where it lands', () => { + const previews = previewOpChanges(['A.', 'B.', 'C.'], { + kind: 'move', + phrase: 'A.', + paragraph: 2, + }); + expect(previews).toHaveLength(2); + expect(previews[0].removed).toBe('A.'); + expect(previews[1].added).toBe('A.'); + }); +}); + +describe('planUndo', () => { + const doc = ['Intro.', 'The cat sat.', 'End.']; + const replace = applyOpLogged(doc, { + kind: 'str_replace', + oldStr: 'cat', + newStr: 'dog', + }); + + it('undoes in place when nothing else changed', () => { + const plan = planUndo(replace.after, replace.undo); + expect(plan).not.toBeNull(); + expect(plan!.reduce(applySplice, replace.after)).toEqual(doc); + }); + + it('relocates after a later edit shifted the paragraphs', () => { + const shifted = ['New first.', ...replace.after]; + const plan = planUndo(shifted, replace.undo); + expect(plan!.reduce(applySplice, shifted)).toEqual(['New first.', ...doc]); + }); + + it('refuses when the writer has changed the edited text since', () => { + const edited = ['Intro.', 'The dog sat down.', 'End.']; + expect(planUndo(edited, replace.undo)).toBeNull(); + }); + + it('refuses when the edited text now occurs more than once', () => { + const twice = [...replace.after, 'The dog sat.']; + const shifted = ['Top.', ...twice]; + expect(planUndo(shifted, replace.undo)).toBeNull(); + }); + + it('undoes a move completely', () => { + const moved = applyOpLogged(doc, { + kind: 'move', + phrase: 'Intro.', + paragraph: 2, + }); + const plan = planUndo(moved.after, moved.undo); + expect(plan!.reduce(applySplice, moved.after)).toEqual(doc); + }); +}); diff --git a/frontend/src/docTools/editTools.ts b/frontend/src/docTools/editTools.ts new file mode 100644 index 00000000..f9baf69e --- /dev/null +++ b/frontend/src/docTools/editTools.ts @@ -0,0 +1,90 @@ +/** + * The own-words edit tools: their schemas, and the mapping from a tool call's + * input to an `EditOp`. Hosts wire them up with their own `execute` and + * approval policy (Chat: word-bank check, then the writer's approval). + */ + +import { z } from 'zod'; + +import type { EditOp } from './types'; + +const OWN_WORDS = + 'Every word you add must be lifted from the writer’s own words (the document and what they’ve said to you), joined only by punctuation and small glue words (a, an, the, and, or, but, of, to, in, on, at, by, for, with, from, as); anything else is rejected before the writer sees it.'; + +const paragraph = z + .number() + .int() + .describe('1-based paragraph number from `view` or the document context.'); + +export const editToolSpecs = { + str_replace: { + description: `Propose replacing a SHORT span (a phrase or sentence within one paragraph). Pass \`paragraph\` so the replace is scoped there. ${OWN_WORDS}`, + inputSchema: z.object({ + old_str: z.string(), + new_str: z.string(), + paragraph: paragraph.optional(), + }), + }, + insert: { + description: `Propose inserting text. Pass \`paragraph\`+\`position\` for a new paragraph, or \`after\` (a quote) to insert within a paragraph. ${OWN_WORDS}`, + inputSchema: z.object({ + text: z.string(), + after: z.string().optional(), + paragraph: paragraph.optional(), + position: z.enum(['before', 'after']).optional(), + }), + }, + move: { + description: + 'Propose relocating an existing passage (the writer’s own words, quoted exactly) to before or after another paragraph. Adds no words.', + inputSchema: z.object({ + phrase: z.string(), + paragraph, + position: z.enum(['before', 'after']).optional(), + }), + }, +}; + +export type EditToolName = keyof typeof editToolSpecs; + +export type EditToolInput = z.infer< + (typeof editToolSpecs)[NAME]['inputSchema'] +>; + +export function isEditToolName(name: string): name is EditToolName { + return name in editToolSpecs; +} + +/** The `EditOp` a tool call's (schema-validated) input describes. */ +export function toEditOp(name: EditToolName, input: unknown): EditOp { + switch (name) { + case 'str_replace': { + const a = input as EditToolInput<'str_replace'>; + return { + kind: 'str_replace', + oldStr: a.old_str, + newStr: a.new_str, + paragraph: a.paragraph, + }; + } + case 'insert': { + const a = input as EditToolInput<'insert'>; + return { + kind: 'insert', + text: a.text, + after: a.after, + paragraph: a.paragraph, + position: a.position, + }; + } + case 'move': { + const a = input as EditToolInput<'move'>; + return { + kind: 'move', + phrase: a.phrase, + paragraph: a.paragraph, + position: a.position, + }; + } + } +} diff --git a/frontend/src/docTools/preview.ts b/frontend/src/docTools/preview.ts new file mode 100644 index 00000000..c15ff518 --- /dev/null +++ b/frontend/src/docTools/preview.ts @@ -0,0 +1,102 @@ +/** + * Compact previews of a proposed edit: just the changed span, with a few words + * of context on each side, so an approval card stays short however long the + * paragraph is. + * + * TODO: once document text is represented as Markdown, a trimmed span can cut + * through formatting (half of a `**bold**`); trim at Markdown-aware boundaries + * then. + */ + +import { lowerOp } from './ops'; +import type { EditOp } from './types'; + +export interface ChangePreview { + /** Unchanged context before the change, already clipped. */ + before: string; + removed: string; + added: string; + /** Unchanged context after the change, already clipped. */ + after: string; + /** Whether `before`/`after` were clipped (render an ellipsis). */ + clippedBefore: boolean; + clippedAfter: boolean; + /** The full old and new text, for "show whole paragraph". */ + fullOld: string; + fullNew: string; +} + +/** Index of the start of the word containing `i` (or `i` itself at a boundary). */ +function wordStart(text: string, i: number): number { + while (i > 0 && !/\s/.test(text[i - 1])) i--; + return i; +} + +/** Index just past the end of the word containing `i - 1`. */ +function wordEnd(text: string, i: number): number { + while (i < text.length && !/\s/.test(text[i])) i++; + return i; +} + +/** The changed span between two texts, whole words, with `contextWords` around. */ +export function previewChange( + oldText: string, + newText: string, + contextWords = 6, +): ChangePreview { + const max = Math.min(oldText.length, newText.length); + let prefix = 0; + while (prefix < max && oldText[prefix] === newText[prefix]) prefix++; + let suffix = 0; + while ( + suffix < max - prefix && + oldText[oldText.length - 1 - suffix] === + newText[newText.length - 1 - suffix] + ) + suffix++; + + // Widen the change to whole words, the same amount in both texts (the + // prefix and suffix are shared, so snapping against the old text is enough + // to keep them shared). + const start = wordStart(oldText, prefix); + const oldEnd = Math.max(start, wordEnd(oldText, oldText.length - suffix)); + const tail = oldText.length - oldEnd; + const newEnd = newText.length - tail; + + const head = oldText.slice(0, start); + const rest = oldText.slice(oldEnd); + const beforeMatch = new RegExp(`(?:\\S+\\s+){0,${contextWords}}$`).exec(head); + const afterMatch = new RegExp(`^(?:\\s*\\S+){0,${contextWords}}`).exec(rest); + const before = beforeMatch ? beforeMatch[0] : ''; + const after = afterMatch ? afterMatch[0] : ''; + + return { + before, + removed: oldText.slice(start, oldEnd), + added: newText.slice(start, newEnd), + after, + clippedBefore: before.length < head.length, + clippedAfter: after.length < rest.length, + fullOld: oldText, + fullNew: newText, + }; +} + +/** + * Previews for each part of an op against the current document (a `move` has + * two: where the passage leaves and where it lands). Throws if the op doesn't + * apply, like `lowerOp`. + */ +export function previewOpChanges( + paragraphs: string[], + op: EditOp, + contextWords?: number, +): ChangePreview[] { + return lowerOp(paragraphs, op).map((splice) => + previewChange( + splice.remove.join('\n'), + splice.insert.join('\n'), + contextWords, + ), + ); +} diff --git a/frontend/src/docTools/report.ts b/frontend/src/docTools/report.ts index 3487a1ba..06d5461e 100644 --- a/frontend/src/docTools/report.ts +++ b/frontend/src/docTools/report.ts @@ -5,7 +5,7 @@ */ import { validateText, type Corpus } from './corpus'; -import { applyEditOp } from './editor'; +import { applyEditOp, type ApplyEditOptions } from './editor'; import { describeOp } from './ops'; import type { EditOp } from './types'; @@ -147,12 +147,26 @@ function targetParagraph(op: EditOp): number | undefined { export async function applyOpAndReport( editor: EditableEditorAPI, op: EditOp, -): Promise<{ ok: boolean; report: string }> { + opts?: ApplyEditOptions, +): Promise< + | { + ok: true; + report: string; + /** What was applied, for capturing an undo. */ + splices: ParagraphSplice[]; + /** The document after the edit. */ + paragraphs: string[]; + } + | { ok: false; report: string } +> { try { - await applyEditOp(editor, op); + const splices = await applyEditOp(editor, op, opts); + const paragraphs = await editor.getParagraphs(); return { ok: true, - report: appliedReport(await editor.getParagraphs(), op), + report: appliedReport(paragraphs, op), + splices, + paragraphs, }; } catch (e) { const window = numberedWindow( @@ -161,7 +175,7 @@ export async function applyOpAndReport( 2, ); return { - ok: false, + ok: false as const, report: `Could not apply that: ${(e as Error).message} The document may have changed since you last looked. Current paragraphs near there:\n${window}\nRe-check the numbers (or \`view\`) and try again.`, }; } diff --git a/frontend/src/docTools/undo.ts b/frontend/src/docTools/undo.ts new file mode 100644 index 00000000..05833cec --- /dev/null +++ b/frontend/src/docTools/undo.ts @@ -0,0 +1,70 @@ +/** + * Undo for an applied edit, in any order rather than only the latest. + * + * An edit's undo is its splices inverted (`invertSplices`), captured when it + * was applied. Each inverse names the paragraphs it expects to find (`remove`, + * the edit's output) and where. Later edits may have shifted that position, so + * a stale index is relocated to the one place that exact run of paragraphs + * still occurs; if the run was changed since, or occurs more than once, the + * undo is refused rather than clobbering the writer's later work. + * + * Splices that expect nothing (`remove: []`, e.g. restoring a paragraph a + * `move` emptied) can't be checked or relocated, so they go back at their + * recorded index. The whole sequence is dry-run on a copy first, so an undo + * either applies completely or not at all. + */ + +import { applySpliceToEditor } from './editor'; +import { applySplice, spliceIsFresh } from './ops'; + +/** Where `splice.remove` sits in `paragraphs` now, or null if not exactly once. */ +export function locateSplice( + paragraphs: string[], + splice: ParagraphSplice, +): number | null { + if (spliceIsFresh(paragraphs, splice)) return splice.index; + if (splice.remove.length === 0) return null; + const found: number[] = []; + for (let i = 0; i + splice.remove.length <= paragraphs.length; i++) { + if (spliceIsFresh(paragraphs, { ...splice, index: i })) found.push(i); + } + return found.length === 1 ? found[0] : null; +} + +/** + * The undo splices re-anchored to the document as it is now, or null if the + * edit's text has changed since (so undoing would overwrite the writer). + */ +export function planUndo( + paragraphs: string[], + undo: ParagraphSplice[], +): ParagraphSplice[] | null { + const planned: ParagraphSplice[] = []; + let current = paragraphs; + for (const splice of undo) { + const index = locateSplice(current, splice); + if (index === null) return null; + const anchored = { ...splice, index }; + planned.push(anchored); + current = applySplice(current, anchored); + } + return planned; +} + +/** Thrown when an edit can't be undone because its text changed since. */ +export class UndoConflict extends Error { + constructor() { + super('That passage has changed since the edit, so it can’t be undone here.'); + this.name = 'UndoConflict'; + } +} + +/** Undo an applied edit. Throws {@link UndoConflict} if it can't be done safely. */ +export async function undoEdit( + editor: EditableEditorAPI, + undo: ParagraphSplice[], +): Promise { + const planned = planUndo(await editor.getParagraphs(), undo); + if (!planned) throw new UndoConflict(); + for (const splice of planned) await applySpliceToEditor(editor, splice); +} diff --git a/frontend/src/pages/chat/ProposalCard.tsx b/frontend/src/pages/chat/ProposalCard.tsx new file mode 100644 index 00000000..ff81bb6f --- /dev/null +++ b/frontend/src/pages/chat/ProposalCard.tsx @@ -0,0 +1,137 @@ +import { useState } from 'react'; + +import type { AppliedEdit } from '@/contexts/chatContext'; +import { isEditToolName, toEditOp } from '@/docTools/editTools'; +import { describeOp } from '@/docTools/ops'; +import type { ChangePreview } from '@/docTools/preview'; + +import classes from './proposalCard.module.css'; +import type { ProposalItem } from './transcript'; + +function Preview({ preview, full }: { preview: ChangePreview; full: boolean }) { + if (full) { + return ( +
+ {preview.fullOld ? ( + {preview.fullOld} + ) : null} + {preview.fullNew ? ( + {preview.fullNew} + ) : null} +
+ ); + } + return ( +
+ {preview.clippedBefore ? '…' : ''} + {preview.before} + {preview.removed ? ( + {preview.removed} + ) : null} + {preview.added ? ( + {preview.added} + ) : null} + {preview.after} + {preview.clippedAfter ? '…' : ''} +
+ ); +} + +const STATUS_LABEL: Record, string> = + { + applying: 'Applying…', + applied: 'Applied', + failed: 'Couldn’t apply', + declined: 'Declined', + 'set-aside': 'Set aside', + }; + +/** + * A proposed edit in the transcript. Pending: what would change, and Accept / + * Decline. Afterwards: one status line, with Undo for an applied edit. + */ +export function ProposalCard({ + proposal, + previews, + applied, + undoError, + busy, + onAccept, + onDecline, + onUndo, +}: { + proposal: ProposalItem; + /** Trimmed previews against the current document; pending cards only. */ + previews: ChangePreview[] | null; + applied: AppliedEdit | undefined; + undoError: string | undefined; + busy: boolean; + onAccept: () => void; + onDecline: () => void; + onUndo: () => void; +}) { + const [full, setFull] = useState(false); + const summary = isEditToolName(proposal.toolName) + ? describeOp(toEditOp(proposal.toolName, proposal.input)) + : proposal.toolName; + + if (proposal.status !== 'pending') { + return ( +
+ + {applied?.undone ? 'Undone' : STATUS_LABEL[proposal.status]}:{' '} + {summary} + + {proposal.status === 'applied' && applied && !applied.undone ? ( + + ) : null} + {undoError ? ( +
{undoError}
+ ) : null} +
+ ); + } + + return ( +
+
{summary}
+ {previews?.map((preview, i) => ( + + ))} + {previews?.some((p) => p.clippedBefore || p.clippedAfter) ? ( + + ) : null} +
+ + +
+
+ ); +} diff --git a/frontend/src/pages/chat/__tests__/proposals.test.ts b/frontend/src/pages/chat/__tests__/proposals.test.ts new file mode 100644 index 00000000..662e95b1 --- /dev/null +++ b/frontend/src/pages/chat/__tests__/proposals.test.ts @@ -0,0 +1,240 @@ +/** + * The proposal loop end to end, through the real SDK with a scripted model: + * the word-bank gate, a proposal waiting on the writer, Accept, and setting a + * proposal aside by sending a new message. + */ +import { convertArrayToReadableStream } from '@ai-sdk/provider-utils/test'; +import { isStepCount, type ModelMessage } from 'ai'; +import { MockLanguageModelV4 } from 'ai/test'; +import { describe, expect, it, vi } from 'vitest'; + +import { noopEditorAPI } from '@/api/__fixtures__/editorAPI'; +import { streamParts } from '@/api/generate'; +import type { ChatDocState } from '@/contexts/chatContext'; +import { editNote } from '@/docTools/editNote'; +import { applySplice } from '@/docTools/ops'; + +import { chatTools } from '../chatTools'; +import { + answerProposal, + displayTranscript, + pendingProposals, + setAsideReason, + writerTurn, +} from '../transcript'; + +type StreamPart = + Awaited< + ReturnType + >['stream'] extends ReadableStream + ? Part + : never; + +const usage = { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, +}; + +function toolCallStep(toolName: string, input: object): StreamPart[] { + return [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'call-1', + toolName, + input: JSON.stringify(input), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: undefined }, + usage, + }, + ]; +} + +function textStep(text: string): StreamPart[] { + return [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 't' }, + { type: 'text-delta', id: 't', delta: text }, + { type: 'text-end', id: 't' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: undefined }, + usage, + }, + ]; +} + +/** A model that plays `steps` in order, one per call. */ +function scriptedModel(...steps: StreamPart[][]) { + let call = 0; + return new MockLanguageModelV4({ + doStream: () => + Promise.resolve({ + stream: convertArrayToReadableStream(steps[call++] ?? textStep('')), + }), + }); +} + +function setup(initial: string[]) { + let paragraphs = [...initial]; + const editor = noopEditorAPI({ + getParagraphs: () => Promise.resolve([...paragraphs]), + getDocText: () => Promise.resolve(paragraphs.join('\n')), + applySplice: (splice) => { + paragraphs = applySplice(paragraphs, splice); + return Promise.resolve(); + }, + }); + const docState: ChatDocState = { baseline: [...initial], seen: new Set() }; + let messages: ModelMessage[] = [writerTurn('', 'Tighten this?')]; + const log = vi.fn(() => Promise.resolve()); + const onApplied = vi.fn(); + const tools = chatTools({ + editor, + docState, + briefText: () => [], + messages: () => messages, + onApplied, + log, + }); + const run = async (model: MockLanguageModelV4, history: ModelMessage[]) => { + messages = history; + const parts = streamParts({ + model, + messages: history, + tools: tools.tools, + toolApproval: tools.toolApproval, + // As Chat does: room to continue after an auto-denial. + stopWhen: isStepCount(6), + }); + let next = await parts.next(); + while (!next.done) next = await parts.next(); + return [...history, ...next.value]; + }; + return { + tools, + docState, + log, + onApplied, + run, + paragraphs: () => paragraphs, + get messages() { + return messages; + }, + }; +} + +/** The prompt the model saw on its `n`th call, as JSON for loose matching. */ +const promptOf = (model: MockLanguageModelV4, n: number) => + JSON.stringify(model.doStreamCalls[n].prompt); + +describe('the word-bank gate', () => { + it('denies an edit with words that are not the writer’s, without showing it', async () => { + const t = setup(['The cat sat on the mat.']); + const model = scriptedModel( + toolCallStep('str_replace', { + old_str: 'cat', + new_str: 'elephant', + paragraph: 1, + }), + textStep('Fair enough.'), + ); + const messages = await t.run(model, t.messages); + + expect(t.paragraphs()).toEqual(['The cat sat on the mat.']); + expect(promptOf(model, 1)).toMatch(/execution-denied.*elephant/); + expect(displayTranscript(messages).map((m) => m.role)).toEqual([ + 'user', + 'assistant', + ]); + expect(t.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'edit_auto_denied', + reason: 'word_bank', + offending: 'elephant', + }), + ); + }); + + it('denies an edit whose target is not in the document', async () => { + const t = setup(['The cat sat.']); + const model = scriptedModel( + toolCallStep('str_replace', { old_str: 'dog', new_str: 'cat' }), + textStep('Hm.'), + ); + await t.run(model, t.messages); + expect(promptOf(model, 1)).toMatch(/doesn't apply to the current document/); + }); +}); + +describe('a proposal the writer answers', () => { + const proposeTrim = () => + toolCallStep('str_replace', { + old_str: 'cat sat on the mat', + new_str: 'cat sat', + paragraph: 1, + }); + + it('waits for the writer when the words check out', async () => { + const t = setup(['The cat sat on the mat.']); + const model = scriptedModel(proposeTrim()); + const messages = await t.run(model, t.messages); + + expect(model.doStreamCalls).toHaveLength(1); + expect(pendingProposals(messages)).toHaveLength(1); + expect(t.paragraphs()).toEqual(['The cat sat on the mat.']); + }); + + it('applies on Accept, once, and keeps the model’s own edit out of the next note', async () => { + const t = setup(['The cat sat on the mat.']); + const proposed = await t.run(scriptedModel(proposeTrim()), t.messages); + const [proposal] = pendingProposals(proposed); + + const result = await t.tools.applyProposal( + proposal.toolCallId, + proposal.toolName, + proposal.input, + ); + expect(result.ok).toBe(true); + expect(t.paragraphs()).toEqual(['The cat sat.']); + expect(t.onApplied).toHaveBeenCalledOnce(); + expect(editNote(t.docState.baseline!, t.paragraphs())).toBeNull(); + + const model = scriptedModel(textStep('Done.')); + const messages = await t.run(model, [ + ...proposed, + answerProposal(proposal, { approved: true, ...result }), + ]); + // The SDK didn't run the tool a second time. + expect(t.paragraphs()).toEqual(['The cat sat.']); + expect(t.onApplied).toHaveBeenCalledOnce(); + expect(displayTranscript(messages)).toContainEqual( + expect.objectContaining({ role: 'proposal', status: 'applied' }), + ); + }); + + it('keeps the history valid when the writer sets a proposal aside', async () => { + const t = setup(['The cat sat on the mat.']); + const proposed = await t.run(scriptedModel(proposeTrim()), t.messages); + const [proposal] = pendingProposals(proposed); + + const model = scriptedModel(textStep('Sure, let’s talk about the ending.')); + const messages = await t.run(model, [ + ...proposed, + answerProposal(proposal, { + approved: false, + reason: setAsideReason('What about the ending?'), + }), + writerTurn('', 'What about the ending?'), + ]); + + expect(t.paragraphs()).toEqual(['The cat sat on the mat.']); + expect(promptOf(model, 0)).toMatch(/execution-denied.*set this proposal aside/); + expect(displayTranscript(messages)).toContainEqual( + expect.objectContaining({ role: 'proposal', status: 'set-aside' }), + ); + expect(pendingProposals(messages)).toHaveLength(0); + }); +}); diff --git a/frontend/src/pages/chat/chatTools.ts b/frontend/src/pages/chat/chatTools.ts new file mode 100644 index 00000000..0ee01404 --- /dev/null +++ b/frontend/src/pages/chat/chatTools.ts @@ -0,0 +1,181 @@ +/** + * Chat's tools and approval policy. + * + * `view` reads the document. On hosts that can edit (`canEdit`), the own-words + * edit tools propose changes: each call first passes the word-bank check and + * a dry run against the current document, in `toolApproval`. A failure is + * denied there, with a reason the model reads and the writer never sees; a + * pass waits for the writer's approval. Only then does `execute` apply it. + */ + +import { + tool, + type ModelMessage, + type ToolApprovalConfiguration, + type ToolSet, +} from 'ai'; +import { z } from 'zod'; + +import { chatLog } from '@/api/logging'; +import type { AppliedEdit, ChatDocState } from '@/contexts/chatContext'; +import { recordSeen } from '@/contexts/chatContext'; +import { buildCorpus } from '@/docTools/corpus'; +import { canEdit } from '@/docTools/editor'; +import { + editToolSpecs, + isEditToolName, + toEditOp, + type EditToolName, +} from '@/docTools/editTools'; +import { invertSplices, lowerOp } from '@/docTools/ops'; +import { applyOpAndReport, validateOp, viewParagraphs } from '@/docTools/report'; +import type { LogFn } from '@/hooks/useLog'; + +import { writerMessages } from './transcript'; + +export interface ChatToolsOptions { + editor: EditorAPI; + docState: ChatDocState; + /** The writer's brief fields, which count as their words. */ + briefText: () => string[]; + /** The transcript the turn is running on (for the writer's messages). */ + messages: () => ModelMessage[]; + onApplied: (toolCallId: string, edit: AppliedEdit) => void; + log: LogFn; +} + +/** The per-tool form of `toolApproval` (not the single-function form). */ +type PerToolApproval = Exclude< + ToolApprovalConfiguration, + (...args: never[]) => unknown +>; + +/** The outcome of applying an approved proposal, as the model will read it. */ +export type ProposalResult = { ok: boolean; report: string }; + +export function chatTools(opts: ChatToolsOptions): { + tools: ToolSet; + toolApproval: PerToolApproval; + /** + * Apply an approved proposal. The Accept handler calls this itself and + * writes the result into the transcript before the model continues; the + * tools' `execute` runs the same code as a fallback. + */ + applyProposal: ( + toolCallId: string, + toolName: string, + input: unknown, + ) => Promise; +} { + const { editor, docState, log } = opts; + const tools: ToolSet = { + view: tool({ + description: + 'Read the current document, paragraphs numbered like [3].', + inputSchema: z.object({}), + execute: async () => { + const paragraphs = await editor.getParagraphs(); + recordSeen(docState, paragraphs); + return viewParagraphs(paragraphs); + }, + }), + }; + const toolApproval: PerToolApproval = { view: 'not-applicable' }; + if (!canEdit(editor)) { + return { + tools, + toolApproval, + applyProposal: () => + Promise.resolve({ + ok: false, + report: 'This editor can’t apply edits.', + }), + }; + } + + const applyProposal = async ( + toolCallId: string, + toolName: string, + input: unknown, + ): Promise => { + if (!isEditToolName(toolName)) { + return { ok: false, report: `Unknown edit tool ${toolName}.` }; + } + const op = toEditOp(toolName, input); + // Approved already, so no reveal beat: apply straight away. + const result = await applyOpAndReport(editor, op, { revealMs: 0 }); + if (!result.ok) { + void chatLog.edit(log, 'failed', { + tool: toolName, + kind: op.kind, + toolCallId, + proposal: op, + }); + return result; + } + // The model knows about its own edit; don't report it back as the + // writer's in the next edit note. + recordSeen(docState, result.paragraphs); + opts.onApplied(toolCallId, { + undo: invertSplices(result.splices), + undone: false, + }); + return { ok: true, report: result.report }; + }; + + const corpus = async () => + buildCorpus({ + docText: await editor.getDocText(), + userMessages: [ + ...writerMessages(opts.messages()), + ...opts.briefText(), + ...docState.seen, + ], + }); + + for (const name of Object.keys(editToolSpecs) as EditToolName[]) { + const spec = editToolSpecs[name]; + // Widened so one loop builds all three; `toEditOp` re-types the input. + const inputSchema: z.ZodType = spec.inputSchema; + tools[name] = tool({ + description: spec.description, + inputSchema, + execute: async (input, { toolCallId }) => { + const result = await applyProposal(toolCallId, name, input); + if (!result.ok) throw new Error(result.report); + return result.report; + }, + }); + toolApproval[name] = async (input, { toolCallId }) => { + const op = toEditOp(name, input); + const logData = { tool: name, kind: op.kind, toolCallId, proposal: op }; + try { + lowerOp(await editor.getParagraphs(), op); + } catch (e) { + void chatLog.edit(log, 'auto_denied', { + ...logData, + reason: 'no_match', + }); + return { + type: 'denied', + reason: `That edit doesn't apply to the current document: ${(e as Error).message} Re-check with \`view\`.`, + }; + } + const check = validateOp(op, await corpus()); + if (!check.ok) { + void chatLog.edit(log, 'auto_denied', { + ...logData, + reason: 'word_bank', + offending: check.offending, + }); + return { + type: 'denied', + reason: `Not the writer's words: "${check.offending}". Rephrase using only their words, or ask them instead.`, + }; + } + void chatLog.edit(log, 'proposed', logData); + return 'user-approval'; + }; + } + return { tools, toolApproval, applyProposal }; +} diff --git a/frontend/src/pages/chat/index.tsx b/frontend/src/pages/chat/index.tsx index 37989c4c..3ee3de25 100644 --- a/frontend/src/pages/chat/index.tsx +++ b/frontend/src/pages/chat/index.tsx @@ -1,4 +1,4 @@ -import { isStepCount, tool } from 'ai'; +import { isStepCount, type ModelMessage } from 'ai'; import { useCallback, useContext, @@ -27,19 +27,33 @@ import { DocTextMarkdown, useDocJump, } from '@/components/docTextLink'; -import { ChatContext } from '@/contexts/chatContext'; +import { ChatContext, recordSeen } from '@/contexts/chatContext'; import { formatDocBriefForPrompt, useDocBrief, } from '@/contexts/docBriefContext'; import { EditorContext } from '@/contexts/editorContext'; -import { viewParagraphs } from '@/docTools/report'; +import { canEdit } from '@/docTools/editor'; +import { isEditToolName, toEditOp } from '@/docTools/editTools'; +import { previewOpChanges, type ChangePreview } from '@/docTools/preview'; +import { locateProposal } from '@/docTools/report'; +import { undoEdit } from '@/docTools/undo'; import { useLog } from '@/hooks/useLog'; import { useDocContext } from '@/utilities'; -import { z } from 'zod'; +import { chatTools } from './chatTools'; +import { ProposalCard } from './ProposalCard'; import classes from './styles.module.css'; -import { displayTranscript, documentContext, writerTurn } from './transcript'; +import { + answerProposal, + DECLINED_REASON, + displayTranscript, + documentContext, + pendingProposals, + setAsideReason, + writerTurn, + type ProposalItem, +} from './transcript'; const suggestionPrompts = [ 'What is my main argument?', @@ -61,12 +75,21 @@ Each of the user's messages starts with a block that the app When referring to a specific part of the document, link to it so the user can jump straight there, using a Markdown link whose target is a doctext URL: [second paragraph of the intro](doctext:A%20short%20quote). The link target must start with "doctext:" and be a URL-component-encoded verbatim quote from the document — from a single line, at most 240 characters, with no surrounding quotation marks or paragraph numbers. The link text should be a short description of the place, not the quote itself. Only link to text that is actually in the document; quote it exactly.`; +/** Added to the instructions where the host can apply edits. */ +const EDIT_INSTRUCTIONS = `\ +You can also propose small edits to the document with \`str_replace\`, \`insert\`, and \`move\`. The user sees each proposal and accepts or declines it; the app rejects any that adds words that aren't the user's own. Mostly, coach: ask, reflect, point at passages. Propose an edit when the user asks for one, or when the improvement is only rearranging or trimming their own words. Propose one edit at a time, and say in a sentence why. If the user declines or sets one aside, don't propose it again; go back to talking it through.`; + /** Runaway cap on model steps (each tool call is a step) per writer turn. */ const MAX_STEPS = 6; export default function Chat() { - const { chatMessages, updateChatMessages, docBaseline, setDocBaseline } = - useContext(ChatContext); + const { + chatMessages, + updateChatMessages, + docState, + appliedEdits, + setAppliedEdits, + } = useContext(ChatContext); const editorAPI = useContext(EditorContext); const { brief } = useDocBrief(); const log = useLog(); @@ -74,6 +97,9 @@ export default function Chat() { // just edited applies to the very next message. const briefRef = useRef(brief); briefRef.current = brief; + // The transcript as of now, for tool calls that run mid-stream. + const messagesRef = useRef(chatMessages); + messagesRef.current = chatMessages; const activeRequestControllerRef = useRef(null); const messagesContainerRef = useRef(null); const textareaRef = useRef(null); @@ -90,6 +116,8 @@ export default function Chat() { text: string; source: 'input' | 'suggested'; } | null>(null); + /** Why an Undo was refused, by the proposal's tool call. */ + const [undoErrors, setUndoErrors] = useState>({}); // Show the "scroll to bottom" button when the user scrolls up, and hide it when they are near the bottom. const handleScroll = useCallback(() => { @@ -146,6 +174,42 @@ export default function Chat() { visibleMessages.push({ role: 'assistant', text: streamingText }); } + /** + * Previews of the proposal awaiting the writer, against the document as it + * is when the proposal arrives. Keyed by tool call so a stale preview is + * never shown on a newer card. + */ + const [pendingPreview, setPendingPreview] = useState<{ + toolCallId: string; + previews: ChangePreview[] | null; + } | null>(null); + const pending = pendingProposals(chatMessages)[0] as + | ProposalItem + | undefined; + const pendingId = pending?.toolCallId; + const showPending = useEffectEvent(async (proposal: ProposalItem) => { + if (!isEditToolName(proposal.toolName)) return; + const op = toEditOp(proposal.toolName, proposal.input); + let previews: ChangePreview[] | null = null; + try { + previews = previewOpChanges(await editorAPI.getParagraphs(), op); + } catch { + // The writer changed the text since the proposal was checked; the + // card still shows the summary, and Accept reports the failure. + } + setPendingPreview({ toolCallId: proposal.toolCallId, previews }); + // Show the writer where, before they decide. + const { highlight } = await locateProposal(editorAPI, op); + if (highlight) { + editorAPI.selectPhrase(highlight).catch(() => {}); + } + }); + useEffect(() => { + if (pending) void showPending(pending); + // Only when a different proposal starts waiting. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pendingId]); + const resizeTextarea = useCallback(() => { const textarea = textareaRef.current; if (!textarea) return; @@ -164,55 +228,44 @@ export default function Chat() { }; }, []); - async function submitMessage(text: string, source: 'input' | 'suggested') { + /** + * Run the model on `messages` (which end with whatever it should respond + * to: a writer turn, or the answer to a proposal) and add its reply to the + * transcript. `rollback` runs if it fails before saying anything. + */ + async function runModel(messages: ModelMessage[], rollback: () => void) { // Only one active request is allowed; cancel any previous stream first. activeRequestControllerRef.current?.abort(); const requestController = new AbortController(); activeRequestControllerRef.current = requestController; - chatLog.messageSent(log, { message: text, source }); updateSendingMessage(true); setErrorInfo(null); setFailedMessage(null); - - // Pull the current document context at send time. The first turn also - // carries the whole document, numbered as `view` numbers it; later ones - // a note of what the writer changed since the model last saw it. - const docContext = await refreshDocContext(); - const paragraphs = await editorAPI.getParagraphs(); - const context = documentContext({ - docContext, - brief: formatDocBriefForPrompt(briefRef.current), - paragraphs, - baseline: chatMessages.length === 0 ? null : docBaseline, - }); - setDocBaseline(paragraphs); - const withTurn = [...chatMessages, writerTurn(context, text)]; - - updateChatMessages(withTurn); + updateChatMessages(messages); + messagesRef.current = messages; setStreamingText(''); setShowScrollButton(false); - updateMessage(''); + const editing = canEdit(editorAPI); + const { tools, toolApproval } = makeTools(); let streamed = ''; try { const parts = streamParts({ model: languageModel, - providerOptions: openaiProviderOptions, - instructions: CHAT_INSTRUCTIONS, - messages: withTurn, - tools: { - view: tool({ - description: - 'Read the current document, paragraphs numbered like [3].', - inputSchema: z.object({}), - execute: async () => { - const seen = await editorAPI.getParagraphs(); - setDocBaseline(seen); - return viewParagraphs(seen); - }, - }), + providerOptions: { + openai: { + ...openaiProviderOptions.openai, + // One proposal at a time. + parallelToolCalls: false, + }, }, + instructions: editing + ? `${CHAT_INSTRUCTIONS}\n\n${EDIT_INSTRUCTIONS}` + : CHAT_INSTRUCTIONS, + messages, + tools, + toolApproval, stopWhen: isStepCount(MAX_STEPS), abortSignal: requestController.signal, }); @@ -231,7 +284,7 @@ export default function Chat() { next = await parts.next(); } if (requestController.signal.aborted) return; - updateChatMessages([...withTurn, ...next.value]); + updateChatMessages([...messages, ...next.value]); chatLog.responseCompleted(log, { responseLength: streamed.length, }); @@ -241,19 +294,13 @@ export default function Chat() { } const info = describeGenerationError(error); console.error('Error while streaming chat response:', error); - // Nothing streamed: roll the turn back and hand the text back to the - // input box, so Retry re-sends it once rather than duplicating the - // turn. If part of a reply did arrive, keep it and just show the error - // under it. + // Nothing streamed: roll back. If part of a reply did arrive, keep it + // and just show the error under it. if (streamed.trim() === '') { - updateChatMessages(chatMessages); - // The model never saw this turn's context either. - setDocBaseline(docBaseline); - updateMessage(text); - setFailedMessage({ text, source }); + rollback(); } else { updateChatMessages([ - ...withTurn, + ...messages, { role: 'assistant', content: streamed }, ]); } @@ -272,6 +319,125 @@ export default function Chat() { } } + function makeTools() { + return chatTools({ + editor: editorAPI, + docState, + briefText: () => Object.values(briefRef.current), + messages: () => messagesRef.current, + onApplied: (toolCallId, edit) => + setAppliedEdits((prev) => ({ ...prev, [toolCallId]: edit })), + log, + }); + } + + function logEdit( + outcome: Parameters[1], + proposal: ProposalItem, + ) { + if (!isEditToolName(proposal.toolName)) return; + const op = toEditOp(proposal.toolName, proposal.input); + void chatLog.edit(log, outcome, { + tool: proposal.toolName, + kind: op.kind, + toolCallId: proposal.toolCallId, + proposal: op, + }); + } + + async function submitMessage(text: string, source: 'input' | 'suggested') { + chatLog.messageSent(log, { message: text, source }); + updateMessage(''); + + // A proposal still open when the writer moves on is set aside: it gets + // its answer before their turn, so the model knows what happened to it. + let base = chatMessages; + for (const proposal of pendingProposals(chatMessages)) { + base = [ + ...base, + answerProposal(proposal, { + approved: false, + reason: setAsideReason(text), + }), + ]; + logEdit('set_aside', proposal); + } + + // Pull the current document context at send time. The first turn also + // carries the whole document, numbered as `view` numbers it; later ones + // a note of what the writer changed since the model last saw it. + const docContext = await refreshDocContext(); + const paragraphs = await editorAPI.getParagraphs(); + const previousBaseline = docState.baseline; + const context = documentContext({ + docContext, + brief: formatDocBriefForPrompt(briefRef.current), + paragraphs, + baseline: chatMessages.length === 0 ? null : previousBaseline, + }); + recordSeen(docState, paragraphs); + + await runModel([...base, writerTurn(context, text)], () => { + // Hand the text back to the input box, so Retry re-sends it once + // rather than duplicating the turn. + updateChatMessages(chatMessages); + // The model never saw this turn's context either. + docState.baseline = previousBaseline; + updateMessage(text); + setFailedMessage({ text, source }); + }); + } + + async function acceptProposal(proposal: ProposalItem) { + logEdit('accepted', proposal); + updateSendingMessage(true); + const result = await makeTools().applyProposal( + proposal.toolCallId, + proposal.toolName, + proposal.input, + ); + const answered = [ + ...chatMessages, + answerProposal(proposal, { approved: true, ...result }), + ]; + // The edit stands even if the model's follow-up fails. + await runModel(answered, () => updateChatMessages(answered)); + } + + async function declineProposal(proposal: ProposalItem) { + logEdit('declined', proposal); + const answered = [ + ...chatMessages, + answerProposal(proposal, { + approved: false, + reason: DECLINED_REASON, + }), + ]; + await runModel(answered, () => updateChatMessages(answered)); + } + + async function undoProposal(proposal: ProposalItem) { + const applied = appliedEdits[proposal.toolCallId]; + if (!applied || applied.undone || !canEdit(editorAPI)) return; + try { + await undoEdit(editorAPI, applied.undo); + } catch (e) { + logEdit('undo_refused', proposal); + setUndoErrors((prev) => ({ + ...prev, + [proposal.toolCallId]: (e as Error).message, + })); + return; + } + // Not recorded as seen: to the model, an undo is the writer's change, + // and the next edit note reports it. + logEdit('undone', proposal); + setAppliedEdits((prev) => ({ + ...prev, + [proposal.toolCallId]: { ...applied, undone: true }, + })); + } + function startNewConversation() { activeRequestControllerRef.current?.abort(); activeRequestControllerRef.current = null; @@ -281,7 +447,10 @@ export default function Chat() { setFailedMessage(null); updateMessage(''); updateChatMessages([]); - setDocBaseline(null); + docState.baseline = null; + docState.seen.clear(); + setAppliedEdits({}); + setUndoErrors({}); chatLog.conversationReset(log); } @@ -354,6 +523,36 @@ export default function Chat() {
) : ( visibleMessages.map((chatMessage, index) => { + if (chatMessage.role === 'proposal') { + return ( + + void acceptProposal(chatMessage) + } + onDecline={() => + void declineProposal(chatMessage) + } + onUndo={() => + void undoProposal(chatMessage) + } + /> + ); + } const isAssistantTyping = chatMessage.role === 'assistant' && chatMessage.text === '' && diff --git a/frontend/src/pages/chat/proposalCard.module.css b/frontend/src/pages/chat/proposalCard.module.css new file mode 100644 index 00000000..f4469c38 --- /dev/null +++ b/frontend/src/pages/chat/proposalCard.module.css @@ -0,0 +1,108 @@ +.card { + align-self: stretch; + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px; + border: 1.5px solid #e4e0d8; + border-radius: 12px; + background: #fdfcf9; + font-size: 13px; + line-height: 1.6; +} + +.summary { + font-weight: 600; + color: #1c1c1c; +} + +.preview { + padding: 6px 8px; + border-radius: 6px; + background: #ffffff; + border: 1px solid #ece8e0; + color: #4a463f; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.removed { + color: #9a3b2e; + background: #fbe9e5; + text-decoration: line-through; +} + +.added { + color: #23613a; + background: #e4f3e8; + text-decoration: none; +} + +/* A replacement: keep the old and new words from reading as one word. */ +.removed + .added { + margin-left: 0.3em; +} + +.actions { + display: flex; + gap: 8px; +} + +.accept, +.decline { + font-family: 'DM Sans', sans-serif; + font-size: 12px; + padding: 5px 12px; + border-radius: 6px; + cursor: pointer; +} + +.accept { + background: #1c1c1c; + color: #faf8f4; + border: 1.5px solid #1c1c1c; +} + +.decline { + background: none; + color: #1c1c1c; + border: 1.5px solid #e4e0d8; +} + +.accept:disabled, +.decline:disabled { + opacity: 0.5; + cursor: default; +} + +.settled { + align-self: stretch; + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 8px; + font-size: 11px; + color: #8a8378; + padding: 0 4px; +} + +.linkButton { + align-self: flex-start; + background: none; + border: none; + padding: 0; + font-size: 11px; + color: #6b5f4f; + text-decoration: underline; + cursor: pointer; +} + +.linkButton:disabled { + opacity: 0.5; + cursor: default; +} + +.undoError { + flex-basis: 100%; + color: #9a3b2e; +} diff --git a/frontend/src/pages/chat/transcript.ts b/frontend/src/pages/chat/transcript.ts index 17dd43fb..c20ba163 100644 --- a/frontend/src/pages/chat/transcript.ts +++ b/frontend/src/pages/chat/transcript.ts @@ -89,40 +89,193 @@ export function writerTurn(context: string, text: string): UserModelMessage { } /** What the writer typed: the last text part of their turn. */ -function writerText(message: UserModelMessage): string { +export function writerText(message: UserModelMessage): string { if (typeof message.content === 'string') return message.content; const texts = message.content.filter((p) => p.type === 'text'); return texts[texts.length - 1]?.text ?? ''; } +/** Everything the writer has typed this conversation (for the word bank). */ +export function writerMessages(messages: ModelMessage[]): string[] { + return messages.flatMap((m) => (m.role === 'user' ? [writerText(m)] : [])); +} + +/** Reasons the model reads when a proposal isn't applied. */ +export const DECLINED_REASON = 'The writer declined this edit.'; +const SET_ASIDE_PREFIX = 'The writer set this proposal aside'; + +export type ProposalStatus = + | 'pending' + | 'applying' + | 'applied' + | 'failed' + | 'declined' + | 'set-aside'; + +export interface ProposalItem { + role: 'proposal'; + toolCallId: string; + approvalId: string; + toolName: string; + input: unknown; + status: ProposalStatus; +} + export interface DisplayMessage { role: 'user' | 'assistant'; text: string; } +export type DisplayItem = DisplayMessage | ProposalItem; + +type ToolCall = { toolCallId: string; toolName: string; input: unknown }; + /** - * The bubbles for a transcript. Tool messages are skipped, and the assistant's - * text across the steps of one reply (text, a `view` call, more text) is - * joined into one bubble. + * What the transcript records about each tool call: the call itself, the + * writer's verdict on its approval (if asked), and its result. */ -export function displayTranscript(messages: ModelMessage[]): DisplayMessage[] { - const out: DisplayMessage[] = []; +function indexToolCalls(messages: ModelMessage[]) { + const calls = new Map(); + const verdicts = new Map(); // by approvalId + const results = new Map(); + for (const message of messages) { + if (message.role === 'assistant' && typeof message.content !== 'string') { + for (const part of message.content) { + if (part.type === 'tool-call') calls.set(part.toolCallId, part); + } + } else if (message.role === 'tool') { + for (const part of message.content) { + if (part.type === 'tool-approval-response') { + verdicts.set(part.approvalId, part.approved); + } else if (part.type === 'tool-result') { + const { output } = part; + results.set(part.toolCallId, { + type: output.type, + reason: + output.type === 'execution-denied' + ? output.reason + : undefined, + }); + } + } + } + } + return { calls, verdicts, results }; +} + +function proposalStatus( + verdict: boolean | undefined, + result: { type: string; reason?: string } | undefined, +): ProposalStatus { + if (result?.type === 'execution-denied') { + return result.reason?.startsWith(SET_ASIDE_PREFIX) + ? 'set-aside' + : 'declined'; + } + if (result) return result.type.startsWith('error') ? 'failed' : 'applied'; + if (verdict === undefined) return 'pending'; + return verdict ? 'applying' : 'declined'; +} + +/** + * The bubbles and proposal cards for a transcript. Tool messages are skipped, + * and the assistant's text across the steps of one reply (text, a `view` call, + * more text) is joined into one bubble unless a proposal sits between. + * Proposals the app denied on its own (the word-bank check) never show. + */ +export function displayTranscript(messages: ModelMessage[]): DisplayItem[] { + const { calls, verdicts, results } = indexToolCalls(messages); + const out: DisplayItem[] = []; + const addText = (text: string) => { + if (text === '') return; + const last = out[out.length - 1]; + if (last?.role === 'assistant') last.text += `\n\n${text}`; + else out.push({ role: 'assistant', text }); + }; for (const message of messages) { if (message.role === 'user') { out.push({ role: 'user', text: writerText(message) }); } else if (message.role === 'assistant') { - const text = - typeof message.content === 'string' - ? message.content - : message.content - .filter((p) => p.type === 'text') - .map((p) => p.text) - .join(''); - if (text === '') continue; - const last = out[out.length - 1]; - if (last?.role === 'assistant') last.text += `\n\n${text}`; - else out.push({ role: 'assistant', text }); + if (typeof message.content === 'string') { + addText(message.content); + continue; + } + let text = ''; + for (const part of message.content) { + if (part.type === 'text') { + text += part.text; + } else if ( + part.type === 'tool-approval-request' && + !part.isAutomatic + ) { + const call = calls.get(part.toolCallId); + if (!call) continue; + addText(text); + text = ''; + out.push({ + role: 'proposal', + toolCallId: call.toolCallId, + approvalId: part.approvalId, + toolName: call.toolName, + input: call.input, + status: proposalStatus( + verdicts.get(part.approvalId), + results.get(call.toolCallId), + ), + }); + } + } + addText(text); } } return out; } + +/** Proposals still waiting on the writer. */ +export function pendingProposals(messages: ModelMessage[]): ProposalItem[] { + return displayTranscript(messages).filter( + (item): item is ProposalItem => + item.role === 'proposal' && item.status === 'pending', + ); +} + +/** + * The tool message that answers a proposal: the writer's verdict plus the + * tool's result, both written by the app. The SDK skips any approval that + * already has a result, so it only continues the conversation; and the + * transcript stays valid whatever comes after it (a writer turn, a failed + * model call), where an approval alone would leave the call without output. + */ +export function answerProposal( + proposal: ProposalItem, + answer: + | { approved: true; ok: boolean; report: string } + | { approved: false; reason: string }, +): ModelMessage { + return { + role: 'tool', + content: [ + { + type: 'tool-approval-response', + approvalId: proposal.approvalId, + approved: answer.approved, + reason: answer.approved ? undefined : answer.reason, + }, + { + type: 'tool-result', + toolCallId: proposal.toolCallId, + toolName: proposal.toolName, + output: !answer.approved + ? { type: 'execution-denied', reason: answer.reason } + : answer.ok + ? { type: 'text', value: answer.report } + : { type: 'error-text', value: answer.report }, + }, + ], + }; +} + +/** The reason the model reads when the writer moved on without answering. */ +export function setAsideReason(writerSaid: string): string { + return `${SET_ASIDE_PREFIX} and said: "${writerSaid}"`; +} From b9e062de941289bc85664ed7d4f54bd990d5bc1d Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Sat, 31 Oct 2026 17:52:57 -0400 Subject: [PATCH 08/13] Relax the overlap rule a bit: allow stitching together pieces from the corpus, if overlap is at least two words. Also remove `as` as a stitch word, since it reads LLM-y. --- .../src/docTools/__tests__/corpus.test.ts | 30 ++++++++++++++++++ frontend/src/docTools/corpus.ts | Bin 5967 -> 7809 bytes frontend/src/docTools/editTools.ts | 2 +- frontend/src/docTools/report.ts | 4 +-- frontend/src/pages/chat/chatTools.ts | 2 +- 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/frontend/src/docTools/__tests__/corpus.test.ts b/frontend/src/docTools/__tests__/corpus.test.ts index 5a565371..b07ffdca 100644 --- a/frontend/src/docTools/__tests__/corpus.test.ts +++ b/frontend/src/docTools/__tests__/corpus.test.ts @@ -66,6 +66,34 @@ describe('validateText — phrase-level rule', () => { expect(validateText('alpha beta gamma', corpus).ok).toBe(false); }); + it('stitches pieces from different sources that overlap by two words', () => { + // The case that motivated the overlap rule: the writer said the first + // half in chat; the document has the second. + const corpus = buildCorpus({ + docText: + "Friction doesn't suffice, though. People see friction as repulsive, not inviting.", + userMessages: [ + 'Reword it like "friction doesn\'t suffice because people see.."', + ], + }); + expect( + validateText( + "Friction doesn't suffice because people see friction as repulsive, not inviting.", + corpus, + ).ok, + ).toBe(true); + }); + + it('says where a run breaks, for the model to repair', () => { + const corpus = corpusOf('alpha beta. beta gamma.'); + expect(validateText('alpha beta gamma', corpus).problem).toBe( + 'the writer\'s words have "alpha beta", but never followed by "gamma" (pieces of their words can only be joined where they overlap by at least two words)', + ); + expect(validateText('alpha delta', corpus).problem).toBe( + '"delta" isn\'t in the writer\'s words', + ); + }); + it('rejects a novel content word the writer never used', () => { const corpus = corpusOf('I enjoy writing essays.'); const result = validateText('I enjoy painting', corpus); @@ -115,6 +143,8 @@ describe('GLUE_WORDS', () => { expect(GLUE_WORDS.has('and')).toBe(true); expect(GLUE_WORDS.has('the')).toBe(true); expect(GLUE_WORDS.has('because')).toBe(false); + // "as" also means "because" ("as people see…"). + expect(GLUE_WORDS.has('as')).toBe(false); expect(GLUE_WORDS.has('however')).toBe(false); }); }); diff --git a/frontend/src/docTools/corpus.ts b/frontend/src/docTools/corpus.ts index ab1429e23c347855c23fb1c1227e110fa77b6b24..f055a4ca800858b7bfacee4434f6c59e4464ce06 100644 GIT binary patch delta 2304 zcmZ8jO^+Kz5LG@98VhLyD1cBDRjerM+01%39KA6i3W0==fR-p6k_fl$_Riq(wAJ0S zX0@1s3r8-#bL5P)KZGk1H*Wj`kPzZk^~|neFYD>4u8&u*Ud=Dv@A^Ogdj9%I6*Sb8 zXA`f-G_htzKGifbc3!#`Z4v%fuUz=Px=Xe!D07WzHlfUun$NYejt;dQDxb|LE65a@ zoGI-{CuA(u5uDHbDAi;3DPc~p%riaGjuxr5nzS0F^reBDEdz^O%^eMoNL8M4t(+&n zFtjjs?6!KGDZ5+P%xk;psQvr5KMnOrl}^)Kn|Y2zv~H6s#?(GLK>TdvGgGufBvUkR z*&E;J(q~2@jZEQn;b~EENSdi5l$RfoL+#!iXk=jP%o(XCRp^XYl74F&uG;)a2WmFM;n)^})q! z7ZCkqdhyDIlOJCE``rC#XKRZ-7mN|oOE)Jf%U!}f6GWx+Gc(q?G);{>66?~|PAi&Q zGtBkuq>BD|;rz4R)i)dWt7p5LaZJm-*0R-N=MT&R`4mUBGeu3yL(Q`i-E&+db%3!1 zo2OPey`Aer#I$9uV+jY;Vf*pJM_+vM8 zHTr8N;xRYHfyQMa6(6$cAPJNkIIpbdCsX3eDFXojcQ24$GMJQwFobG4?1o|x%QGuKiKTGqD7j4gB{NAHY486b#dTRx^ZK5_0pSFB4;ko zM!F+z6S;qrZpCrjiXz;*c@u7|_N6WOT>~~V>mGs_?0x|Ga!QkmQw{9@-e{2FX#jaJu!I>9%a4@C+z2ai&;5h;n9UD57J%$H!BW; zRuuZHrrQeQq`W5l=NPqqkYbObWjrlTWQ$xmbd|U4h5-O;p<{Wb?Y1<6&ujEh2uekz zP%j6l0#2M7;-ExMbf&AqAmXLc8(*7+I}RGG?DhnFmi0 zz{oLex7V3~`?ZOnUh|yZ4%2mKeOUe*T0F+o3lxqbERPN}5S_a%VPfDb6c2a^0AXe! zn<$(!fzU)zU9tG%XMWKq&IV7*(@9x}_2jRY&AFEZpjyRN`>+1l52e<7uPh@v|*XkR~OPs=u@fM=wZtJAe7;yn=S9t~u?nvEEAJynO;64#IG zM7lw7>U0>=1oc<^1<+MK?y^dBHfb|nwi6m+mle=wTaPB>1N-`?fF_{e5&7W?DV%^nXJA9OR?*`d3bF^K4xQDhasQps~t)CtFN n^F89`s$gNabeOqpnB|#2>b6!pm$y#_m;X4okx1ev&X&xd)LgaIV^OEl7b)b zcSJ&RlQv&Kn>s}#Bsy9O)+f=h-RygB-;a*6odkUOOi2K&H)V|L|R!+ zOI?}(2GAZU)c{z5osdJNC{6v;x%qzL#+_m!XlMtb2?|D*#4&O*kciIb0w)Hci;{(M zQuYw+RD(|D*i_gl`g5b!*5NSK5*x^|0j=Q}#f!{~d9|ou4QT@kXZGOJ}?=`=J$F8?f|62a! zFZ9C@W=FxS8LxcoH1`{+SIhf Date: Sat, 31 Oct 2026 17:54:52 -0400 Subject: [PATCH 09/13] describe the overlap rule to the model --- docs/chat-document-tools.md | 7 ++++++- frontend/src/docTools/editTools.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/chat-document-tools.md b/docs/chat-document-tools.md index 7d58ae93..8670bce3 100644 --- a/docs/chat-document-tools.md +++ b/docs/chat-document-tools.md @@ -34,7 +34,12 @@ On hosts with `applyEdit` (`canEdit`), Chat offers `str_replace`, `insert` and words as the reason; 3. otherwise `'user-approval'`: the stream stops and the card appears. -Denials go back to the model, never to the writer. Word bank: the live +Denials go back to the model, never to the writer, saying where the text +breaks ("…have "alpha beta", but never followed by "gamma""). The check +(`docTools/corpus.ts`) accepts runs of content words lifted verbatim, or +stitched from pieces that overlap by at least two words, joined by punctuation +and a closed set of glue words (no connectives: "as" was dropped because it +can mean "because"). Word bank: the live document, every paragraph the model has seen this conversation (so deleted words can be reinstated), the writer's typed messages, and the brief, but not the `` blocks or assistant text. diff --git a/frontend/src/docTools/editTools.ts b/frontend/src/docTools/editTools.ts index 586522e1..05f0ebab 100644 --- a/frontend/src/docTools/editTools.ts +++ b/frontend/src/docTools/editTools.ts @@ -9,7 +9,7 @@ import { z } from 'zod'; import type { EditOp } from './types'; const OWN_WORDS = - 'Every word you add must be lifted from the writer’s own words (the document and what they’ve said to you), joined only by punctuation and small glue words (a, an, the, and, or, but, of, to, in, on, at, by, for, with, from); anything else is rejected before the writer sees it.'; + 'Every word you add must be lifted from the writer’s own words (the document and what they’ve said to you). Prefer their exact phrasing. Pieces of their words may be stitched together where they overlap by two or more words (“…because people see” + “people see things), or joined by punctuation and small glue words (a, an, the, and, or, but, of, to, in, on, at, by, for, with, from); anything else is rejected before the writer sees it.'; const paragraph = z .number() From 64d5758544fa1055c9b6994a59ac6d2b0565a1d0 Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Sat, 31 Oct 2026 17:58:51 -0400 Subject: [PATCH 10/13] Remove the My Words text prototype; Chat supersedes it Walkthrough/Propose, their interaction/ seam, and the scripted demo page are gone: Chat's own-words edits replace Propose. My Words is now the voice prototype alone, on the shared docTools/. The mock editor moves to __fixtures__ for the voice tests. Docs that described the removed code point at docs/chat-document-tools.md. Co-Authored-By: Claude Opus 5.5 --- docs/my-words-interaction-design.md | 8 + docs/my-words-voice-native-research.md | 5 +- docs/prompt-packs-plan.md | 12 +- frontend/.gitignore | 3 - frontend/mywords-demo.html | 23 -- frontend/src/docTools/editTools.ts | 2 +- .../src/pages/my-words/InteractionPanel.tsx | 171 --------------- .../{demo => __fixtures__}/mockEditor.ts | 7 +- .../my-words/__tests__/voiceControl.test.ts | 2 +- frontend/src/pages/my-words/demo/DemoApp.tsx | 198 ------------------ frontend/src/pages/my-words/demo/main.tsx | 11 - frontend/src/pages/my-words/demo/scenarios.ts | 144 ------------- .../pages/my-words/demo/scriptedResponder.ts | 26 --- frontend/src/pages/my-words/index.tsx | 186 ++-------------- .../my-words/interaction/liveResponder.ts | 192 ----------------- .../src/pages/my-words/interaction/shared.ts | 49 ----- .../interaction/strategies/propose.ts | 109 ---------- .../interaction/strategies/walkthrough.ts | 64 ------ .../src/pages/my-words/interaction/types.ts | 102 --------- .../my-words/interaction/useInteraction.ts | 81 ------- frontend/src/pages/my-words/styles.module.css | 198 ------------------ frontend/tests/mywords-demo.spec.ts | 48 ----- frontend/vite.config.ts | 1 - 23 files changed, 41 insertions(+), 1601 deletions(-) delete mode 100644 frontend/mywords-demo.html delete mode 100644 frontend/src/pages/my-words/InteractionPanel.tsx rename frontend/src/pages/my-words/{demo => __fixtures__}/mockEditor.ts (90%) delete mode 100644 frontend/src/pages/my-words/demo/DemoApp.tsx delete mode 100644 frontend/src/pages/my-words/demo/main.tsx delete mode 100644 frontend/src/pages/my-words/demo/scenarios.ts delete mode 100644 frontend/src/pages/my-words/demo/scriptedResponder.ts delete mode 100644 frontend/src/pages/my-words/interaction/liveResponder.ts delete mode 100644 frontend/src/pages/my-words/interaction/shared.ts delete mode 100644 frontend/src/pages/my-words/interaction/strategies/propose.ts delete mode 100644 frontend/src/pages/my-words/interaction/strategies/walkthrough.ts delete mode 100644 frontend/src/pages/my-words/interaction/types.ts delete mode 100644 frontend/src/pages/my-words/interaction/useInteraction.ts delete mode 100644 frontend/src/pages/my-words/styles.module.css delete mode 100644 frontend/tests/mywords-demo.spec.ts diff --git a/docs/my-words-interaction-design.md b/docs/my-words-interaction-design.md index 05020b4b..fd79caca 100644 --- a/docs/my-words-interaction-design.md +++ b/docs/my-words-interaction-design.md @@ -1,5 +1,13 @@ # My Words — Interaction Design Note +> **Historical (2026-10-31).** The text prototype described here (the +> Walkthrough/Propose strategies, `interaction/`, the `demo/` playback) has been +> removed. Propose's design — every edit staged behind the writer's verdict, +> which becomes the model's tool result — lives on in Chat's own-words edits: +> see [chat-document-tools.md](chat-document-tools.md). Voice remains, on the +> shared `frontend/src/docTools/`. Code paths below refer to the removed code +> (recoverable from git history). + **Status:** design exploration. Captures *why* the current My Words interaction feels wrong and the direction to fix it. This is a design brief, not an implementation diff — the implementation sketch at the end is a starting point, diff --git a/docs/my-words-voice-native-research.md b/docs/my-words-voice-native-research.md index bd78e572..80f2fccb 100644 --- a/docs/my-words-voice-native-research.md +++ b/docs/my-words-voice-native-research.md @@ -7,7 +7,10 @@ hands-free, genuinely collaborative), the platform options, and the questions worth resolving before building. **Companion to** `docs/my-words-interaction-design.md` (the text-prototype design -brief; branch `claude/mywords-interaction-design-cuvkl3`). That note argues the +brief; branch `claude/mywords-interaction-design-cuvkl3`). The text prototype +has since been removed in favour of Chat's own-words edits +([chat-document-tools.md](chat-document-tools.md)); references below to +`interaction/` are historical. That note argues the *turn* should be one conversational move, then yield the floor. This note asks what happens when the floor is made of *sound*. diff --git a/docs/prompt-packs-plan.md b/docs/prompt-packs-plan.md index 485f97f9..fc2c1bb4 100644 --- a/docs/prompt-packs-plan.md +++ b/docs/prompt-packs-plan.md @@ -61,11 +61,11 @@ A pack is a small JSON document. Format sketch (field names provisional): } ``` -Execution: the pack's `prompt` becomes the `instructions` of a tool loop (the -manual loop pattern proven in -`frontend/src/pages/my-words/interaction/liveResponder.ts` — tools carry -schemas but no `execute`; the app routes each call), restricted to the tools -the pack declares. The loop runs through the existing LLM proxy under the +Execution: the pack's `prompt` becomes the `instructions` of a tool loop +(Chat's, `frontend/src/pages/chat/`: `streamText` with `toolApproval`, where the +app gates and answers each consequential call itself — see +[chat-document-tools.md](chat-document-tools.md)), restricted to the tools the +pack declares. The loop runs through the existing LLM proxy under the user's own session; `pack_id` joins `client_id` in the usage and log attribution. @@ -123,7 +123,7 @@ editor, under the covenant. | Tool | Essential schema | Who needs it | Precedent | Notes | | --- | --- | --- | --- | --- | -| `view` | `{} → numbered paragraphs` | nearly everything | my-words `liveResponder`; moving into Chat per [#583](https://github.com/AIToolsLab/writing-tools/issues/583) | Paragraph numbers are loop-internal coordinates, never shown to the writer (my-words rule). | +| `view` | `{} → numbered paragraphs` | nearly everything | Chat (`pages/chat/chatTools.ts`), per [#583](https://github.com/AIToolsLab/writing-tools/issues/583) | Paragraph numbers are loop-internal coordinates, never shown to the writer (my-words rule). | | `highlight` | `{phrase} → ok \| not_found` | Say-Back, Prior Self (*quote*), Table Read, Marginalia, doctext-style citations | my-words `highlight`; `EditorAPI.selectPhrase`; `docTextLink` | Anchor with a W3C **`TextQuoteSelector`** (exact + prefix/suffix), not a bare needle — see [Reuse over invention](#reuse-over-invention). Fixes [#589](https://github.com/AIToolsLab/writing-tools/issues/589), [#591](https://github.com/AIToolsLab/writing-tools/issues/591), [#592](https://github.com/AIToolsLab/writing-tools/issues/592) once for every pack. | | `annotate` | `{selector, note, kind} → ok` | Marginalia, Reader's Reply, Table Read, any `lens` pack | Word comment API; Google Docs comments blocked on a Drive scope ([#580](https://github.com/AIToolsLab/writing-tools/issues/580)) | Same selector model. Falls back to an in-pane annotation list where the host can't do margin comments. | | `propose_edit` | `{selector, replace, rationale} → accepted \| rejected \| not_found` | Blind Rewrite, rewording flows, my-words ops | my-words `str_replace`/`insert`/`move` + propose strategy; rooms plan's patch-with-confirm | **Always** confirm-gated. The `rationale` renders on the confirm card — the writer sees why, not just what. | diff --git a/frontend/.gitignore b/frontend/.gitignore index adb20877..26cb07ad 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -9,6 +9,3 @@ node_modules/ /blob-report/ /playwright/.cache/ /playwright/.auth/ - -# Generated demo recordings (reproduce with `npx playwright test mywords-demo`) -/mywords-videos/ diff --git a/frontend/mywords-demo.html b/frontend/mywords-demo.html deleted file mode 100644 index 0b1148df..00000000 --- a/frontend/mywords-demo.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - My Words — interaction demo - - - -
- - - diff --git a/frontend/src/docTools/editTools.ts b/frontend/src/docTools/editTools.ts index 05f0ebab..48a18613 100644 --- a/frontend/src/docTools/editTools.ts +++ b/frontend/src/docTools/editTools.ts @@ -9,7 +9,7 @@ import { z } from 'zod'; import type { EditOp } from './types'; const OWN_WORDS = - 'Every word you add must be lifted from the writer’s own words (the document and what they’ve said to you). Prefer their exact phrasing. Pieces of their words may be stitched together where they overlap by two or more words (“…because people see” + “people see things), or joined by punctuation and small glue words (a, an, the, and, or, but, of, to, in, on, at, by, for, with, from); anything else is rejected before the writer sees it.'; + 'Every word you add must be lifted from the writer’s own words (the document and what they’ve said to you). Prefer their exact phrasing. Pieces of their words may be stitched together where they overlap by two or more words (“…because people see” + “people see things”), or joined by punctuation and small glue words (a, an, the, and, or, but, of, to, in, on, at, by, for, with, from); anything else is rejected before the writer sees it.'; const paragraph = z .number() diff --git a/frontend/src/pages/my-words/InteractionPanel.tsx b/frontend/src/pages/my-words/InteractionPanel.tsx deleted file mode 100644 index c815b948..00000000 --- a/frontend/src/pages/my-words/InteractionPanel.tsx +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Presentational panel for the My Words interaction. - * - * Layout: the writer's words (scratchpad) fill the top and grow; everything - * conversational lives in one zone at the bottom, next to where they reply — the - * model's fleeting utterance (or a staged proposal) just above the input. - * - * The model's speech is rendered non-selectable: it contributes no words to the - * document, so the writer shouldn't be able to lift its phrasing. Sent messages - * aren't shown separately — the page appends them to the scratchpad, since they - * are part of the writer's word bank too. - */ - -import { AiOutlineSend } from 'react-icons/ai'; - -import classes from './styles.module.css'; -import type { Awaiting, PendingProposal } from './interaction/types'; - -export interface InteractionPanelProps { - caption: string; - isThinking: boolean; - awaiting: Awaiting; - pending: PendingProposal | null; - scratchpad: string; - onScratchpadChange: (v: string) => void; - input: string; - onInputChange: (v: string) => void; - onSend: () => void; - onContinue: () => void; - onAccept: () => void; - onReject: () => void; - /** Disable controls (e.g. while a demo plays itself). */ - disabled?: boolean; -} - -export function InteractionPanel(props: InteractionPanelProps) { - const { - caption, - isThinking, - awaiting, - pending, - scratchpad, - onScratchpadChange, - input, - onInputChange, - onSend, - onContinue, - onAccept, - onReject, - disabled, - } = props; - - const blocked = disabled || isThinking; - const showProposal = awaiting === 'decision' && pending; - - return ( -
- -