diff --git a/docs/chat-document-tools.md b/docs/chat-document-tools.md new file mode 100644 index 00000000..f9c951f4 --- /dev/null +++ b/docs/chat-document-tools.md @@ -0,0 +1,81 @@ +# 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. + +The card does not select anything in the document on its own: the writer may +be typing there, and a selection that appears under them is replaced by their +next keystroke. "Show in document" selects the target on request (trimming the +quote until it's found, since Word's search rejects long strings). + +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. + +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. `applyProposal` is the +only path that edits the document: the tools' `execute` throws, so if the SDK +ever did run one, the proposal shows as failed rather than being applied twice. + +## 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. A splice that removes nothing (putting back a paragraph a `move` +took out) has no text of its own to find, so it records the ~32 characters on +each side of its gap (`prefix`/`suffix`, like #589's `TextAnchor`, across +paragraph breaks so blank or short neighbors don't leave it ambiguous) and is +relocated by those, by the same rule. Blank paragraphs count literally for now: +adding or removing a blank line at the spot makes the undo refuse (see the +comment on `contextAt` in `undo.ts` for the alternative). 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/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/my-words-voice-scratchpad.md b/docs/my-words-voice-scratchpad.md index 5cf3af8c..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. @@ -183,8 +184,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/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/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/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/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; } } 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/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 c06d780d..9ba6fbb0 100644 --- a/frontend/src/contexts/chatContext.tsx +++ b/frontend/src/contexts/chatContext.tsx @@ -1,28 +1,82 @@ +import type { ModelMessage } from 'ai'; import { + type Dispatch, type PropsWithChildren, + type SetStateAction, createContext, - useState, useMemo, + useState, } from 'react'; -// Stores chat messages in a context so that they are saved when a user switches between tabs +import type { UndoSplice } from '@/docTools/undo'; + +/** + * 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: UndoSplice[]; + 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: ChatMessage[]; - updateChatMessages: (chatMessages: ChatMessage[]) => void; + chatMessages: ModelMessage[]; + updateChatMessages: (chatMessages: ModelMessage[]) => void; + docState: ChatDocState; + appliedEdits: Record; + setAppliedEdits: Dispatch>>; }>({ chatMessages: [], - updateChatMessages: (_chatMessages: ChatMessage[]) => {}, + updateChatMessages: (_chatMessages: ModelMessage[]) => {}, + docState: { baseline: null, seen: new Set() }, + appliedEdits: {}, + setAppliedEdits: () => {}, }); export default function ChatContextWrapper({ children }: PropsWithChildren) { - const [chatMessages, updateChatMessages] = useState([]); + const [chatMessages, updateChatMessages] = useState([]); + // 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, + docState, + appliedEdits, + setAppliedEdits, }), - [chatMessages], + [chatMessages, docState, appliedEdits], ); return ( 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/pages/my-words/__tests__/corpus.test.ts b/frontend/src/docTools/__tests__/corpus.test.ts similarity index 80% rename from frontend/src/pages/my-words/__tests__/corpus.test.ts rename to frontend/src/docTools/__tests__/corpus.test.ts index 5a565371..b07ffdca 100644 --- a/frontend/src/pages/my-words/__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/__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/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/docTools/__tests__/previewUndo.test.ts b/frontend/src/docTools/__tests__/previewUndo.test.ts new file mode 100644 index 00000000..56aa7a81 --- /dev/null +++ b/frontend/src/docTools/__tests__/previewUndo.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; + +import { applyOpLogged, applySplice } from '../ops'; +import { previewChange, previewOpChanges } from '../preview'; +import { captureUndo, planUndo } from '../undo'; +import type { EditOp } from '../types'; + +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.'); + }); +}); + +/** Apply an op, capturing its undo as Chat does. */ +function logged(doc: string[], op: EditOp) { + const { after, splices } = applyOpLogged(doc, op); + return { after, undo: captureUndo(after, splices) }; +} + +describe('planUndo', () => { + const doc = ['Intro.', 'The cat sat.', 'End.']; + const replace = logged(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 = logged(doc, { + kind: 'move', + phrase: 'Intro.', + paragraph: 2, + }); + const plan = planUndo(moved.after, moved.undo); + expect(plan!.reduce(applySplice, moved.after)).toEqual(doc); + }); + + describe('a move, after later changes', () => { + const four = ['A.', 'B.', 'C.', 'D.']; + const moved = logged(four, { + kind: 'move', + phrase: 'D.', + paragraph: 1, + position: 'before', + }); + + it('puts the paragraph back between its old neighbors', () => { + const shifted = ['New.', ...moved.after]; + const plan = planUndo(shifted, moved.undo); + expect(plan!.reduce(applySplice, shifted)).toEqual(['New.', ...four]); + }); + + it('refuses when the text at its old spot has changed', () => { + const edited = moved.after.map((p) => (p === 'C.' ? 'C, edited.' : p)); + expect(planUndo(edited, moved.undo)).toBeNull(); + }); + }); + + it('undoes a move after an edit away from the spot', () => { + const long = [ + 'An opening paragraph, long enough that its start is out of range.', + 'Moved.', + 'A closing paragraph, also long enough to have a middle of its own.', + ]; + const moved = logged(long, { + kind: 'move', + phrase: 'Moved.', + paragraph: 1, + position: 'before', + }); + const edited = moved.after.map((p) => + p.replace('An opening', 'The first'), + ); + const plan = planUndo(edited, moved.undo); + expect(plan!.reduce(applySplice, edited)).toEqual([ + 'The first paragraph, long enough that its start is out of range.', + 'Moved.', + 'A closing paragraph, also long enough to have a middle of its own.', + ]); + }); + + describe('a move next to blank paragraphs', () => { + const spaced = ['First paragraph.', '', 'Second paragraph.', '', 'Last.']; + const moved = logged(spaced, { + kind: 'move', + phrase: 'Second paragraph.', + paragraph: 1, + position: 'before', + }); + + it('finds its spot by the text past the blank lines', () => { + const shifted = ['New.', '', ...moved.after]; + const plan = planUndo(shifted, moved.undo); + expect(plan!.reduce(applySplice, shifted)).toEqual([ + 'New.', + '', + ...spaced, + ]); + }); + + it('refuses when a blank line at the spot was removed', () => { + const at = moved.after.indexOf('First paragraph.') + 1; + const closed = moved.after.filter((_, i) => i !== at); + expect(planUndo(closed, moved.undo)).toBeNull(); + }); + }); +}); diff --git a/frontend/src/pages/my-words/__tests__/spliceAdapter.test.ts b/frontend/src/docTools/__tests__/spliceAdapter.test.ts similarity index 96% rename from frontend/src/pages/my-words/__tests__/spliceAdapter.test.ts rename to frontend/src/docTools/__tests__/spliceAdapter.test.ts index 3b703277..519a22e5 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 @@ -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/pages/my-words/corpus.ts b/frontend/src/docTools/corpus.ts similarity index 65% rename from frontend/src/pages/my-words/corpus.ts rename to frontend/src/docTools/corpus.ts index ab1429e2..f055a4ca 100644 Binary files a/frontend/src/pages/my-words/corpus.ts and b/frontend/src/docTools/corpus.ts differ 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/docTools/editTools.ts b/frontend/src/docTools/editTools.ts new file mode 100644 index 00000000..48a18613 --- /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). 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() + .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/pages/my-words/interaction/editor.ts b/frontend/src/docTools/editor.ts similarity index 96% rename from frontend/src/pages/my-words/interaction/editor.ts rename to frontend/src/docTools/editor.ts index 7a447c6b..d6b65715 100644 --- a/frontend/src/pages/my-words/interaction/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/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/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/pages/my-words/interaction/shared.ts b/frontend/src/docTools/report.ts similarity index 72% rename from frontend/src/pages/my-words/interaction/shared.ts rename to frontend/src/docTools/report.ts index 0bb6e91c..95f5dcf4 100644 --- a/frontend/src/pages/my-words/interaction/shared.ts +++ b/frontend/src/docTools/report.ts @@ -1,20 +1,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. + * 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 { validateText, type Corpus } from './corpus'; +import { applyEditOp, type ApplyEditOptions } from './editor'; import { describeOp } from './ops'; -import type { - AssistantMove, - EditOp, - StrategyContext, -} from './types'; +import type { EditOp } from './types'; const clip = (s: string, n = 40) => s.length > n ? `${s.slice(0, n - 1)}…` : s; @@ -152,23 +145,37 @@ function targetParagraph(op: EditOp): number | undefined { * re-orient and retry instead of flying blind. */ export async function applyOpAndReport( - ctx: StrategyContext, + 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(ctx.editor, op); + const splices = await applyEditOp(editor, op, opts); + const paragraphs = await editor.getParagraphs(); return { ok: true, - report: appliedReport(await ctx.editor.getParagraphs(), op), + report: appliedReport(paragraphs, op), + splices, + paragraphs, }; } catch (e) { const window = numberedWindow( - await ctx.editor.getParagraphs(), + await editor.getParagraphs(), targetParagraph(op), 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.`, }; } @@ -178,48 +185,10 @@ export async function applyOpAndReport( export function validateOp( op: EditOp, corpus: Corpus, -): { ok: boolean; offending?: string } { +): { ok: boolean; offending?: string; problem?: 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 }; -} - -/** - * Pump the model through read-only moves (`view`, `highlight`) — which are not - * commitments — until it produces something a strategy must decide on: an - * `edit`, or pure speech. Read-only results are fed back so the model can react. - * Capped so a confused model can't spin forever. - */ -export async function advanceToDecision( - ctx: StrategyContext, - maxSteps = 6, -): Promise { - for (let i = 0; i < maxSteps; i++) { - const move = await ctx.responder.next(); - if (move.say) ctx.setCaption(move.say); - - const action = move.action; - if (!action) return move; // pure speech: floor passes back - if (action.tool === 'edit') return move; // a commitment to decide on - - if (action.tool === 'view') { - ctx.responder.recordToolResult(await viewText(ctx.editor)); - continue; - } - if (action.tool === 'highlight') { - try { - await ctx.editor.selectPhrase(action.phrase); - ctx.responder.recordToolResult('Highlighted.'); - } catch { - ctx.responder.recordToolResult( - `Could not find "${action.phrase}".`, - ); - } - continue; - } - } - // Hit the cap: nudge the model to speak next turn. - return { say: 'Let me know how you’d like to proceed.' }; + return { ok: res.ok, offending: res.offending, problem: res.problem }; } 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/docTools/undo.ts b/frontend/src/docTools/undo.ts new file mode 100644 index 00000000..d8aa81e5 --- /dev/null +++ b/frontend/src/docTools/undo.ts @@ -0,0 +1,146 @@ +/** + * 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. putting back a paragraph a + * `move` took out) have no text of their own to find, so `captureUndo` records + * the text on either side of the gap (`prefix`/`suffix`, as in #589's + * `TextAnchor`) and they're relocated by that, by the same rule: exactly one + * match, or refuse. 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, invertSplices, spliceIsFresh } from './ops'; + +/** + * An undo step. Put-backs (empty `remove`) carry the text around their gap. + * Kept off `ParagraphSplice`: these are long-lived anchors, not the exact + * preconditions #589 plans for hosts to check. + */ +export interface UndoSplice extends ParagraphSplice { + prefix?: string; + suffix?: string; +} + +/** How much text on each side of a gap identifies it. */ +const CONTEXT_CHARS = 32; + +/** + * The text just before and after the gap at paragraph `index`, each paragraph + * ending in a line break, so the window reaches past short or blank + * paragraphs into real text. + * + * Blank paragraphs count literally, so adding or removing a blank line at the + * spot changes the window and the undo refuses. If `edit_undo_refused` shows + * that's common (Word writers often space with blank lines), build the window + * from non-blank paragraphs instead; every slot within one run of blank lines + * then matches, so take the one nearest the recorded index. + */ +function contextAt( + paragraphs: string[], + index: number, +): { prefix: string; suffix: string } { + let prefix = ''; + for (let i = index - 1; i >= 0 && prefix.length < CONTEXT_CHARS; i--) + prefix = `${paragraphs[i]}\n${prefix}`; + let suffix = ''; + for (let i = index; i < paragraphs.length && suffix.length < CONTEXT_CHARS; i++) + suffix += `${paragraphs[i]}\n`; + return { + prefix: prefix.slice(-CONTEXT_CHARS), + suffix: suffix.slice(0, CONTEXT_CHARS), + }; +} + +/** The undo for `splices`, applied to give `after`, with put-backs anchored. */ +export function captureUndo( + after: string[], + splices: ParagraphSplice[], +): UndoSplice[] { + let current = after; + return invertSplices(splices).map((splice) => { + const anchored = + splice.remove.length === 0 + ? { ...splice, ...contextAt(current, splice.index) } + : splice; + current = applySplice(current, splice); + return anchored; + }); +} + +/** Where `splice` applies in `paragraphs` now, or null if not exactly one place. */ +export function locateSplice( + paragraphs: string[], + splice: UndoSplice, +): number | null { + if (splice.remove.length === 0) return locateGap(paragraphs, splice); + if (spliceIsFresh(paragraphs, splice)) return splice.index; + 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 one gap between paragraphs with the put-back's recorded context. */ +function locateGap( + paragraphs: string[], + { prefix, suffix }: UndoSplice, +): number | null { + if (prefix === undefined || suffix === undefined) return null; + const found: number[] = []; + for (let i = 0; i <= paragraphs.length; i++) { + // A window cut short by the document's start or end is matched as far + // as it goes, so text added beyond it doesn't block the undo. + const at = contextAt(paragraphs, i); + if (at.prefix.endsWith(prefix) && at.suffix.startsWith(suffix)) + 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: UndoSplice[], +): ParagraphSplice[] | null { + const planned: ParagraphSplice[] = []; + let current = paragraphs; + for (const splice of undo) { + const index = locateSplice(current, splice); + if (index === null) return null; + // What the host gets: a plain splice, without the anchoring context. + const anchored = { index, remove: splice.remove, insert: splice.insert }; + 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: UndoSplice[], +): 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/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/chat/ProposalCard.tsx b/frontend/src/pages/chat/ProposalCard.tsx new file mode 100644 index 00000000..775c6fe8 --- /dev/null +++ b/frontend/src/pages/chat/ProposalCard.tsx @@ -0,0 +1,162 @@ +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> = + { + 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, + onShow, +}: { + 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; + /** Select the proposal's target in the document; resolves false if not found. */ + onShow: () => Promise; +}) { + const [full, setFull] = useState(false); + const [showState, setShowState] = useState< + 'idle' | 'showing' | 'not-found' + >('idle'); + const show = async () => { + setShowState('showing'); + const found = await onShow().catch(() => false); + setShowState(found ? 'idle' : 'not-found'); + }; + 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} + {showState === 'not-found' ? ( + + Not found in the document + + ) : null} +
+
+ + +
+
+ ); +} 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__/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/__tests__/transcript.test.ts b/frontend/src/pages/chat/__tests__/transcript.test.ts new file mode 100644 index 00000000..e2b80720 --- /dev/null +++ b/frontend/src/pages/chat/__tests__/transcript.test.ts @@ -0,0 +1,201 @@ +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('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.'); + + const later = documentContext({ + docContext, + brief: 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: ['One.'], + baseline: ['One.'], + }); + expect(context).toMatch(/^\nAudience: reviewers/); + }); + + it('omits the cursor window for an empty document', () => { + const context = documentContext({ + docContext: { beforeCursor: '', selectedText: '', afterCursor: '' }, + brief: null, + paragraphs: [''], + baseline: null, + }); + 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([]); + }); + + it('never re-offers a proposal approved without a result', () => { + // The app writes approval and result together; if only the approval + // is there, the card must not show Accept again. + const messages: ModelMessage[] = [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'c1', + toolName: 'str_replace', + input: { old_str: 'a', new_str: 'b' }, + }, + { + type: 'tool-approval-request', + approvalId: 'a1', + toolCallId: 'c1', + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-approval-response', + approvalId: 'a1', + approved: true, + }, + ], + }, + ]; + expect(displayTranscript(messages)).toEqual([ + expect.objectContaining({ role: 'proposal', status: 'failed' }), + ]); + }); +}); diff --git a/frontend/src/pages/chat/chatTools.ts b/frontend/src/pages/chat/chatTools.ts new file mode 100644 index 00000000..d0a8cdf4 --- /dev/null +++ b/frontend/src/pages/chat/chatTools.ts @@ -0,0 +1,188 @@ +/** + * 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. The app applies an approved edit + * itself (`applyProposal`) and writes its result into the transcript, so the + * SDK never runs these tools' `execute`; it throws rather than editing. + */ + +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 { lowerOp } from '@/docTools/ops'; +import { applyOpAndReport, validateOp, viewParagraphs } from '@/docTools/report'; +import { captureUndo } from '@/docTools/undo'; +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 only code path that edits the document. + * The Accept handler calls it and writes the result into the transcript + * before the model continues. + */ + 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: captureUndo(result.paragraphs, 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, + // Unreachable: every answer to a proposal carries its result, and + // the SDK skips approvals that already have one. If that ever breaks, + // fail visibly rather than edit the document from a second path. + execute: (): Promise => + Promise.reject( + new Error( + 'Edit proposals are applied by the app, not by the tool.', + ), + ), + }); + 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.problem ?? `"${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 e773e86d..80559ef6 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, type ModelMessage } 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'; @@ -25,17 +25,36 @@ import BriefSection from '@/components/briefSection'; import { DocJumpStatus, DocTextMarkdown, + jumpToDocText, 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 { 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 { chatTools } from './chatTools'; +import { ProposalCard } from './ProposalCard'; import classes from './styles.module.css'; +import { + answerProposal, + DECLINED_REASON, + displayTranscript, + documentContext, + pendingProposals, + setAsideReason, + writerTurn, + type ProposalItem, +} from './transcript'; const suggestionPrompts = [ 'What is my main argument?', @@ -51,67 +70,27 @@ 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. 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. -const CHAT_GREETING_MESSAGE: ChatMessage = { - role: 'assistant', - content: 'What do you think about your document so far?', -}; - -/** - * 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; +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.`; -/** - * 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; -} +/** 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.`; -/** - * 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); + const { + chatMessages, + updateChatMessages, + docState, + appliedEdits, + setAppliedEdits, + } = useContext(ChatContext); const editorAPI = useContext(EditorContext); const { brief } = useDocBrief(); const log = useLog(); @@ -119,6 +98,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); @@ -135,6 +117,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(() => { @@ -176,31 +160,55 @@ 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 }); + } + + /** + * 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 }); + // Deliberately not selecting the target here: the writer may be typing + // in the document, and a selection that appears under them gets + // overwritten by the next keystroke. The card's "Show in document" does + // it on request. + }); + 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; @@ -220,52 +228,65 @@ 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 so the model sees the - // document as it is now, then inject it as the doc-context message. - const docContext = await refreshDocContext(); - let newMessages = [ - ...withCurrentDocContext( - chatMessages, - docContext, - formatDocBriefForPrompt(briefRef.current), - ), - { role: 'user', content: text }, - { role: 'assistant', content: '' }, - ]; - - updateChatMessages(newMessages); + updateChatMessages(messages); + messagesRef.current = messages; + setStreamingText(''); setShowScrollButton(false); - updateMessage(''); + const editing = canEdit(editorAPI); + const { tools, toolApproval } = makeTools(); + let streamed = ''; try { - const deltas = streamTextDeltas({ + const parts = streamParts({ model: languageModel, - providerOptions: openaiProviderOptions, - instructions: CHAT_INSTRUCTIONS, - messages: newMessages.slice(0, -1) as ModelMessage[], + 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, }); - 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([...messages, ...next.value]); chatLog.responseCompleted(log, { - responseLength: - newMessages[newMessages.length - 1].content.length, + responseLength: streamed.length, }); } catch (error) { if (requestController.signal.aborted) { @@ -273,14 +294,15 @@ 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)); - updateMessage(text); - setFailedMessage({ text, source }); + // Nothing streamed: roll back. If part of a reply did arrive, keep it + // and just show the error under it. + if (streamed.trim() === '') { + rollback(); + } else { + updateChatMessages([ + ...messages, + { role: 'assistant', content: streamed }, + ]); } setErrorInfo(info); chatLog.responseError(log, { @@ -292,18 +314,157 @@ export default function Chat() { if (activeRequestControllerRef.current === requestController) { activeRequestControllerRef.current = null; updateSendingMessage(false); + setStreamingText(null); } } } + 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)); + } + + /** Select where a proposal would land; false if it can't be found. */ + async function showProposalInDoc(proposal: ProposalItem): Promise { + if (!isEditToolName(proposal.toolName)) return false; + const op = toEditOp(proposal.toolName, proposal.input); + const { highlight } = await locateProposal(editorAPI, op); + if (!highlight) return false; + // Trims the quote until it's found: Word's search rejects long strings. + const { found } = await jumpToDocText( + (phrase) => editorAPI.selectPhrase(phrase), + highlight, + ); + return found; + } + + 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; updateSendingMessage(false); + setStreamingText(null); setErrorInfo(null); setFailedMessage(null); updateMessage(''); updateChatMessages([]); + docState.baseline = null; + docState.seen.clear(); + setAppliedEdits({}); + setUndoErrors({}); chatLog.conversationReset(log); } @@ -376,14 +537,47 @@ export default function Chat() { ) : ( visibleMessages.map((chatMessage, index) => { + if (chatMessage.role === 'proposal') { + return ( + + void acceptProposal(chatMessage) + } + onDecline={() => + void declineProposal(chatMessage) + } + onUndo={() => + void undoProposal(chatMessage) + } + onShow={() => + showProposalInDoc(chatMessage) + } + /> + ); + } const isAssistantTyping = chatMessage.role === 'assistant' && - chatMessage.content === '' && + chatMessage.text === '' && isSendingMessage; return (
{chatMessage.role === 'assistant' ? ( @@ -405,10 +599,10 @@ export default function Chat() { {chatMessage.role === 'assistant' ? ( - {chatMessage.content} + {chatMessage.text} ) : ( - chatMessage.content + 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..c56eb855 --- /dev/null +++ b/frontend/src/pages/chat/proposalCard.module.css @@ -0,0 +1,120 @@ +.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; +} + +.links { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 12px; +} + +.notFound { + font-size: 11px; + color: #9a3b2e; +} + +.undoError { + flex-basis: 100%; + color: #9a3b2e; +} diff --git a/frontend/src/pages/chat/transcript.ts b/frontend/src/pages/chat/transcript.ts new file mode 100644 index 00000000..bef5d149 --- /dev/null +++ b/frontend/src/pages/chat/transcript.ts @@ -0,0 +1,282 @@ +/** + * 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 { editNote } from '@/docTools/editNote'; +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. 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; + /** 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 (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() !== '') { + 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. */ +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' + | '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 }; + +/** + * What the transcript records about each tool call: the call itself, the + * writer's verdict on its approval (if asked), and its result. + */ +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'; + // An approval with no result: the app always writes both together, so + // this shouldn't happen. Never 'pending' (that would offer Accept again). + return verdict ? 'failed' : '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') { + 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}"`; +} 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 ( -
- -