diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index b88fbf67..392dc72f 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -60,6 +60,13 @@ jobs: python -m pip install --upgrade pip pip install -e . pip install pyinstaller uvicorn + - name: Set frontend package version + run: | + VERSION="${{ env.RELEASE_VERSION }}" + VERSION_CLEAN="${VERSION#v}" + python -c "import json; p='src/frontend/package.json'; d=json.load(open(p)); d['version']='${VERSION_CLEAN}'; json.dump(d, open(p, 'w'), indent=2)" + - name: Build Frontend + run: | cd src/frontend npm ci npm run build @@ -151,6 +158,13 @@ jobs: python -m pip install --upgrade pip pip install -e . pip install pyinstaller uvicorn + - name: Set frontend package version + run: | + VERSION="${{ env.RELEASE_VERSION }}" + VERSION_CLEAN="${VERSION#v}" + python -c "import json; p='src/frontend/package.json'; d=json.load(open(p)); d['version']='${VERSION_CLEAN}'; json.dump(d, open(p, 'w'), indent=2)" + - name: Build Frontend + run: | cd src/frontend npm ci npm run build diff --git a/AGENTS.md b/AGENTS.md index 83a897cf..8af7ab6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,6 +167,7 @@ python tools/check_copyright.py . - Never hardcode user-facing English strings in UI components. - Use `react-i18next` translation keys. - Inputs/textareas that edit story text must set `lang={storyLanguage || 'en'}` (or equivalent) and keep spellcheck behavior correct. +- Never hardcode LLM facing prompts or prompt templates, use `instructions.json` and its infrastructure for project specific language prompts and templates. ## 8. Test Data Safety (Strict) diff --git a/openapi.json b/openapi.json index 938fdfaf..3e6c7206 100644 --- a/openapi.json +++ b/openapi.json @@ -2672,6 +2672,67 @@ } } }, + "/api/v1/projects/{project_name}/chat/tools/batches/{batch_id}/chapter-before/{chapter_id}": { + "get": { + "tags": ["Chat"], + "summary": "Api Chat Batch Chapter Before", + "description": "Return the pre-batch content of a chapter for diff-baseline restoration.\n\nUsed by the frontend to reconstruct the baseline state for chapters that\nwere not loaded in memory when an AI tool modified them.", + "operationId": "api_chat_batch_chapter_before_api_v1_projects__project_name__chat_tools_batches__batch_id__chapter_before__chapter_id__get", + "parameters": [ + { + "name": "batch_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Batch Id" + } + }, + { + "name": "chapter_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Chapter Id" + } + }, + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChapterBeforeContentResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/projects/{project_name}/chat/stream": { "post": { "tags": ["Chat"], @@ -3628,6 +3689,18 @@ "title": "BooksReorderRequest", "description": "Request body for reordering books." }, + "ChapterBeforeContentResponse": { + "properties": { + "content": { + "type": "string", + "title": "Content" + } + }, + "type": "object", + "required": ["content"], + "title": "ChapterBeforeContentResponse", + "description": "Response body for ``GET /api/v1/chat/tools/batches/{batch_id}/chapter-before/{chapter_id}``." + }, "ChapterContentUpdate": { "properties": { "content": { @@ -3982,6 +4055,17 @@ ], "title": "Editing Scratchpad" }, + "projectContextRevision": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Projectcontextrevision" + }, "created_at": { "anyOf": [ { @@ -5673,6 +5757,48 @@ "title": "ReplaceAllRequest", "description": "Request to replace all occurrences of a search query." }, + "ReplaceChangeLocation": { + "properties": { + "type": { + "type": "string", + "title": "Type", + "description": "One of: chapter, story, metadata, sourcebook, book" + }, + "target_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target Id", + "description": "Target identifier for the changed section, e.g. chapter ID or sourcebook entry name" + }, + "field": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Field", + "description": "Optional field name or metadata subfield affected by the replacement" + }, + "label": { + "type": "string", + "title": "Label", + "description": "Human-readable label for the changed section" + } + }, + "type": "object", + "required": ["type", "label"], + "title": "ReplaceChangeLocation", + "description": "Structured information about a single replaced section." + }, "ReplaceResponse": { "properties": { "replacements_made": { @@ -5688,6 +5814,14 @@ "type": "array", "title": "Changed Sections", "description": "Human-readable labels for each changed section" + }, + "changed_sections_meta": { + "items": { + "$ref": "#/components/schemas/ReplaceChangeLocation" + }, + "type": "array", + "title": "Changed Sections Meta", + "description": "Structured information for each changed section" } }, "type": "object", diff --git a/resources/config/instructions.json b/resources/config/instructions.json index e7006c2d..df5e1649 100644 --- a/resources/config/instructions.json +++ b/resources/config/instructions.json @@ -120,7 +120,7 @@ "Treat conflicts on the active writing unit as the authoritative source of unresolved dramatic tension; do not substitute generic notes for conflicts.", "If a conflict lacks a resolution plan, propose one with a specific draft target or scene target and ask the user to confirm before proceeding.", "- Do NOT call call_writing_llm until one or more active conflicts are documented for the target writing unit (story/chapter) and a resolution direction is in place.", - "Use call_writing_llm whenever new story prose, new scenes, or other fresh narrative text must be produced. Provide only the parameters required by the active function signature.", + "Use call_writing_llm whenever new story prose, new scenes, or other fresh narrative text must be produced. call_writing_llm is stateless: the WRITING LLM sees only the instruction/context you pass in that single call. Always include all required context explicitly (relevant chapter excerpts, conflict status, constraints, POV/style guidance, and exact IDs).", "Use call_editing_assistant ONLY when existing prose text already stored in the current draft must be corrected, refined, rewritten, or structurally revised.", "", "DEFAULT WORKFLOW (guideline, not a rigid law):", @@ -139,16 +139,18 @@ "- After major milestones, it is acceptable to pause and ask whether the user wants to continue unless the user asked for mostly autonomous progress.", "", "CORE TOOLS:", - "- call_writing_llm: Use this whenever new story prose, new scene text, or any other fresh narrative content must be written. Set write_mode='append' to add to the end of a chapter, 'replace' to overwrite, 'insert_at_marker' to insert at ~~~, or omit write_mode to get text without writing. Provide only the parameters shown in the active function schema.", + "- call_writing_llm: Use this whenever new story prose, new scene text, or any other fresh narrative content must be written. This tool is stateless: never assume the WRITING LLM knows chapter order, chapter IDs, or current chapter content unless you pass that data in this call. Set write_mode='append' to add to the end of a chapter, 'replace' to swap out a specific passage (provide replace_target with the exact text to replace — first occurrence only), 'replace_all' to REPLACE THE COMPLETE EXISTING CONTENT of the chapter/draft (all prior text is permanently overwritten — use only for full rewrites), 'insert_at_marker' to insert at ~~~, or omit write_mode to get text without writing. For append continuity, provide preceding_content with the prose immediately before the insertion point when available; if omitted, the backend auto-fills it from the target chapter tail. Provide only the parameters shown in the active function schema.", "- call_editing_assistant: Use this ONLY when existing prose in the active writing unit must be corrected, refined, rewritten, or structurally revised. The task argument must always reference actual stored project text.", - "- get_project_overview: List the current structure with IDs for the active project. This overview omits per-unit notes by default; request them when needed.", - "- get_story_metadata / update_story_metadata: Maintain story-level metadata (title, summary, tags, notes, conflicts when present). These are the only story-metadata tools; get_story_summary and set_story_tags no longer exist as separate tools.", - "- get_chapter_metadata / update_chapter_metadata / get_chapter_summaries / write_chapter_summary / sync_summary: Maintain chapter-level metadata for projects that support chapters. Conflicts are first class here; prefer conflict resolution updates over generic notes updates.", + "- get_project_overview: List the current structure with IDs for the active project. This overview includes per-unit notes by default; omit them only when the overview must stay brief.", + "- get_story_metadata / update_story_metadata: Maintain story-level metadata (title, summary, tags, notes, conflicts when present). Prefer *_patch arguments (summary_patch, notes_patch, tags_patch, conflicts_patch) for partial edits so existing content is preserved unless explicit full replacement is intended. These are the only story-metadata tools; get_story_summary and set_story_tags no longer exist as separate tools.", + "- get_chapter_metadata / update_chapter_metadata / get_chapter_summaries / write_chapter_summary / sync_summary: Maintain chapter-level metadata for projects that support chapters. Prefer *_patch arguments (summary_patch, notes_patch, conflicts_patch) for partial edits. Conflicts are first class here; prefer conflict updates over generic notes updates.", "- get_chapter_content: Inspect existing prose in projects that support chapters when deciding what to do next.", "- replace_text_in_chapter / insert_text_at_marker / apply_chapter_replacements: Make targeted edits inside chapters without rewriting the whole chapter. These tools apply only to projects that support chapters. For insert_text_at_marker, use the fixed marker `~~~` in the chapter text.", - "- search_in_project (scope='sourcebook') / list_sourcebook_entries / get_sourcebook_entry / create_sourcebook_entry / update_sourcebook_entry / delete_sourcebook_entry / add_sourcebook_relation / remove_sourcebook_relation: Maintain world knowledge.", + "- search_in_project (scope='sourcebook') / list_sourcebook_entries / get_sourcebook_entry / create_sourcebook_entry / update_sourcebook_entry / delete_sourcebook_entry / add_sourcebook_relation / remove_sourcebook_relation: Maintain world knowledge. For update_sourcebook_entry prefer description_patch/synonyms_patch/images_patch when changing only part of a field. Use add_sourcebook_relation/remove_sourcebook_relation for relation-only edits instead of replacing relation sets.", + "- Keep metadata patch calls compact: send only the smallest single-field operation needed (avoid multi-field replacement payloads unless required).", "- sync_summary: Auto-generate a summary from the current project prose context.", + "- undo_last_tool_changes: Undo the most recent LLM tool call's project changes (scope='last_call'), or roll back all project changes made by LLM tools since the last user prompt (scope='all_this_turn'). For 'all_this_turn', pass the batch_ids from all tool results in this turn. Use this to recover from unintended modifications before the user reviews them.", "- create_new_chapter / write_chapter_heading: Maintain chapter structure in projects that support chapters. Chapter and book reordering is done via the GUI or REST API, not via LLM tools.", "- list_images / generate_image_description / create_image_placeholder / set_image_metadata: Manage project visuals.", "- read_scratchpad / write_scratchpad: State management for per-chat, temporary planning state. Avoid storing permanent facts here; use story notes or sourcebook entries for durable world model updates.", @@ -162,7 +164,7 @@ "IMPORTANT:", "- Do NOT write story prose yourself in the chat response. Use call_writing_llm instead.", "- Do NOT perform prose editing yourself in the chat response. Use call_editing_assistant instead.", - "- call_writing_llm can write directly to the project when you specify write_mode. Use write_mode='append' to continue the story, 'replace' for full rewrites, 'insert_at_marker' for targeted insertion at ~~~, or omit write_mode to review the text before deciding. For chapter-based projects (novel/series), provide the chap_id. For short-story projects, chap_id is optional and will auto-detect. Without write_mode, it only returns text that you can then integrate via call_editing_assistant or other tools.", + "- call_writing_llm can write directly to the project when you specify write_mode. Use write_mode='append' to continue the story, 'replace' to swap a specific existing passage with the generated text (set replace_target to the exact passage to replace — first occurrence only), 'replace_all' to REPLACE THE COMPLETE EXISTING CONTENT of the chapter/draft with newly generated text (WARNING: all prior text is permanently overwritten — only use when a full replacement is explicitly intended), 'insert_at_marker' for targeted insertion at ~~~, or omit write_mode to review the text before deciding. For chapter-based projects (novel/series), provide the chap_id. For short-story projects, chap_id is optional and will auto-detect. Important: call_writing_llm is stateless, so always pass the exact chapter/content context it must use in this request. For append, include preceding_content when you have a precise local anchor; otherwise the backend uses the last paragraphs of the target chapter. Without write_mode, it only returns text that you can then integrate via call_editing_assistant or other tools.", "- The call_editing_assistant is a prose editor ONLY. It operates exclusively on existing project prose already stored in the active writing unit. NEVER use it for character analysis, psychological insights, world-building advice, brainstorming, answering questions, or any task that does not directly edit specific stored prose. Answer those requests yourself.", "- When returning text that may be used as tool arguments (e.g. JSON parameter values), use typographic quotation marks (“ ”) instead of straight double quotes (\") for string content to avoid breaking JSON formatting.", "- You may update metadata directly when appropriate because that is part of your role.", @@ -197,13 +199,14 @@ "- write_chapter_content: Rewrite the ENTIRE chapter when a complete prose replacement is required in chapter-based projects. WARNING: replaces all existing text — only use for short chapters or complete rewrites.", "- write_story_content: Rewrite the ENTIRE story draft when a short-story project needs full-draft replacement. WARNING: replaces all existing text.", "- insert_image_in_chapter: Insert a Markdown image reference at a chosen location inside a chapter (end, marker, or after_paragraph:N) in chapter-based projects.", - "- call_writing_llm: Use this whenever creative writing tasks or any net-new story content are required.", + "- call_writing_llm: Use this whenever creative writing tasks or any net-new story content are required. The tool is stateless: the WRITING LLM only knows what you include in this call. Always provide complete local context and exact IDs instead of assuming prior chapter knowledge. For append requests, provide preceding_content when possible so continuation is anchored at the intended location.", "- sync_story_summary: Trigger AI regeneration of the story-level summary from the current project prose context. You MAY call this after significant edits.", "- read_editing_scratchpad / write_editing_scratchpad: Your own private state across turns — track your editing plan, open issues, and progress notes here.", "- recommend_metadata_updates: Report suggested changes to summaries, notes, conflicts, tags, or sourcebook content so CHAT can review and apply them. If story content directly contradicts sourcebook or character data in a way you cannot resolve via prose edits alone, describe the discrepancy in rationale so the user can decide.", "RULES:", "- Never call metadata-writing or sourcebook-writing tools directly. Exception: sync_story_summary (AI generation trigger) is permitted.", "- Never add new plot beats on your own. If new prose is needed, request it with call_writing_llm.", + "- Before any call_writing_llm request, verify the target unit with tools (for example get_project_overview/get_chapter_content) and pass the relevant chapter text and constraints in context.", "- When returning text that may be used as tool arguments (e.g. JSON parameter values), use typographic quotation marks (“ ”) instead of straight double quotes (\") for string content to avoid breaking JSON formatting.", "- Always call a tool when you want to modify chapter prose or return structured metadata recommendations." ] @@ -476,6 +479,63 @@ "User Request: {user_text}" ] }, + "call_writing_llm_request": { + "_use": "WRITING", + "en": [ + "Task for this request:", + "{instruction}", + "", + "Context materials:", + "{context}", + "{sourcebook_entries}" + ] + }, + "call_writing_llm_preceding_anchor": { + "_use": "WRITING", + "en": [ + "", + "", + "Immediate preceding prose (anchor for continuation):", + "{preceding_content}" + ] + }, + "sourcebook_entries_block": { + "_use": "WRITING", + "en": ["Relevant sourcebook entries:"] + }, + "sourcebook_entry_summary": { + "_use": "WRITING", + "en": ["- {name} ({category}): {description}"] + }, + "sourcebook_entry_relations": { + "_use": "WRITING", + "en": [" Relations: {relation_text}"] + }, + "sourcebook_entry_relations_none": { + "_use": "WRITING", + "en": ["None"] + }, + "sourcebook_entry_unknown_category": { + "_use": "WRITING", + "en": ["Unknown"] + }, + "sourcebook_entry_missing_description": { + "_use": "WRITING", + "en": ["No description available."] + }, + "call_editing_assistant_request": { + "_use": "EDITING", + "en": [ + "Editing task for this request:", + "{task}", + "", + "Read any additional story, chapter, or sourcebook context you need with tools before editing.{context_note}" + ] + }, + "image_describer_request": { + "_use": "EDITING", + "en": ["Describe this image."] + }, "select_relevant_entries": { "_use": "EDITING", "en": [ diff --git a/resources/config/model_presets.json b/resources/config/model_presets.json index 6d063dcc..3371a55c 100644 --- a/resources/config/model_presets.json +++ b/resources/config/model_presets.json @@ -114,6 +114,25 @@ "extra_body": "{\"repetition_penalty\": 1.0, \"chat_template_kwargs\": {\"enable_thinking\": false}}" } }, + { + "id": "delta-gemma4-non-thinking", + "name": "Tweak: Gemma 4 Non-Thinking", + "description": "Switches Gemma 4 from thinking mode to direct non-thinking responses.", + "model_id_patterns": ["^gemma4", "^gemma-4"], + "preset_type": "delta", + "parameters": { + "temperature": null, + "top_p": null, + "max_tokens": null, + "presence_penalty": null, + "frequency_penalty": null, + "stop": null, + "seed": null, + "top_k": null, + "min_p": null, + "extra_body": "{\"generation_config\": {\"thinking_config\": {\"thinking_budget\": 0}}, \"chat_template_kwargs\": {\"enable_thinking\": false}}" + } + }, { "id": "delta-creative-writing", "name": "Tweak: Creative Writing", diff --git a/src/augmentedquill/api/v1/chat.py b/src/augmentedquill/api/v1/chat.py index 961a8660..3638c5e5 100644 --- a/src/augmentedquill/api/v1/chat.py +++ b/src/augmentedquill/api/v1/chat.py @@ -11,6 +11,7 @@ """ import asyncio +import base64 import datetime import re import augmentedquill.services.llm.llm as llm @@ -48,6 +49,7 @@ capture_project_snapshot, restore_project_snapshot, ) +from augmentedquill.services.projects.projects import use_project_context from augmentedquill.services.chat.chat_api_session_ops import ( list_active_chats, load_active_chat, @@ -61,6 +63,7 @@ from augmentedquill.models.chat import ( ChatInitialStateResponse, ChatToolBatchMutationResponse, + ChapterBeforeContentResponse, ChatListItem, ChatListResponse, ChatDetailResponse, @@ -102,35 +105,38 @@ async def _run_tool_calls( story_cfg = load_story_config(active_dir / "story.json") or {} project_language = str(story_cfg.get("language", "en") or "en") - for call in tool_calls: - if not isinstance(call, dict): - continue - call_id = str(call.get("id") or "") - func = call.get("function") or {} - name = (func.get("name") if isinstance(func, dict) else None) or "" - args_raw = (func.get("arguments") if isinstance(func, dict) else None) or "{}" - try: - args_obj = ( - try_parse_json_robust(args_raw, language=project_language) - if isinstance(args_raw, str) - else (args_raw or {}) + with use_project_context(active_dir): + for call in tool_calls: + if not isinstance(call, dict): + continue + call_id = str(call.get("id") or "") + func = call.get("function") or {} + name = (func.get("name") if isinstance(func, dict) else None) or "" + args_raw = ( + func.get("arguments") if isinstance(func, dict) else None + ) or "{}" + try: + args_obj = ( + try_parse_json_robust(args_raw, language=project_language) + if isinstance(args_raw, str) + else (args_raw or {}) + ) + except (ValueError, TypeError): + args_obj = {} + if not name or not call_id: + continue + tool_names.append(name) + msg = await execute_registered_tool( + name, + args_obj, + call_id, + payload, + mutations, + tool_role=model_type, ) - except (ValueError, TypeError): - args_obj = {} - if not name or not call_id: - continue - tool_names.append(name) - msg = await execute_registered_tool( - name, - args_obj, - call_id, - payload, - mutations, - tool_role=model_type, - ) - if isinstance(msg, dict) and "role" not in msg: - msg = tool_message(name, call_id, msg) - appended.append(msg) + if isinstance(msg, dict) and "role" not in msg: + msg = tool_message(name, call_id, msg) + appended.append(msg) return appended, mutations, tool_names @@ -156,24 +162,49 @@ def _snapshot_storage_dir(project_dir: Path, batch_id: str) -> Path: return _safe_child_path(project_dir, _CHAT_TOOL_BATCH_DIR, safe_batch_id) +def _compute_changed_chapter_ids( + project_dir: Path, + before: Dict[str, str], + after: Dict[str, str], +) -> list[int]: + """Return the virtual chapter IDs whose file content differs between snapshots.""" + from augmentedquill.services.chapters.chapter_helpers import _scan_chapter_files + + changed: list[int] = [] + for vid, abs_path in _scan_chapter_files(project_dir): + rel_path = str(abs_path.relative_to(project_dir)) + if before.get(rel_path) != after.get(rel_path): + changed.append(vid) + return changed + + def _store_chat_tool_batch_snapshot( project_dir: Path, batch_id: str, before_snapshot: Dict[str, str], after_snapshot: Dict[str, str], tool_names: list[str], -) -> Any: - """Persist before/after snapshots for reversible tool-call batches.""" +) -> list[int]: + """Persist before/after snapshots for reversible tool-call batches. + + Returns the list of changed chapter IDs so callers can include them in + the mutations payload without recomputing. + """ target_dir = _snapshot_storage_dir(project_dir, batch_id) target_dir.mkdir(parents=True, exist_ok=True) + changed_chapter_ids = _compute_changed_chapter_ids( + project_dir, before_snapshot, after_snapshot + ) metadata = { "batch_id": batch_id, "created_at": datetime.datetime.now().isoformat(), "tool_names": tool_names, + "changed_chapter_ids": changed_chapter_ids, "before": before_snapshot, "after": after_snapshot, } (target_dir / "batch.json").write_text(_json.dumps(metadata), encoding="utf-8") + return changed_chapter_ids def _load_chat_tool_batch_snapshot(project_dir: Path, batch_id: str) -> Dict[str, Any]: @@ -298,6 +329,9 @@ async def _run_and_signal() -> Any: result_holder.append( (appended_inner, mutations_inner, names_inner, None) ) + except asyncio.CancelledError: + result_holder.append(([], initial_mutations, [], None)) + raise except Exception as exc: # noqa: BLE001 result_holder.append(([], initial_mutations, [], exc)) finally: @@ -305,18 +339,37 @@ async def _run_and_signal() -> Any: task = asyncio.create_task(_run_and_signal()) + # Cancel the tool task if the client disconnects mid-stream. + async def _watch_disconnect() -> None: + """Cancel the running tool task when the HTTP client disconnects.""" + disconnected = await request.is_disconnected() + if disconnected: + task.cancel() + + watcher = asyncio.create_task(_watch_disconnect()) + # Relay prose-streaming events emitted by call_writing_llm. - while True: - item = await stream_queue.get() - if item is None: - break - kind, data = item - if kind == "prose_start": - yield f"data: {_json.dumps({'type': 'prose_start', **data})}\n\n" - elif kind == "prose_chunk": - yield f"data: {_json.dumps({'type': 'prose_chunk', **data})}\n\n" + try: + while True: + try: + item = await stream_queue.get() + except asyncio.CancelledError: + task.cancel() + raise + if item is None: + break + kind, data = item + if kind == "prose_start": + yield f"data: {_json.dumps({'type': 'prose_start', **data})}\n\n" + elif kind == "prose_chunk": + yield f"data: {_json.dumps({'type': 'prose_chunk', **data})}\n\n" + finally: + watcher.cancel() - await task # ensure the background task has fully finished + try: + await task # ensure the background task has fully finished + except asyncio.CancelledError: + return if not result_holder: yield f"data: {_json.dumps({'type': 'error', 'error': 'Tool execution produced no result'})}\n\n" @@ -340,7 +393,7 @@ async def _run_and_signal() -> Any: and mutations.get("story_changed") ): after_snapshot = capture_project_snapshot(active_project_dir) - _store_chat_tool_batch_snapshot( + changed_chapter_ids = _store_chat_tool_batch_snapshot( active_project_dir, batch_id, before_snapshot, @@ -352,6 +405,7 @@ async def _run_and_signal() -> Any: "tool_names": tool_names, "operation_count": len(tool_names), "label": _build_chat_tool_batch_label(tool_names), + "changed_chapter_ids": changed_chapter_ids, } # Log tool execution if there were any @@ -404,6 +458,45 @@ async def api_chat_tools_redo( return ChatToolBatchMutationResponse(ok=True, batch_id=batch_id) +@router.get( + "/projects/{project_name}/chat/tools/batches/{batch_id}/chapter-before/{chapter_id}", + response_model=ChapterBeforeContentResponse, +) +async def api_chat_batch_chapter_before( + batch_id: str, chapter_id: int, project_dir: ProjectDep +) -> ChapterBeforeContentResponse: + """Return the pre-batch content of a chapter for diff-baseline restoration. + + Used by the frontend to reconstruct the baseline state for chapters that + were not loaded in memory when an AI tool modified them. + """ + from augmentedquill.services.chapters.chapter_helpers import _scan_chapter_files + + batch = _load_chat_tool_batch_snapshot(project_dir, batch_id) + before_snapshot: Dict[str, str] = batch.get("before") or {} + + chapter_files = _scan_chapter_files(project_dir) + rel_path: str | None = None + for vid, abs_path in chapter_files: + if vid == chapter_id: + rel_path = str(abs_path.relative_to(project_dir)) + break + + if rel_path is None: + raise HTTPException(status_code=404, detail="Chapter not found in project") + + content_b64 = before_snapshot.get(rel_path) + if content_b64 is None: + raise HTTPException( + status_code=404, detail="Chapter not found in batch before-snapshot" + ) + + content = base64.b64decode(content_b64.encode("ascii")).decode( + "utf-8", errors="replace" + ) + return ChapterBeforeContentResponse(content=content) + + @router.post("/projects/{project_name}/chat/stream") async def api_chat_stream( request: Request, project_dir: ProjectDep diff --git a/src/augmentedquill/models/chat.py b/src/augmentedquill/models/chat.py index 53cad225..a8a7fcd0 100644 --- a/src/augmentedquill/models/chat.py +++ b/src/augmentedquill/models/chat.py @@ -43,6 +43,12 @@ class ChatToolBatchMutationResponse(BaseModel): detail: Optional[str] = None +class ChapterBeforeContentResponse(BaseModel): + """Response body for ``GET /api/v1/chat/tools/batches/{batch_id}/chapter-before/{chapter_id}``.""" + + content: str + + # --------------------------------------------------------------------------- # Chat session list / load # --------------------------------------------------------------------------- @@ -73,6 +79,7 @@ class ChatDetailResponse(BaseModel): allowWebSearch: Optional[bool] = None scratchpad: Optional[str] = None editing_scratchpad: Optional[str] = None + projectContextRevision: Optional[int] = None created_at: Optional[str] = None updated_at: Optional[str] = None diff --git a/src/augmentedquill/models/search.py b/src/augmentedquill/models/search.py index 251f2af9..1bc72129 100644 --- a/src/augmentedquill/models/search.py +++ b/src/augmentedquill/models/search.py @@ -83,6 +83,23 @@ class SearchResultSection(BaseModel): matches: list[SearchMatch] = Field(default_factory=list) +class ReplaceChangeLocation(BaseModel): + """Structured information about a single replaced section.""" + + type: str = Field( + ..., description="One of: chapter, story, metadata, sourcebook, book" + ) + target_id: str | None = Field( + None, + description="Target identifier for the changed section, e.g. chapter ID or sourcebook entry name", + ) + field: str | None = Field( + None, + description="Optional field name or metadata subfield affected by the replacement", + ) + label: str = Field(..., description="Human-readable label for the changed section") + + class SearchResponse(BaseModel): """Top-level response for a search request.""" @@ -138,3 +155,7 @@ class ReplaceResponse(BaseModel): default_factory=list, description="Human-readable labels for each changed section", ) + changed_sections_meta: list[ReplaceChangeLocation] = Field( + default_factory=list, + description="Structured information for each changed section", + ) diff --git a/src/augmentedquill/services/chat/chat_tool_decorator.py b/src/augmentedquill/services/chat/chat_tool_decorator.py index 8977013d..ab82ca04 100644 --- a/src/augmentedquill/services/chat/chat_tool_decorator.py +++ b/src/augmentedquill/services/chat/chat_tool_decorator.py @@ -98,6 +98,22 @@ def _simplify_schema(schema: Any) -> Any: return result +def _sanitize_validation_details(details: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Strip oversized raw input values from validation details.""" + sanitized: list[dict[str, Any]] = [] + for detail in details: + if not isinstance(detail, dict): + continue + sanitized.append( + { + "type": detail.get("type", "validation_error"), + "loc": detail.get("loc", []), + "msg": detail.get("msg", "Validation failed"), + } + ) + return sanitized + + def chat_tool( description: str, name: str | None = None, @@ -174,6 +190,7 @@ def decorator(func: Callable) -> Callable: "properties": schema.get("properties", {}), "required": schema.get("required", []), "additionalProperties": False, + "$defs": schema.get("$defs", {}), }, }, } @@ -196,7 +213,6 @@ async def wrapper( "type": "type_error.dict", "loc": ["arguments"], "msg": "Tool arguments must be a JSON object", - "input": args_obj, } ], }, @@ -214,7 +230,6 @@ async def wrapper( "type": "extra_forbidden", "loc": [key], "msg": "Extra inputs are not permitted", - "input": args_obj.get(key), } for key in unknown_keys ], @@ -233,7 +248,6 @@ async def wrapper( "type": "missing", "loc": [key], "msg": "Field required", - "input": args_obj, } for key in missing_required ], @@ -246,7 +260,16 @@ async def wrapper( return _tool_message( tool_name, call_id, - {"error": "Invalid parameters", "details": e.errors()}, + { + "error": "Invalid parameters", + "details": _sanitize_validation_details( + e.errors( + include_url=False, + include_context=False, + include_input=False, + ) + ), + }, ) except Exception as e: return _tool_message( @@ -313,6 +336,7 @@ def get_tool_schemas( if func_name == "update_story_metadata" and project_type in ("novel", "series"): if properties is not None: properties.pop("conflicts", None) + properties.pop("conflicts_patch", None) if func_name == "call_writing_llm" and properties is not None: chap_prop = properties.get("chap_id") diff --git a/src/augmentedquill/services/chat/chat_tools/__init__.py b/src/augmentedquill/services/chat/chat_tools/__init__.py index 637ee4d7..e3189b76 100644 --- a/src/augmentedquill/services/chat/chat_tools/__init__.py +++ b/src/augmentedquill/services/chat/chat_tools/__init__.py @@ -23,6 +23,7 @@ from augmentedquill.services.chat.chat_tools import story_tools # noqa: F401 from augmentedquill.services.chat.chat_tools import web_search_tools # noqa: F401 from augmentedquill.services.chat.chat_tools import search_tools # noqa: F401 +from augmentedquill.services.chat.chat_tools import undo_tools # noqa: F401 __all__ = [ "chapter_tools", @@ -34,4 +35,5 @@ "story_tools", "web_search_tools", "search_tools", + "undo_tools", ] diff --git a/src/augmentedquill/services/chat/chat_tools/chapter_prose_tools.py b/src/augmentedquill/services/chat/chat_tools/chapter_prose_tools.py index 06da4bac..638a51f3 100644 --- a/src/augmentedquill/services/chat/chat_tools/chapter_prose_tools.py +++ b/src/augmentedquill/services/chat/chat_tools/chapter_prose_tools.py @@ -10,9 +10,10 @@ from typing import Any import json -from pydantic import BaseModel, Field +from pydantic import AliasChoices, BaseModel, Field from augmentedquill.core.config import load_story_config +from augmentedquill.core.prompts import get_user_prompt from augmentedquill.utils.json_repair import apply_typographic_quotes from augmentedquill.services.chapters.chapter_helpers import _chapter_by_id_or_404 from augmentedquill.services.chat.chat_tool_decorator import ( @@ -26,6 +27,128 @@ ) from augmentedquill.services.chat.chat_tools.chapter_tools import MARKER + +def _count_leading_newlines(text: str) -> int: + count = 0 + for char in text: + if char != "\n": + break + count += 1 + return count + + +def _join_appended_prose(existing: str, generated_text: str) -> str: + if not generated_text: + return existing + + leading_nl = _count_leading_newlines(generated_text) + if leading_nl >= 2: + body = generated_text.lstrip("\n") + return existing.rstrip("\n") + "\n\n" + body + + if leading_nl == 1: + body = generated_text.lstrip("\n") + return existing.rstrip("\n") + "\n" + body + + prefix = existing.rstrip("\n") + if ( + prefix + and not prefix[-1].isspace() + and not generated_text.startswith((" ", "\t", "\n")) + ): + return prefix + " " + generated_text + return prefix + generated_text + + +def _extract_tail_paragraphs(text: str, max_paragraphs: int = 3) -> str: + """Return the last few paragraphs used as append anchoring context.""" + stripped = text.strip() + if not stripped: + return "" + + paragraphs = [p.strip() for p in stripped.split("\n\n") if p.strip()] + if not paragraphs: + return stripped + + return "\n\n".join(paragraphs[-max_paragraphs:]) + + +def _format_sourcebook_entry_prompt(entry: dict, language: str) -> str: + """Format a sourcebook entry compactly for the WRITING LLM prompt.""" + name = entry.get("name", "") + category = entry.get("category") or get_user_prompt( + "sourcebook_entry_unknown_category", language=language + ) + description = entry.get("description", "").strip() + if not description: + description = get_user_prompt( + "sourcebook_entry_missing_description", + language=language, + ) + + relations = entry.get("relations") or [] + relation_lines: list[str] = [] + for relation in relations: + relation_type = relation.get("relation", "") + target_id = relation.get("target_id", "") + direction = relation.get("direction", "forward") + if relation_type and target_id: + relation_lines.append(f"{relation_type} ({direction}) -> {target_id}") + relation_text = ( + "; ".join(relation_lines) + if relation_lines + else get_user_prompt( + "sourcebook_entry_relations_none", + language=language, + ) + ) + + summary = get_user_prompt( + "sourcebook_entry_summary", + language=language, + name=name, + category=category, + description=description, + ) + relations_line = get_user_prompt( + "sourcebook_entry_relations", + language=language, + relation_text=relation_text, + ) + + return f"{summary}\n{relations_line}" + + +def _build_sourcebook_entries_context(entry_names: list[str], language: str) -> str: + """Build a compact sourcebook context block for the writing prompt.""" + from augmentedquill.services.sourcebook.sourcebook_helpers import ( + sourcebook_get_entry, + ) + + seen_ids: set[str] = set() + lines: list[str] = [] + for name_or_synonym in entry_names: + if not isinstance(name_or_synonym, str) or not name_or_synonym.strip(): + continue + entry = sourcebook_get_entry(name_or_synonym) + if not entry: + continue + entry_id = str(entry.get("id") or "") + if entry_id in seen_ids: + continue + seen_ids.add(entry_id) + lines.append(_format_sourcebook_entry_prompt(entry, language=language)) + + if not lines: + return "" + + return ( + get_user_prompt("sourcebook_entries_block", language=language) + + "\n" + + "\n".join(lines) + ) + + # ============================================================================ # call_writing_llm # ============================================================================ @@ -36,14 +159,28 @@ class CallWritingLlmParams(BaseModel): instruction: str = Field( ..., - description="The task for the WRITING LLM for the active draft or chapter (e.g. 'Rewrite this paragraph to be more descriptive').", + description="The task for the WRITING LLM for this single stateless request (e.g. 'Rewrite this paragraph to be more descriptive'). Do not assume it has prior chapter knowledge.", ) context: str = Field( - ..., description="The text context the WRITING LLM needs to operate on." + ..., + description="All text/context the WRITING LLM needs for this stateless call (relevant chapter excerpt, constraints, conflict status, style/POV requirements, and any needed identifiers).", + ) + preceding_content: str | None = Field( + None, + validation_alias=AliasChoices("preceding_content", "preceeding_content"), + description="Optional prose immediately preceding the insertion point. In append mode, if omitted, the system auto-fills this with the last paragraphs of the target chapter.", + ) + sourcebook_entries: list[str] | None = Field( + None, + description="Optional list of sourcebook entry names or synonyms to include in the WRITING LLM prompt. Matching entries are resolved by name or synonym and added compactly with category, description, and relations.", ) write_mode: str | None = Field( None, - description="How to persist output: 'append' (add to end of chapter), 'replace' (overwrite entire chapter), 'insert_at_marker' (insert at ~~~ marker), or None (return text without writing).", + description="How to persist output: 'append' (add to end of chapter), 'replace' (replace the single occurrence of replace_target in the chapter with the generated text — replace_target is required), 'replace_all' (REPLACES THE COMPLETE EXISTING CONTENT of the chapter/draft with the newly generated text — all prior content is lost), 'insert_at_marker' (insert at ~~~ marker), or None (return text without writing).", + ) + replace_target: str | None = Field( + None, + description="Required when write_mode='replace': the exact text passage to search for in the chapter. The first occurrence is replaced with the generated text. Must match the existing text exactly.", ) chap_id: int | None = Field( None, @@ -52,7 +189,7 @@ class CallWritingLlmParams(BaseModel): @chat_tool( - description="Delegate a creative writing or rewriting task to the WRITING LLM. Can optionally write the output directly to a chapter with write_mode: 'append' adds to end, 'replace' overwrites all, 'insert_at_marker' inserts at ~~~ marker. Without write_mode, just returns generated text.", + description="Delegate a creative writing or rewriting task to the WRITING LLM. Stateless behavior: it only sees instruction/context provided in this call, so include all required chapter context and exact IDs explicitly. Can optionally write the output directly to a chapter with write_mode: 'append' adds to end, 'replace' substitutes a specific passage (set replace_target to the exact text to swap out — only the first occurrence is replaced), 'replace_all' REPLACES THE COMPLETE EXISTING CONTENT of the chapter/draft (all prior text is permanently overwritten — use only for full rewrites), 'insert_at_marker' inserts at ~~~ marker. Without write_mode, just returns generated text.", allowed_roles=(CHAT_ROLE, EDITING_ROLE), capability="delegation", project_types=("short-story", "novel", "series"), @@ -63,6 +200,7 @@ async def call_writing_llm( """Execute the writing LLM tool with provided parameters and return the generated prose.""" from augmentedquill.core.config import BASE_DIR, load_machine_config from augmentedquill.core.prompts import ( + get_user_prompt, get_system_message, load_model_prompt_overrides, ) @@ -109,6 +247,51 @@ async def call_writing_llm( "story_writer", model_overrides, language=project_lang ) + resolved_chap_id: int | None = None + resolved_path = None + existing_for_append = "" + if params.write_mode == "append": + resolved_chap_id = params.chap_id + if resolved_chap_id is None: + if project_type == "short-story": + resolved_chap_id = 1 + else: + raise BadRequestError( + "chap_id is required when write_mode is set for chapter-based projects (novel/series). " + "Call get_project_overview to see available chapter IDs." + ) + _, resolved_path, _ = _chapter_by_id_or_404(resolved_chap_id) + existing_for_append = resolved_path.read_text(encoding="utf-8") + + preceding_content = params.preceding_content + if params.write_mode == "append" and not preceding_content: + preceding_content = _extract_tail_paragraphs(existing_for_append) + + user_content = get_user_prompt( + "call_writing_llm_request", + language=project_lang, + instruction=params.instruction, + context=params.context, + ) + + sourcebook_context = _build_sourcebook_entries_context( + params.sourcebook_entries or [], project_lang + ) + user_content = get_user_prompt( + "call_writing_llm_request", + language=project_lang, + instruction=params.instruction, + context=params.context, + sourcebook_entries=sourcebook_context, + ) + + if preceding_content: + user_content += get_user_prompt( + "call_writing_llm_preceding_anchor", + language=project_lang, + preceding_content=preceding_content, + ) + messages = [ { "role": "system", @@ -116,10 +299,7 @@ async def call_writing_llm( }, { "role": "user", - "content": ( - f"Task for this request:\n{params.instruction}\n\n" - f"Context materials:\n{params.context}" - ), + "content": user_content, }, ] @@ -132,7 +312,9 @@ async def call_writing_llm( # Resolve write_mode and chap_id early so we can include them in events. # (Actual persistence happens after streaming completes below.) preview_write_mode = params.write_mode or "return_only" - preview_chap_id: int | None = params.chap_id + preview_chap_id: int | None = ( + resolved_chap_id if resolved_chap_id is not None else params.chap_id + ) if ( preview_chap_id is None and params.write_mode @@ -192,7 +374,7 @@ async def call_writing_llm( # If write_mode is specified, persist the generated text if params.write_mode: # Auto-detect chapter ID for short-story projects if not provided - chap_id = params.chap_id + chap_id = resolved_chap_id if resolved_chap_id is not None else params.chap_id if chap_id is None: if project_type == "short-story": chap_id = 1 # Short-story projects use pseudo-chapter ID 1 @@ -203,16 +385,15 @@ async def call_writing_llm( ) # Validate chapter exists and get path - _, path, _ = _chapter_by_id_or_404(chap_id) + if resolved_path is None: + _, path, _ = _chapter_by_id_or_404(chap_id) + else: + path = resolved_path if params.write_mode == "append": # Append to end of chapter (like continue_chapter) - existing = path.read_text(encoding="utf-8") - new_content = ( - existing - + ("\n" if existing and not existing.endswith("\n") else "") - + generated_text - ) + existing = existing_for_append or path.read_text(encoding="utf-8") + new_content = _join_appended_prose(existing, generated_text) _write_chapter_content(chap_id, new_content) mutations["story_changed"] = True return { @@ -223,8 +404,20 @@ async def call_writing_llm( } elif params.write_mode == "replace": - # Replace entire chapter content - _write_chapter_content(chap_id, generated_text) + # Replace first occurrence of replace_target with generated text. + if not params.replace_target: + raise BadRequestError( + "replace_target is required when write_mode='replace'. " + "Provide the exact text passage to search for and replace." + ) + existing = path.read_text(encoding="utf-8") + if params.replace_target not in existing: + raise BadRequestError( + f"replace_target not found in chapter {chap_id}. " + "Ensure the text matches the chapter content exactly." + ) + new_content = existing.replace(params.replace_target, generated_text, 1) + _write_chapter_content(chap_id, new_content) mutations["story_changed"] = True return { "generated_text": generated_text, @@ -233,6 +426,19 @@ async def call_writing_llm( "chap_id": chap_id, } + elif params.write_mode == "replace_all": + # Replace entire chapter content — ALL prior content is overwritten. + _write_chapter_content(chap_id, generated_text) + mutations["story_changed"] = True + return { + "generated_text": generated_text, + "written": True, + "write_mode": "replace_all", + "replaced_complete_content": True, + "chap_id": chap_id, + "status": "Complete chapter content has been replaced with the newly generated text.", + } + elif params.write_mode == "insert_at_marker": # Insert at ~~~ marker (replace marker with text) existing = path.read_text(encoding="utf-8") @@ -259,7 +465,7 @@ async def call_writing_llm( else: raise BadRequestError( f"Invalid write_mode: {params.write_mode}. " - "Use 'append', 'replace', 'insert_at_marker', or omit for return-only." + "Use 'append', 'replace', 'replace_all', 'insert_at_marker', or omit for return-only." ) # Default: just return the generated text without writing @@ -304,6 +510,7 @@ async def call_editing_assistant( get_registered_tool_schemas, ) from augmentedquill.core.prompts import ( + get_user_prompt, load_model_prompt_overrides, get_system_message, ) @@ -327,17 +534,16 @@ async def call_editing_assistant( if params.book_id: ctx_note += f", book ID: {params.book_id}" + user_content = get_user_prompt( + "call_editing_assistant_request", + language=project_lang, + task=params.task, + context_note=ctx_note, + ) + messages = [ {"role": "system", "content": sys_msg}, - { - "role": "user", - "content": ( - "Editing task for this request:\n" - f"{params.task}\n\n" - "Read any additional story, chapter, or sourcebook context you need with tools before editing." - + ctx_note - ), - }, + {"role": "user", "content": user_content}, ] # Build base payload so EDITING tools can resolve the active chapter automatically diff --git a/src/augmentedquill/services/chat/chat_tools/chapter_tools.py b/src/augmentedquill/services/chat/chat_tools/chapter_tools.py index ee31883a..7b60be4a 100644 --- a/src/augmentedquill/services/chat/chat_tools/chapter_tools.py +++ b/src/augmentedquill/services/chat/chat_tools/chapter_tools.py @@ -26,8 +26,8 @@ chat_tool, ) from augmentedquill.services.projects.project_helpers import ( - _chapter_content_slice, _project_overview, + _snap_to_boundary, ) from augmentedquill.services.story.story_generation_ops import ( continue_chapter_from_summary, @@ -42,6 +42,12 @@ write_chapter_summary as _write_chapter_summary, write_chapter_title, ) +from augmentedquill.services.chat.chat_tools.metadata_patching import ( + ConflictListPatch, + TextPatch, + apply_conflict_list_patch, + apply_text_patch, +) _MAX_CHAPTER_CHARS = 8000 @@ -166,6 +172,22 @@ class UpdateChapterMetadataParams(BaseModel): title: str | None = Field(None, description="The chapter title") summary: str | None = Field(None, description="The chapter summary") notes: str | None = Field(None, description="Public notes about the chapter") + summary_patch: TextPatch | None = Field( + None, + description=( + "Optional partial summary edit object. " + "Use {operation:'replace'|'append'|'prepend', value:'...'} or " + "{operation:'replace_text', old_text:'...', new_text:'...', occurrence:'first|last|all|unique'}." + ), + ) + notes_patch: TextPatch | None = Field( + None, + description=( + "Optional partial notes edit object. " + "Use {operation:'replace'|'append'|'prepend', value:'...'} or " + "{operation:'replace_text', old_text:'...', new_text:'...', occurrence:'first|last|all|unique'}." + ), + ) conflicts: list | str | None = Field( None, description=( @@ -173,6 +195,16 @@ class UpdateChapterMetadataParams(BaseModel): "Each conflict should include description, resolution, and optional resolved status." ), ) + conflicts_patch: ConflictListPatch | None = Field( + None, + description=( + "Optional conflict patch object: {operations:[...]}. " + "Each operation: {index:, updates:{...}} to update fields of an existing conflict, " + "{conflict:{...}} to append a new conflict, " + "{index:} to remove a conflict. " + "op is inferred automatically; only set it explicitly for 'insert' or 'clear'." + ), + ) class GetChapterSummariesParams(BaseModel): @@ -188,11 +220,18 @@ class GetChapterContentParams(BaseModel): None, description="The chapter ID to get content for. If not provided, uses active chapter.", ) - start: int = Field(0, description="The starting character position") + start: int = Field( + 0, + description="The starting character position. Ignored when read_from_end=True.", + ) max_chars: int = Field( _MAX_CHAPTER_CHARS, description=f"Maximum characters to return (1-{_MAX_CHAPTER_CHARS})", ) + read_from_end: bool = Field( + False, + description="When True, return the last max_chars characters instead of reading from start. Useful for reading the most recent prose before appending.", + ) class GetCurrentChapterParams(BaseModel): @@ -354,7 +393,6 @@ async def get_chapter_metadata( "id": chap.get("id"), "title": chap.get("title"), "summary": chap.get("summary"), - "filename": chap.get("filename"), "notes": meta.get("notes", ""), "conflicts": meta.get("conflicts") or [], }, @@ -384,8 +422,14 @@ async def get_chapter_metadata( @chat_tool( - description="Update metadata for a specific chapter (title, summary, notes, conflicts). " - "Chapter conflicts are treated as active story arcs; include any resolved status changes.", + description=( + "Update metadata for a specific chapter (title, summary, notes, conflicts). " + "Use summary_patch/notes_patch/conflicts_patch for safe partial edits that keep existing content. " + "summary_patch/notes_patch must be patch objects. " + "conflicts_patch should be {operations:[...]} with index-based operations. " + "Chapter conflicts are treated as active story arcs; include resolved status changes when needed. " + "conflicts_patch is index-based (operations[].index) and does not support JSON Patch path pointers." + ), allowed_roles=(CHAT_ROLE, EDITING_ROLE), capability="metadata-write", ) @@ -400,15 +444,110 @@ async def update_chapter_metadata( except Exception: conflicts = None + active = get_active_project_dir() + story = load_story_config((active / "story.json") if active else None) or {} + files = _scan_chapter_files(active) + _, path, _ = _chapter_by_id_or_404(params.chap_id, active=active) + current_meta = ( + _get_chapter_metadata_entry( + story, + params.chap_id, + path, + files=files, + active=active, + ) + or {} + ) + + summary_value = params.summary + if params.summary_patch is not None: + summary_value = apply_text_patch( + current_meta.get("summary", ""), params.summary_patch + ) + + notes_value = params.notes + if params.notes_patch is not None: + notes_value = apply_text_patch( + current_meta.get("notes", ""), params.notes_patch + ) + + conflicts_value = conflicts + if params.conflicts_patch is not None: + current_conflicts = current_meta.get("conflicts") + if not isinstance(current_conflicts, list): + current_conflicts = [] + conflicts_value = apply_conflict_list_patch( + current_conflicts, + params.conflicts_patch, + ) + + fields_set = set(params.model_fields_set) + current_summary = current_meta.get("summary") or "" + current_notes = current_meta.get("notes") + current_conflicts = current_meta.get("conflicts") + if not isinstance(current_conflicts, list): + current_conflicts = [] + + changed_fields: list[str] = [] + + title_to_write: str | None = None + if "title" in fields_set and params.title is not None: + next_title = params.title.strip() + if next_title != str(current_meta.get("title") or "").strip(): + title_to_write = params.title + changed_fields.append("title") + + summary_requested = "summary" in fields_set or params.summary_patch is not None + summary_to_write: str | None = None + if summary_requested and summary_value is not None: + next_summary = summary_value.strip() + if next_summary != current_summary: + summary_to_write = summary_value + changed_fields.append("summary") + + notes_requested = "notes" in fields_set or params.notes_patch is not None + notes_to_write: str | None = None + if notes_requested and notes_value is not None and notes_value != current_notes: + notes_to_write = notes_value + changed_fields.append("notes") + + conflicts_requested = ( + "conflicts" in fields_set or params.conflicts_patch is not None + ) + conflicts_to_write: list | None = None + if ( + conflicts_requested + and conflicts_value is not None + and conflicts_value != current_conflicts + ): + conflicts_to_write = conflicts_value + changed_fields.append("conflicts") + + if not changed_fields: + return { + "ok": True, + "changed": False, + "changed_fields": [], + "message": f"No metadata changes for chapter {params.chap_id}", + "chap_id": params.chap_id, + } + _update_chapter_metadata( params.chap_id, - title=params.title, - summary=params.summary, - notes=params.notes, - conflicts=conflicts, + title=title_to_write, + summary=summary_to_write, + notes=notes_to_write, + conflicts=conflicts_to_write, + active=active, ) mutations["story_changed"] = True - return {"ok": True, "message": f"Metadata updated for chapter {params.chap_id}"} + return { + "ok": True, + "changed": True, + "changed_fields": changed_fields, + "message": f"Metadata updated for chapter {params.chap_id}", + "chap_id": params.chap_id, + } @chat_tool( @@ -443,7 +582,7 @@ async def get_chapter_summaries( @chat_tool( - description="Get content from a specific chapter with pagination support.", + description="Get content from a specific chapter with pagination support. Use read_from_end=True to read the last max_chars characters, which is recommended before appending prose.", allowed_roles=(CHAT_ROLE, EDITING_ROLE), capability="prose-read", ) @@ -459,10 +598,27 @@ async def get_chapter_content( if not isinstance(chap_id, int): return {"error": "chap_id is required"} - start = max(0, params.start) max_chars = max(1, min(_MAX_CHAPTER_CHARS, params.max_chars)) - data = _chapter_content_slice(chap_id, start=start, max_chars=max_chars) - return data + _, path, _ = _chapter_by_id_or_404(chap_id) + text = path.read_text(encoding="utf-8") + total = len(text) + + if params.read_from_end: + raw_start = max(0, total - max_chars) + start = _snap_to_boundary(text, raw_start, forward=False) + end = total + else: + start = max(0, params.start) + raw_end = min(total, start + max_chars) + end = min(total, _snap_to_boundary(text, raw_end, forward=True)) + + return { + "id": chap_id, + "start": start, + "end": end, + "total": total, + "content": text[start:end], + } @chat_tool( diff --git a/src/augmentedquill/services/chat/chat_tools/image_tools.py b/src/augmentedquill/services/chat/chat_tools/image_tools.py index 97b1e1de..c4472138 100644 --- a/src/augmentedquill/services/chat/chat_tools/image_tools.py +++ b/src/augmentedquill/services/chat/chat_tools/image_tools.py @@ -56,6 +56,13 @@ class SetImageMetadataParams(BaseModel): async def _tool_generate_image_description(filename: str, payload: dict) -> str: """Tool Generate Image Description.""" + from augmentedquill.core.config import load_story_config + from augmentedquill.core.prompts import ( + get_system_message, + get_user_prompt, + load_model_prompt_overrides, + ) + from augmentedquill.services.projects.projects import get_active_project_dir from augmentedquill.services.llm import llm from augmentedquill.utils.image_helpers import get_images_dir, update_image_metadata @@ -98,15 +105,30 @@ async def _tool_generate_image_description(filename: str, payload: dict) -> str: with open(img_path, "rb") as f: base64_image = base64.b64encode(f.read()).decode("utf-8") + active = get_active_project_dir() + story = load_story_config((active / "story.json") if active else None) or {} + project_lang = str(story.get("language", "en") or "en") + + from augmentedquill.core.config import BASE_DIR, load_machine_config + + machine_config = load_machine_config(BASE_DIR / "config" / "machine.json") or {} + model_overrides = load_model_prompt_overrides(machine_config, model_name) + system_prompt = get_system_message( + "image_describer", + model_overrides, + language=project_lang, + ) + user_prompt = get_user_prompt("image_describer_request", language=project_lang) + messages = [ { "role": "system", - "content": "You are a helpful assistant that describes images. Provide a detailed description of the image.", + "content": system_prompt, }, { "role": "user", "content": [ - {"type": "text", "text": "Describe this image."}, + {"type": "text", "text": user_prompt}, { "type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{base64_image}"}, diff --git a/src/augmentedquill/services/chat/chat_tools/metadata_patching.py b/src/augmentedquill/services/chat/chat_tools/metadata_patching.py new file mode 100644 index 00000000..458944b2 --- /dev/null +++ b/src/augmentedquill/services/chat/chat_tools/metadata_patching.py @@ -0,0 +1,264 @@ +# Copyright (C) 2026 StableLlama +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +"""Shared metadata patch models and helpers for safe partial updates.""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class TextPatch(BaseModel): + """Patch operation for text fields.""" + + operation: Literal["replace", "append", "prepend", "replace_text"] = Field( + ..., + description=( + "replace = set full value, append/prepend = add text while keeping" + " existing content, replace_text = replace old_text with new_text." + ), + ) + value: str | None = Field( + None, + description="Text value used by replace/append/prepend operations.", + ) + old_text: str | None = Field( + None, + description="Exact text to find when operation=replace_text.", + ) + new_text: str | None = Field( + None, + description="Replacement text used when operation=replace_text.", + ) + occurrence: Literal["first", "last", "all", "unique"] = Field( + "first", + description=( + "Which match to replace when operation=replace_text. " + "unique fails unless exactly one match exists." + ), + ) + + @model_validator(mode="after") + def _validate_shape(self) -> "TextPatch": + if self.operation in ("replace", "append", "prepend"): + if self.value is None: + raise ValueError( + "value is required for replace/append/prepend operations" + ) + if self.operation == "replace_text": + if self.old_text is None or self.new_text is None: + raise ValueError( + "old_text and new_text are required for replace_text operation" + ) + return self + + +class StringListPatch(BaseModel): + """Patch operation for string list fields (tags, synonyms, images).""" + + set: list[str] | None = Field( + None, + description="Optional full replacement list before add/remove operations.", + ) + add: list[str] | None = Field( + None, + description="Values to add while preserving untouched existing values.", + ) + remove: list[str] | None = Field( + None, + description="Values to remove from the current list.", + ) + clear: bool = Field(False, description="Clear the existing list before add/set.") + unique: bool = Field( + True, + description="If true, deduplicate while preserving first-seen order.", + ) + + +class ConflictPatchOperation(BaseModel): + """One atomic conflict-list change.""" + + model_config = ConfigDict(extra="forbid") + + op: Literal["add", "insert", "replace", "update", "remove", "clear"] | None = Field( + None, + description=( + "Operation type. Inferred automatically when omitted: " + "updates present → 'update'; conflict present with index → 'replace'; " + "conflict present without index → 'add'; only index present → 'remove'. " + "Must be set explicitly for 'insert' and 'clear'." + ), + ) + index: int | None = Field( + None, + description="0-based conflict list index. Required for insert/replace/update/remove.", + ) + conflict: dict[str, Any] | None = Field( + None, + description="Conflict payload for add/insert/replace operations.", + ) + updates: dict[str, Any] | None = Field( + None, + description="Fields to merge into the existing conflict for update operations.", + ) + + @model_validator(mode="before") + @classmethod + def _infer_op(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + if data.get("op") is None: + updates = data.get("updates") + conflict = data.get("conflict") + index = data.get("index") + if updates is not None: + data = {**data, "op": "update"} + elif conflict is not None and index is not None: + data = {**data, "op": "replace"} + elif conflict is not None: + data = {**data, "op": "add"} + elif index is not None: + data = {**data, "op": "remove"} + return data + + @model_validator(mode="after") + def _validate_shape(self) -> "ConflictPatchOperation": + if self.op is None: + raise ValueError( + "op is required and could not be inferred; " + "provide op explicitly (add/insert/replace/update/remove/clear)" + ) + if self.op in ("insert", "replace", "update", "remove") and self.index is None: + raise ValueError("index is required for insert/replace/update/remove") + if self.op in ("add", "insert", "replace") and self.conflict is None: + raise ValueError("conflict is required for add/insert/replace") + if self.op == "update" and self.updates is None: + raise ValueError("updates is required for update") + return self + + +class ConflictListPatch(BaseModel): + """Patch operation for conflict list fields.""" + + operations: list[ConflictPatchOperation] = Field( + ..., + description=( + "Ordered index-based operations to apply to the conflicts list. " + "Use numeric index for update/insert/replace/remove operations." + ), + ) + + +def apply_text_patch(current: str, patch: TextPatch) -> str: + """Apply a text patch and return updated value.""" + text = current or "" + if patch.operation == "replace": + return patch.value or "" + if patch.operation == "append": + return text + (patch.value or "") + if patch.operation == "prepend": + return (patch.value or "") + text + + old_text = patch.old_text or "" + new_text = patch.new_text or "" + count = text.count(old_text) + if count == 0: + raise ValueError("replace_text failed: old_text was not found") + + if patch.occurrence == "unique": + if count != 1: + raise ValueError(f"replace_text failed: expected one match, found {count}") + return text.replace(old_text, new_text, 1) + + if patch.occurrence == "all": + return text.replace(old_text, new_text) + + if patch.occurrence == "last": + idx = text.rfind(old_text) + return text[:idx] + new_text + text[idx + len(old_text) :] + + return text.replace(old_text, new_text, 1) + + +def apply_string_list_patch(current: list[str], patch: StringListPatch) -> list[str]: + """Apply string list patch while preserving existing items by default.""" + result: list[str] + if patch.set is not None: + result = list(patch.set) + elif patch.clear: + result = [] + else: + result = list(current or []) + + if patch.add: + result.extend(patch.add) + + if patch.remove: + remove_set = set(patch.remove) + result = [item for item in result if item not in remove_set] + + if patch.unique: + deduped: list[str] = [] + seen: set[str] = set() + for item in result: + if item in seen: + continue + seen.add(item) + deduped.append(item) + result = deduped + + return result + + +def apply_conflict_list_patch( + current: list[dict[str, Any]], patch: ConflictListPatch +) -> list[dict[str, Any]]: + """Apply ordered conflict-list operations.""" + result = [dict(item) for item in (current or []) if isinstance(item, dict)] + + for op in patch.operations: + if op.op == "clear": + result = [] + continue + + if op.op == "add": + result.append(dict(op.conflict or {})) + continue + + if op.index is None: + raise ValueError("Conflict operation is missing index") + if op.index < 0 or op.index > len(result): + raise ValueError( + f"Conflict operation index {op.index} is out of bounds for size {len(result)}" + ) + + if op.op == "insert": + result.insert(op.index, dict(op.conflict or {})) + elif op.op == "replace": + if op.index >= len(result): + raise ValueError( + f"replace index {op.index} is out of bounds for size {len(result)}" + ) + result[op.index] = dict(op.conflict or {}) + elif op.op == "update": + if op.index >= len(result): + raise ValueError( + f"update index {op.index} is out of bounds for size {len(result)}" + ) + merged = dict(result[op.index]) + merged.update(op.updates or {}) + result[op.index] = merged + elif op.op == "remove": + if op.index >= len(result): + raise ValueError( + f"remove index {op.index} is out of bounds for size {len(result)}" + ) + result.pop(op.index) + + return result diff --git a/src/augmentedquill/services/chat/chat_tools/project_tools.py b/src/augmentedquill/services/chat/chat_tools/project_tools.py index 46bde885..ebc75824 100644 --- a/src/augmentedquill/services/chat/chat_tools/project_tools.py +++ b/src/augmentedquill/services/chat/chat_tools/project_tools.py @@ -34,8 +34,8 @@ class GetProjectOverviewParams(BaseModel): """Parameters for get_project_overview.""" include_notes: bool = Field( - False, - description="If true, include per-chapter notes in the overview output (default false).", + True, + description="If true, include per-chapter notes in the overview output (default true).", ) @@ -101,7 +101,6 @@ async def get_project_overview( ) -> Any: """Return project overview.""" data = _project_overview(include_notes=params.include_notes) - # Return data directly - decorator handles wrapping in tool message format return data diff --git a/src/augmentedquill/services/chat/chat_tools/search_tools.py b/src/augmentedquill/services/chat/chat_tools/search_tools.py index 5af710d7..6100c41d 100644 --- a/src/augmentedquill/services/chat/chat_tools/search_tools.py +++ b/src/augmentedquill/services/chat/chat_tools/search_tools.py @@ -175,8 +175,13 @@ async def replace_in_project( if result.replacements_made > 0: mutations["story_changed"] = True + if result.changed_sections_meta: + mutations["change_locations"] = [ + loc.model_dump() for loc in result.changed_sections_meta + ] return { "replacements_made": result.replacements_made, "changed_sections": result.changed_sections, + "change_locations": [loc.model_dump() for loc in result.changed_sections_meta], } diff --git a/src/augmentedquill/services/chat/chat_tools/sourcebook_tools.py b/src/augmentedquill/services/chat/chat_tools/sourcebook_tools.py index 8102e51c..da31e833 100644 --- a/src/augmentedquill/services/chat/chat_tools/sourcebook_tools.py +++ b/src/augmentedquill/services/chat/chat_tools/sourcebook_tools.py @@ -28,6 +28,12 @@ sourcebook_update_entry, _get_story_data, ) +from augmentedquill.services.chat.chat_tools.metadata_patching import ( + StringListPatch, + TextPatch, + apply_string_list_patch, + apply_text_patch, +) def _strip_internal_sourcebook_fields(entry: dict | None) -> dict | None: @@ -167,13 +173,25 @@ class UpdateSourcebookEntryParams(BaseModel): name_or_id: str = Field(..., description="The name or ID of the entry to update") name: str | None = Field(None, description="New name for the entry") description: str | None = Field(None, description="New description for the entry") + description_patch: TextPatch | None = Field( + None, + description="Optional patch operation for partially editing description.", + ) category: str | None = Field(None, description="New category for the entry") synonyms: list[str] | None = Field( None, description="New list of synonyms for the entry" ) + synonyms_patch: StringListPatch | None = Field( + None, + description="Optional patch operation for synonyms (add/remove/set/clear).", + ) images: list[str] | None = Field( None, description="New list of image IDs for the entry" ) + images_patch: StringListPatch | None = Field( + None, + description="Optional patch operation for images (add/remove/set/clear).", + ) class DeleteSourcebookEntryParams(BaseModel): @@ -240,9 +258,10 @@ async def create_sourcebook_entry( @chat_tool( description=( "Update an existing sourcebook entry. Provide only the fields you want to change; this is a partial replacement. " - "At least one of name, description, category, synonyms, or images must be provided. " + "At least one of name, description, description_patch, category, synonyms, synonyms_patch, images, or images_patch must be provided. " "If category is provided, it must be one of: Character, Location, Organization, Item, Event, Lore, Other. " - "For better lookup, also update synonyms and relations (e.g., related characters/locations/organizations) when applicable." + "For better lookup, also update synonyms and relations (e.g., related characters/locations/organizations) when applicable. " + "Use add_sourcebook_relation/remove_sourcebook_relation for atomic relation edits without replacing entry fields." ), allowed_roles=(CHAT_ROLE,), capability="sourcebook-write", @@ -254,21 +273,51 @@ async def update_sourcebook_entry( if ( params.name is None and params.description is None + and params.description_patch is None and params.category is None and params.synonyms is None + and params.synonyms_patch is None and params.images is None + and params.images_patch is None ): return { - "error": "No update fields provided. Provide at least one of name, description, category, synonyms, or images with replacement values to update the entry." + "error": "No update fields provided. Provide at least one of name, description, description_patch, category, synonyms, synonyms_patch, images, or images_patch." } + current = sourcebook_get_entry(params.name_or_id) + if not isinstance(current, dict): + return {"error": "Entry not found."} + + description_value = params.description + if params.description_patch is not None: + description_value = apply_text_patch( + str(current.get("description") or ""), + params.description_patch, + ) + + synonyms_value = params.synonyms + if params.synonyms_patch is not None: + current_synonyms = current.get("synonyms") + if not isinstance(current_synonyms, list): + current_synonyms = [] + synonyms_value = apply_string_list_patch( + current_synonyms, params.synonyms_patch + ) + + images_value = params.images + if params.images_patch is not None: + current_images = current.get("images") + if not isinstance(current_images, list): + current_images = [] + images_value = apply_string_list_patch(current_images, params.images_patch) + result = sourcebook_update_entry( name_or_id=params.name_or_id, name=params.name, - description=params.description, + description=description_value, category=params.category, - synonyms=params.synonyms, - images=params.images, + synonyms=synonyms_value, + images=images_value, ) if "error" not in result: mutations["story_changed"] = True diff --git a/src/augmentedquill/services/chat/chat_tools/story_tools.py b/src/augmentedquill/services/chat/chat_tools/story_tools.py index b34c49bb..3ed940b3 100644 --- a/src/augmentedquill/services/chat/chat_tools/story_tools.py +++ b/src/augmentedquill/services/chat/chat_tools/story_tools.py @@ -9,7 +9,6 @@ from typing import Any -import json as _json import os from pydantic import BaseModel, Field @@ -21,6 +20,7 @@ EDITING_ROLE, chat_tool, ) +from augmentedquill.services.projects.project_helpers import _snap_to_boundary from augmentedquill.services.projects.projects import ( get_active_project_dir, read_book_content as _read_book_content, @@ -34,6 +34,14 @@ read_editing_scratchpad as _read_editing_scratchpad, write_editing_scratchpad as _write_editing_scratchpad, ) +from augmentedquill.services.chat.chat_tools.metadata_patching import ( + ConflictListPatch, + StringListPatch, + TextPatch, + apply_conflict_list_patch, + apply_string_list_patch, + apply_text_patch, +) # Pydantic models for tool parameters @@ -50,11 +58,27 @@ class UpdateStoryMetadataParams(BaseModel): title: str | None = Field(None, description="The new story title") summary: str | None = Field(None, description="The new story summary") notes: str | None = Field(None, description="General notes for the story") + summary_patch: TextPatch | None = Field( + None, + description="Optional patch operation for partially editing summary.", + ) + notes_patch: TextPatch | None = Field( + None, + description="Optional patch operation for partially editing notes.", + ) tags: list[str] | None = Field(None, description="List of tags for the story") + tags_patch: StringListPatch | None = Field( + None, + description="Optional patch operation for tags (add/remove/set/clear).", + ) conflicts: list[dict] | None = Field( None, description="List of active story conflicts with description and optional resolution.", ) + conflicts_patch: ConflictListPatch | None = Field( + None, + description="Optional ordered operations for partial conflict updates.", + ) class ReadStoryContentParams(BaseModel): @@ -62,12 +86,16 @@ class ReadStoryContentParams(BaseModel): start: int = Field( 0, - description="Starting character index (0-based).", + description="Starting character index (0-based). Ignored when read_from_end=True.", ) max_chars: int = Field( 8000, description="Maximum number of characters to return (max 8000).", ) + read_from_end: bool = Field( + False, + description="When True, return the last max_chars characters instead of reading from start. Useful for reading the most recent prose before appending.", + ) class WriteStoryContentParams(BaseModel): @@ -89,6 +117,14 @@ class UpdateBookMetadataParams(BaseModel): title: str | None = Field(None, description="The new book title") summary: str | None = Field(None, description="The new book summary") notes: str | None = Field(None, description="General notes for the book") + summary_patch: TextPatch | None = Field( + None, + description="Optional patch operation for partially editing summary.", + ) + notes_patch: TextPatch | None = Field( + None, + description="Optional patch operation for partially editing notes.", + ) class ReadBookContentParams(BaseModel): @@ -97,12 +133,16 @@ class ReadBookContentParams(BaseModel): book_id: str = Field(..., description="The UUID of the book") start: int = Field( 0, - description="Starting character index (0-based).", + description="Starting character index (0-based). Ignored when read_from_end=True.", ) max_chars: int = Field( 8000, description="Maximum number of characters to return (max 8000).", ) + read_from_end: bool = Field( + False, + description="When True, return the last max_chars characters instead of reading from start. Useful for reading the most recent prose before appending.", + ) class WriteBookContentParams(BaseModel): @@ -112,24 +152,6 @@ class WriteBookContentParams(BaseModel): content: str = Field(..., description="The new content for the book") -class GetStorySummaryParams(BaseModel): - """Parameters for get_story_summary (no parameters needed).""" - - pass - - -class GetStoryTagsParams(BaseModel): - """Parameters for get_story_tags (no parameters needed).""" - - pass - - -class SetStoryTagsParams(BaseModel): - """Parameters for setting story tags.""" - - tags: list[str] = Field(..., description="Array of tag strings") - - class SyncStorySummaryParams(BaseModel): """Parameters for auto-generating story summary.""" @@ -139,12 +161,6 @@ class SyncStorySummaryParams(BaseModel): ) -class WriteStorySummaryParams(BaseModel): - """Parameters for directly setting story summary.""" - - summary: str = Field(..., description="The new story summary text") - - class ReadScratchpadParams(BaseModel): """Parameters for reading the scratchpad.""" @@ -207,7 +223,11 @@ async def get_story_metadata( @chat_tool( - description="Update story-level metadata such as title, summary, notes, or tags. Provide only the fields you want to change.", + description=( + "Update story-level metadata such as title, summary, notes, tags, and conflicts. " + "Use *_patch fields (notes_patch, summary_patch, tags_patch, conflicts_patch) for " + "safe partial edits that keep untouched content." + ), allowed_roles=(CHAT_ROLE, EDITING_ROLE), capability="metadata-write", ) @@ -215,19 +235,43 @@ async def update_story_metadata( params: UpdateStoryMetadataParams, payload: dict, mutations: dict ) -> Any: """Update Story Metadata.""" + active = get_active_project_dir() + story = load_story_config((active / "story.json") if active else None) or {} + + summary_value = params.summary + if params.summary_patch is not None: + summary_value = apply_text_patch( + story.get("story_summary", ""), params.summary_patch + ) + + notes_value = params.notes + if params.notes_patch is not None: + notes_value = apply_text_patch(story.get("notes", ""), params.notes_patch) + + tags_value = params.tags + if params.tags_patch is not None: + tags_value = apply_string_list_patch(story.get("tags") or [], params.tags_patch) + + conflicts_value = params.conflicts + if params.conflicts_patch is not None: + conflicts_value = apply_conflict_list_patch( + story.get("conflicts") or [], + params.conflicts_patch, + ) + _update_story_metadata( title=params.title, - summary=params.summary, - notes=params.notes, - tags=params.tags, - conflicts=params.conflicts, + summary=summary_value, + notes=notes_value, + tags=tags_value, + conflicts=conflicts_value, ) mutations["story_changed"] = True return {"ok": True, "message": "Story metadata updated successfully"} @chat_tool( - description="Read the story-level introduction or content file.", + description="Read the story-level introduction or content file. Use read_from_end=True to read the last max_chars characters, which is recommended before appending prose.", allowed_roles=(CHAT_ROLE, EDITING_ROLE), capability="metadata-read", ) @@ -236,14 +280,21 @@ async def read_story_content( ) -> Any: """Read story content.""" content = _read_story_content() or "" - start = max(0, params.start) max_chars = max(1, min(8000, params.max_chars)) - end = min(len(content), start + max_chars) + total = len(content) + if params.read_from_end: + raw_start = max(0, total - max_chars) + start = _snap_to_boundary(content, raw_start, forward=False) + end = total + else: + start = max(0, params.start) + raw_end = min(total, start + max_chars) + end = min(total, _snap_to_boundary(content, raw_end, forward=True)) return { "content": content[start:end], "start": start, "end": end, - "total": len(content), + "total": total, } @@ -292,7 +343,10 @@ async def get_book_metadata( @chat_tool( - description="Update the title, summary, or notes of a specific book. Provide only the fields you want to change.", + description=( + "Update the title, summary, or notes of a specific book. " + "Use summary_patch/notes_patch for safe partial edits that preserve remaining text." + ), allowed_roles=(CHAT_ROLE,), capability="metadata-write", project_types=("series",), @@ -301,15 +355,40 @@ async def update_book_metadata( params: UpdateBookMetadataParams, payload: dict, mutations: dict ) -> Any: """Update Book Metadata.""" + active = get_active_project_dir() + story = load_story_config((active / "story.json") if active else None) or {} + books = story.get("books", []) + + book_id = os.path.basename(params.book_id) if params.book_id else "" + target = next( + (b for b in books if b.get("id") == book_id or b.get("folder") == book_id), + None, + ) + if not target: + return {"error": f"Book ID {book_id} not found"} + + summary_value = params.summary + if params.summary_patch is not None: + summary_value = apply_text_patch( + target.get("summary", ""), params.summary_patch + ) + + notes_value = params.notes + if params.notes_patch is not None: + notes_value = apply_text_patch(target.get("notes", ""), params.notes_patch) + _update_book_metadata( - params.book_id, title=params.title, summary=params.summary, notes=params.notes + params.book_id, + title=params.title, + summary=summary_value, + notes=notes_value, ) mutations["story_changed"] = True return {"ok": True} @chat_tool( - description="Read the content file for a specific book.", + description="Read the content file for a specific book. Use read_from_end=True to read the last max_chars characters.", allowed_roles=(CHAT_ROLE, EDITING_ROLE), capability="metadata-read", project_types=("series",), @@ -319,14 +398,21 @@ async def read_book_content( ) -> Any: """Read book content.""" content = _read_book_content(params.book_id) or "" - start = max(0, params.start) max_chars = max(1, min(8000, params.max_chars)) - end = min(len(content), start + max_chars) + total = len(content) + if params.read_from_end: + raw_start = max(0, total - max_chars) + start = _snap_to_boundary(content, raw_start, forward=False) + end = total + else: + start = max(0, params.start) + raw_end = min(total, start + max_chars) + end = min(total, _snap_to_boundary(content, raw_end, forward=True)) return { "content": content[start:end], "start": start, "end": end, - "total": len(content), + "total": total, } @@ -431,45 +517,6 @@ async def write_scratchpad( return {"ok": True} -async def get_story_summary_tool( - params: GetStorySummaryParams, payload: dict, mutations: dict -) -> Any: - """Deprecated: use get_story_metadata instead.""" - active = get_active_project_dir() - story = load_story_config((active / "story.json") if active else None) or {} - summary = story.get("story_summary", "") - return {"story_summary": summary} - - -async def get_story_tags( - params: GetStoryTagsParams, payload: dict, mutations: dict -) -> Any: - """Deprecated: use get_story_metadata instead.""" - active = get_active_project_dir() - story = load_story_config((active / "story.json") if active else None) or {} - tags = story.get("tags", []) - return {"tags": tags} - - -async def set_story_tags( - params: SetStoryTagsParams, payload: dict, mutations: dict -) -> Any: - """Deprecated: use update_story_metadata instead.""" - active = get_active_project_dir() - if not active: - return {"error": "No active project"} - - story_path = active / "story.json" - story = load_story_config(story_path) or {} - story["tags"] = params.tags - - with open(story_path, "w", encoding="utf-8") as f: - _json.dump(story, f, indent=2, ensure_ascii=False) - - mutations["story_changed"] = True - return {"tags": params.tags, "message": "Story tags updated successfully"} - - @chat_tool( description=( "Auto-generate a story summary from the current project prose context using AI. " @@ -491,25 +538,6 @@ async def sync_story_summary( return data -async def write_story_summary( - params: WriteStorySummaryParams, payload: dict, mutations: dict -) -> Any: - """Deprecated: use update_story_metadata with summary= instead.""" - active = get_active_project_dir() - if not active: - return {"error": "No active project"} - - story_path = active / "story.json" - story = load_story_config(story_path) or {} - story["story_summary"] = params.summary.strip() - - with open(story_path, "w", encoding="utf-8") as f: - _json.dump(story, f, indent=2, ensure_ascii=False) - - mutations["story_changed"] = True - return {"summary": params.summary, "message": "Story summary updated successfully"} - - # --------------------------------------------------------------------------- # EDITING scratchpad (separate from the CHAT scratchpad) # --------------------------------------------------------------------------- diff --git a/src/augmentedquill/services/chat/chat_tools/undo_tools.py b/src/augmentedquill/services/chat/chat_tools/undo_tools.py new file mode 100644 index 00000000..7de88639 --- /dev/null +++ b/src/augmentedquill/services/chat/chat_tools/undo_tools.py @@ -0,0 +1,175 @@ +# Copyright (C) 2026 StableLlama +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +"""Defines the undo tools unit so this responsibility stays isolated, testable, and easy to evolve. + +LLM-callable tool that lets the CHAT LLM undo its own recent project modifications +without requiring user intervention via the frontend undo button. +""" + +import json +import re +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, Field + +from augmentedquill.services.chat.chat_tool_decorator import ( + CHAT_ROLE, + EDITING_ROLE, + chat_tool, +) +from augmentedquill.services.projects.project_snapshots import restore_project_snapshot +from augmentedquill.services.projects.projects import get_active_project_dir + +# Must match _CHAT_TOOL_BATCH_DIR in augmentedquill.api.v1.chat +_BATCH_DIR = ".aq_history/chat_tool_batches" +_BATCH_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,80}$") + + +def _load_batch(project_dir: Path, batch_id: str) -> dict[str, Any]: + """Load a stored batch snapshot, raising BadRequestError on invalid input.""" + from augmentedquill.services.exceptions import BadRequestError + + if not _BATCH_ID_RE.fullmatch(batch_id): + raise BadRequestError(f"Invalid batch id: {batch_id!r}") + batch_file = project_dir / _BATCH_DIR / batch_id / "batch.json" + if not batch_file.is_file(): + raise BadRequestError(f"No snapshot found for batch: {batch_id!r}") + return json.loads(batch_file.read_text(encoding="utf-8")) + + +def _find_most_recent_batch(project_dir: Path) -> dict[str, Any] | None: + """Return the most recently created batch snapshot for the project, or None.""" + batches_dir = project_dir / _BATCH_DIR + if not batches_dir.is_dir(): + return None + + entries: list[dict[str, Any]] = [] + for batch_file in batches_dir.glob("*/batch.json"): + try: + data = json.loads(batch_file.read_text(encoding="utf-8")) + if isinstance(data, dict) and data.get("batch_id"): + entries.append(data) + except (OSError, json.JSONDecodeError): + pass + + if not entries: + return None + + entries.sort(key=lambda e: str(e.get("created_at") or ""), reverse=True) + return entries[0] + + +# ============================================================================ +# undo_last_tool_changes +# ============================================================================ + + +class UndoLastToolChangesParams(BaseModel): + """Parameters for the undo_last_tool_changes tool.""" + + scope: str = Field( + ..., + description=( + "'last_call' undoes the project changes made by the single most recent LLM" + " tool call (usually a smaller delta than one full undo-stack entry)." + " 'all_this_turn' undoes ALL project changes made by LLM tools since the" + " last user prompt, effectively reverting the project to the state it was in" + " before the current LLM turn began." + " Pass 'last_call' to fix the most recent mistake; pass 'all_this_turn'" + " when you want to start the current turn's work over from scratch." + ), + ) + batch_ids: list[str] | None = Field( + None, + description=( + "For scope='all_this_turn': provide ALL batch_id values received in tool" + " results for this turn, oldest first, so every batch can be reversed in" + " correct order. For scope='last_call': omit this (the system auto-detects" + " the most recent batch) or pass just the single batch_id to undo." + ), + ) + + +@chat_tool( + description=( + "Undo recent LLM tool call project changes without waiting for the user to" + " press the undo button." + " scope='last_call' reverses only the most recent tool call's project" + " modifications (granular undo — usually much less than one full undo-stack" + " entry)." + " scope='all_this_turn' reverses ALL project modifications made by LLM tools" + " since the last user prompt, returning the project to its pre-turn state" + " (equivalent to popping the current undo-stack entry)." + " After a successful undo the tool reports which batches were restored so you" + " can confirm the rollback and decide how to proceed." + ), + allowed_roles=(CHAT_ROLE, EDITING_ROLE), + capability="undo", + project_types=("short-story", "novel", "series"), +) +async def undo_last_tool_changes( + params: UndoLastToolChangesParams, payload: dict, mutations: dict +) -> Any: + """Restore project content to the state before one or more recent tool call batches.""" + from augmentedquill.services.exceptions import BadRequestError + + if params.scope not in ("last_call", "all_this_turn"): + raise BadRequestError( + f"Invalid scope: {params.scope!r}. Use 'last_call' or 'all_this_turn'." + ) + + project_dir = get_active_project_dir() + if project_dir is None: + raise BadRequestError("No active project. Cannot undo.") + + batches_to_undo: list[dict[str, Any]] = [] + + if params.scope == "last_call": + if params.batch_ids: + # Use the last provided batch_id as the single target. + batch = _load_batch(project_dir, params.batch_ids[-1]) + batches_to_undo = [batch] + else: + batch = _find_most_recent_batch(project_dir) + if batch is None: + raise BadRequestError( + "No tool call batches found for the active project." + ) + batches_to_undo = [batch] + + else: # all_this_turn + if not params.batch_ids: + raise BadRequestError( + "scope='all_this_turn' requires batch_ids: provide all batch_id values" + " from tool results in this turn, oldest first." + ) + for bid in params.batch_ids: + batches_to_undo.append(_load_batch(project_dir, bid)) + + # Restore batches in reverse order so we unwind the most recent change first. + restored_ids: list[str] = [] + for batch in reversed(batches_to_undo): + before_snapshot = batch.get("before") + if not isinstance(before_snapshot, dict): + raise BadRequestError( + f"Batch {batch.get('batch_id')!r} has an invalid snapshot." + ) + restore_project_snapshot(project_dir, before_snapshot) + restored_ids.append(str(batch.get("batch_id") or "")) + + mutations["story_changed"] = True + return { + "undone": True, + "scope": params.scope, + "restored_batches": restored_ids, + "status": ( + f"Reverted {len(restored_ids)} batch(es): {', '.join(restored_ids)}." + " Project content has been restored to its prior state." + ), + } diff --git a/src/augmentedquill/services/projects/project_helpers.py b/src/augmentedquill/services/projects/project_helpers.py index 1616c2d0..4f38f6a4 100644 --- a/src/augmentedquill/services/projects/project_helpers.py +++ b/src/augmentedquill/services/projects/project_helpers.py @@ -104,7 +104,7 @@ def _handle_chapters(chapters: Any) -> Any: def _project_overview(include_notes: bool = False) -> dict: - """Return project title and a list of chapters with id, filename, title, summary. + """Return project title and a list of chapters with id, title, and summary. Notes are excluded by default to keep the overview lightweight. """ @@ -120,9 +120,7 @@ def _project_overview(include_notes: bool = False) -> dict: } if p_type == "short-story": - fn = story.get("content_file", "content.md") draft = { - "filename": fn, "title": story.get("project_title") or (active.name if active else ""), "summary": story.get("story_summary") or "", } @@ -131,7 +129,6 @@ def _project_overview(include_notes: bool = False) -> dict: return { **base_info, - "content_file": fn, "draft": draft, } @@ -184,7 +181,6 @@ def _project_overview(include_notes: bool = False) -> dict: meta = id_to_meta.get(vid, {}) chapter_item = { "id": vid, - "filename": path.name, "title": meta.get("title") or path.stem, "summary": meta.get("summary") or "", } @@ -216,7 +212,6 @@ def _project_overview(include_notes: bool = False) -> dict: title = path.name chapter_item = { "id": idx, - "filename": path.name, "title": title, "summary": summary, } @@ -243,3 +238,48 @@ def _chapter_content_slice(chap_id: int, start: int = 0, max_chars: int = 8000) "total": total, "content": text[start:end], } + + +def _snap_to_boundary( + text: str, + pos: int, + forward: bool, + word_scan: int = 20, + sentence_scan: int = 100, +) -> int: + """Adjust a character boundary to avoid mid-word or mid-sentence cuts. + + When forward=True, adjusts an end boundary forward (scans right). + When forward=False, adjusts a start boundary backward (scans left). + Prefers a newline (sentence boundary) over a space (word boundary). + Returns pos unchanged if it is already on a clean break. + """ + total = len(text) + _WS = (" ", "\n", "\t", "\r") + + if forward: + # Clean if pos is at end or the next char is whitespace. + if pos >= total or text[pos] in _WS: + return pos + snippet_far = text[pos : min(total, pos + sentence_scan)] + nl_idx = snippet_far.find("\n") + if nl_idx >= 0: + return pos + nl_idx + 1 + snippet_near = text[pos : min(total, pos + word_scan)] + sp_idx = snippet_near.find(" ") + if sp_idx >= 0: + return pos + sp_idx + 1 + return pos + else: + # Clean if pos is at start or the previous char is whitespace. + if pos <= 0 or text[pos - 1] in _WS: + return pos + snippet_far = text[max(0, pos - sentence_scan) : pos] + nl_idx = snippet_far.rfind("\n") + if nl_idx >= 0: + return max(0, pos - sentence_scan) + nl_idx + 1 + snippet_near = text[max(0, pos - word_scan) : pos] + sp_idx = snippet_near.rfind(" ") + if sp_idx >= 0: + return max(0, pos - word_scan) + sp_idx + 1 + return pos diff --git a/src/augmentedquill/services/projects/projects.py b/src/augmentedquill/services/projects/projects.py index dcdb1c09..fdf86c0f 100644 --- a/src/augmentedquill/services/projects/projects.py +++ b/src/augmentedquill/services/projects/projects.py @@ -9,10 +9,12 @@ from __future__ import annotations +from contextlib import contextmanager +from contextvars import ContextVar from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Dict, List, Tuple +from typing import Dict, Iterator, List, Tuple import os from augmentedquill.services.projects.project_story_ops import ( @@ -61,6 +63,10 @@ DEFAULT_PROJECTS_REGISTRY_PATH, ) +_ACTIVE_PROJECT_OVERRIDE: ContextVar[Path | None] = ContextVar( + "active_project_override", default=None +) + def get_projects_root() -> Path: """Return the root directory where projects (stories) are stored. @@ -106,9 +112,22 @@ def set_active_project(path: Path) -> None: def get_active_project_dir() -> Path | None: """Return active project dir.""" + overridden = _ACTIVE_PROJECT_OVERRIDE.get() + if overridden is not None: + return overridden return get_active_project_dir_from_registry(load_registry()) +@contextmanager +def use_project_context(path: Path | None) -> Iterator[None]: + """Temporarily resolve active-project lookups to a request-scoped path.""" + token = _ACTIVE_PROJECT_OVERRIDE.set(path) + try: + yield + finally: + _ACTIVE_PROJECT_OVERRIDE.reset(token) + + def _require_active_project() -> Path: """Helper for active project..""" active = get_active_project_dir() diff --git a/src/augmentedquill/services/search/replace_service.py b/src/augmentedquill/services/search/replace_service.py index 6115bb54..06d90c15 100644 --- a/src/augmentedquill/services/search/replace_service.py +++ b/src/augmentedquill/services/search/replace_service.py @@ -19,8 +19,9 @@ from augmentedquill.models.search import ( ReplaceAllRequest, - ReplaceSingleRequest, + ReplaceChangeLocation, ReplaceResponse, + ReplaceSingleRequest, SearchScope, ) from augmentedquill.services.search.search_service import ( @@ -30,6 +31,20 @@ ) +def _make_change_location( + type: str, + target_id: str | None, + field: str | None, + label: str, +) -> ReplaceChangeLocation: + return ReplaceChangeLocation( + type=type, + target_id=target_id, + field=field, + label=label, + ) + + def _apply_replace( text: str, query: str, @@ -136,13 +151,13 @@ def _replace_in_chapter_content( is_regex: bool, is_phonetic: bool, match_index: int | None, -) -> tuple[int, str | None]: - """Replace in a single chapter's prose. Returns (count, label_or_None).""" +) -> tuple[int, str | None, ReplaceChangeLocation | None]: + """Replace in a single chapter's prose. Returns (count, label_or_None, location_or_None).""" from augmentedquill.services.projects.projects import write_chapter_content content = _read_chapter_content(chap_id) if not content: - return 0, None + return 0, None, None if match_index is None: new_content, count = _apply_replace( @@ -161,8 +176,13 @@ def _replace_in_chapter_content( if count > 0: write_chapter_content(chap_id, new_content) - return count, f"Chapter {chap_id} content" - return 0, None + label = f"Chapter {chap_id} content" + return ( + count, + label, + _make_change_location("chapter", str(chap_id), "content", label), + ) + return 0, None, None # ─── Chapter metadata ──────────────────────────────────────────────────────── @@ -179,7 +199,7 @@ def _replace_in_chapter_metadata( target_section_id: str | None = None, target_field: str | None = None, match_index: int | None = None, -) -> tuple[int, list[str]]: +) -> tuple[int, list[str], list[ReplaceChangeLocation]]: """Replace in chapter metadata fields across all (or a targeted) chapter.""" from augmentedquill.core.config import load_story_config, save_story_config @@ -187,7 +207,7 @@ def _replace_in_chapter_metadata( try: story = load_story_config(story_path) or {} except Exception: - return 0, [] + return 0, [], [] p_type = story.get("project_type", "novel") if p_type == "series": @@ -199,6 +219,7 @@ def _replace_in_chapter_metadata( total = 0 changed = [] + change_locations: list[ReplaceChangeLocation] = [] for idx, entry in enumerate(all_chapters): chap_id = chapter_ids[idx] if idx < len(chapter_ids) else idx + 1 @@ -231,7 +252,16 @@ def _replace_in_chapter_metadata( if count > 0: entry[field_key] = new_val total += count - changed.append(f"{title_label} {field_key}") + label = f"{title_label} {field_key}" + changed.append(label) + change_locations.append( + _make_change_location( + "metadata", + str(chap_id), + field_key, + label, + ) + ) for cidx, conflict in enumerate(entry.get("conflicts") or []): for sub_field in ["description", "resolution"]: @@ -258,12 +288,21 @@ def _replace_in_chapter_metadata( if count > 0: conflict[sub_field] = new_val total += count - changed.append(f"{title_label} conflict {cidx + 1} {sub_field}") + label = f"{title_label} conflict {cidx + 1} {sub_field}" + changed.append(label) + change_locations.append( + _make_change_location( + "metadata", + str(chap_id), + full_field, + label, + ) + ) if total > 0: save_story_config(story_path, story) - return total, changed + return total, changed, change_locations # ─── Story metadata ────────────────────────────────────────────────────────── @@ -279,7 +318,7 @@ def _replace_in_story_metadata( target_section_id: str | None = None, target_field: str | None = None, match_index: int | None = None, -) -> tuple[int, list[str]]: +) -> tuple[int, list[str], list[ReplaceChangeLocation]]: """Replace in story metadata.""" from augmentedquill.core.config import load_story_config, save_story_config @@ -287,12 +326,19 @@ def _replace_in_story_metadata( try: story = load_story_config(story_path) or {} except Exception: - return 0, [] + return 0, [], [] total = 0 changed = [] - - story_fields = ["project_title", "story_summary", "notes", "private_notes"] + change_locations: list[ReplaceChangeLocation] = [] + + _story_field_labels: dict[str, str] = { + "project_title": "Story title", + "story_summary": "Story summary", + "notes": "Story notes", + "private_notes": "Story private notes", + } + story_fields = list(_story_field_labels.keys()) for field_key in story_fields: if target_field is not None and field_key != target_field: continue @@ -318,7 +364,11 @@ def _replace_in_story_metadata( if count > 0: story[field_key] = new_val total += count - changed.append(f"Story {field_key}") + label = _story_field_labels[field_key] + changed.append(label) + change_locations.append( + _make_change_location("metadata", "story", field_key, label) + ) for cidx, conflict in enumerate(story.get("conflicts") or []): for sub_field in ["description", "resolution"]: @@ -347,7 +397,11 @@ def _replace_in_story_metadata( if count > 0: conflict[sub_field] = new_val total += count - changed.append(f"Story conflict {cidx + 1} {sub_field}") + label = f"Story conflict {cidx + 1} {sub_field}" + changed.append(label) + change_locations.append( + _make_change_location("metadata", "story", full_field, label) + ) # Series books metadata for book in story.get("books", []): @@ -379,12 +433,21 @@ def _replace_in_story_metadata( if count > 0: book[field_key] = new_val total += count - changed.append(f"Book '{book_title}' {field_key}") + label = f"Book '{book_title}' {field_key}" + changed.append(label) + change_locations.append( + _make_change_location( + "book", + str(book_id), + field_key, + label, + ) + ) if total > 0: save_story_config(story_path, story) - return total, changed + return total, changed, change_locations # ─── Sourcebook ────────────────────────────────────────────────────────────── @@ -400,7 +463,7 @@ def _replace_in_sourcebook( target_section_id: str | None = None, target_field: str | None = None, match_index: int | None = None, -) -> tuple[int, list[str]]: +) -> tuple[int, list[str], list[ReplaceChangeLocation]]: """Replace text in sourcebook entries in story.json.""" from augmentedquill.core.config import load_story_config, save_story_config @@ -408,11 +471,11 @@ def _replace_in_sourcebook( try: story = load_story_config(story_path) or {} except Exception: - return 0, [] + return 0, [], [] sourcebook = story.get("sourcebook") or {} if not isinstance(sourcebook, dict): - return 0, [] + return 0, [], [] global_rels = story.get("sourcebook_relations") or [] if not isinstance(global_rels, list): @@ -420,6 +483,7 @@ def _replace_in_sourcebook( total = 0 changed = [] + change_locations: list[ReplaceChangeLocation] = [] rename_map: dict[str, str] = {} for entry_key, entry_data in list(sourcebook.items()): @@ -460,14 +524,29 @@ def _replace_in_sourcebook( if count > 0: total += count - changed.append(f"Sourcebook '{entry_id}' {field_label}") if field_key == "name": new_name = new_val - if new_name != entry_key and new_name not in sourcebook: + label = f"Sourcebook '{new_name}' {field_label}" + target_id = new_name + if new_name != entry_id and new_name not in sourcebook: rename_map[entry_key] = new_name + else: + target_id = entry_id else: + label = f"Sourcebook '{entry_id}' {field_label}" + target_id = entry_id entry_data[field_key] = new_val + changed.append(label) + change_locations.append( + _make_change_location( + "sourcebook", + target_id, + field_key, + label, + ) + ) + if target_field in (None, "synonyms"): synonyms = entry_data.get("synonyms") or [] new_synonyms: list[str] = [] @@ -492,7 +571,16 @@ def _replace_in_sourcebook( if synonym_count > 0: entry_data["synonyms"] = new_synonyms total += synonym_count - changed.append(f"Sourcebook '{entry_id}' synonyms") + label = f"Sourcebook '{entry_id}' synonyms" + changed.append(label) + change_locations.append( + _make_change_location( + "sourcebook", + entry_id, + "synonyms", + label, + ) + ) if target_field in (None, "relations") or ( target_field is not None and target_field.startswith("relations[") @@ -547,7 +635,16 @@ def _replace_in_sourcebook( if count > 0: rel[rel_field] = new_val total += count - changed.append(f"Sourcebook '{entry_id}' relation {rel_label}") + label = f"Sourcebook '{entry_id}' relation {rel_label}" + changed.append(label) + change_locations.append( + _make_change_location( + "sourcebook", + entry_id, + field_path, + label, + ) + ) for old_name, new_name in rename_map.items(): if old_name in sourcebook and new_name not in sourcebook: @@ -557,13 +654,20 @@ def _replace_in_sourcebook( rel["source_id"] = new_name if rel.get("target_id") == old_name: rel["target_id"] = new_name + for location in change_locations: + if location.type == "sourcebook" and location.target_id == old_name: + location.target_id = new_name + location.label = location.label.replace( + f"Sourcebook '{old_name}'", + f"Sourcebook '{new_name}'", + ) if total > 0: story["sourcebook"] = sourcebook story["sourcebook_relations"] = global_rels save_story_config(story_path, story) - return total, changed + return total, changed, change_locations # ─── Public API ────────────────────────────────────────────────────────────── @@ -583,6 +687,7 @@ def replace_all(req: ReplaceAllRequest, active: Path) -> ReplaceResponse: scope = req.scope total = 0 changed: list[str] = [] + changed_locations: list[ReplaceChangeLocation] = [] chapter_ids = _get_all_chapter_ids() @@ -598,27 +703,38 @@ def replace_all(req: ReplaceAllRequest, active: Path) -> ReplaceResponse: else chapter_ids ) for chap_id in ids: - count, label = _replace_in_chapter_content( + count, label, location = _replace_in_chapter_content( chap_id, q, r, cs, rx, ph, match_index=None ) if count > 0 and label: total += count changed.append(label) + if location is not None: + changed_locations.append(location) if scope in (SearchScope.metadata, SearchScope.all): - n, labels = _replace_in_chapter_metadata(active, chapter_ids, q, r, cs, rx, ph) + n, labels, locations = _replace_in_chapter_metadata( + active, chapter_ids, q, r, cs, rx, ph + ) total += n changed.extend(labels) - n, labels = _replace_in_story_metadata(active, q, r, cs, rx, ph) + changed_locations.extend(locations) + n, labels, locations = _replace_in_story_metadata(active, q, r, cs, rx, ph) total += n changed.extend(labels) + changed_locations.extend(locations) if scope in (SearchScope.sourcebook, SearchScope.all): - n, labels = _replace_in_sourcebook(active, q, r, cs, rx, ph) + n, labels, locations = _replace_in_sourcebook(active, q, r, cs, rx, ph) total += n changed.extend(labels) + changed_locations.extend(locations) - return ReplaceResponse(replacements_made=total, changed_sections=changed) + return ReplaceResponse( + replacements_made=total, + changed_sections=changed, + changed_sections_meta=changed_locations, + ) def replace_single(req: ReplaceSingleRequest, active: Path) -> ReplaceResponse: @@ -640,15 +756,17 @@ def replace_single(req: ReplaceSingleRequest, active: Path) -> ReplaceResponse: chap_id = int(sec_id) except ValueError: return ReplaceResponse(replacements_made=0, changed_sections=[]) - count, label = _replace_in_chapter_content( + count, label, location = _replace_in_chapter_content( chap_id, q, r, cs, rx, ph, match_index=idx ) - if count and label: - return ReplaceResponse(replacements_made=count, changed_sections=[label]) - return ReplaceResponse(replacements_made=0, changed_sections=[]) + return ReplaceResponse( + replacements_made=count, + changed_sections=[label] if count and label else [], + changed_sections_meta=[location] if location is not None else [], + ) if sec_type == "chapter_metadata": - n, labels = _replace_in_chapter_metadata( + n, labels, locations = _replace_in_chapter_metadata( active, chapter_ids, q, @@ -660,10 +778,14 @@ def replace_single(req: ReplaceSingleRequest, active: Path) -> ReplaceResponse: target_field=field, match_index=idx, ) - return ReplaceResponse(replacements_made=n, changed_sections=labels) + return ReplaceResponse( + replacements_made=n, + changed_sections=labels, + changed_sections_meta=locations, + ) if sec_type == "story_metadata": - n, labels = _replace_in_story_metadata( + n, labels, locations = _replace_in_story_metadata( active, q, r, @@ -674,10 +796,14 @@ def replace_single(req: ReplaceSingleRequest, active: Path) -> ReplaceResponse: target_field=field, match_index=idx, ) - return ReplaceResponse(replacements_made=n, changed_sections=labels) + return ReplaceResponse( + replacements_made=n, + changed_sections=labels, + changed_sections_meta=locations, + ) if sec_type == "sourcebook": - n, labels = _replace_in_sourcebook( + n, labels, locations = _replace_in_sourcebook( active, q, r, @@ -688,6 +814,10 @@ def replace_single(req: ReplaceSingleRequest, active: Path) -> ReplaceResponse: target_field=field, match_index=idx, ) - return ReplaceResponse(replacements_made=n, changed_sections=labels) + return ReplaceResponse( + replacements_made=n, + changed_sections=labels, + changed_sections_meta=locations, + ) return ReplaceResponse(replacements_made=0, changed_sections=[]) diff --git a/src/augmentedquill/services/story/story_api_prompt_ops.py b/src/augmentedquill/services/story/story_api_prompt_ops.py index c41e7964..f104d37c 100644 --- a/src/augmentedquill/services/story/story_api_prompt_ops.py +++ b/src/augmentedquill/services/story/story_api_prompt_ops.py @@ -40,8 +40,6 @@ def _get_read_only_tool_schemas(project_type: str | None = None) -> list[dict]: relevant_names = { "get_project_overview", "get_story_metadata", - "get_story_summary", - "get_story_tags", "get_chapter_metadata", "get_chapter_content", "get_chapter_summary", diff --git a/src/augmentedquill/utils/llm_parsing.py b/src/augmentedquill/utils/llm_parsing.py index 15daf2f2..1d0615ce 100644 --- a/src/augmentedquill/utils/llm_parsing.py +++ b/src/augmentedquill/utils/llm_parsing.py @@ -78,9 +78,12 @@ def _sanitize_visible_prose(content: str) -> str: # Remove inline thought/thinking sections (closed and unclosed). cleaned = re.sub( - r"<(thought|thinking)>.*?", "", cleaned, flags=re.IGNORECASE | re.DOTALL + r"<(thought|thinking|think)>.*?", + "", + cleaned, + flags=re.IGNORECASE | re.DOTALL, ) - cleaned = re.sub(r"<(thought|thinking)>.*$", "", cleaned, flags=re.IGNORECASE) + cleaned = re.sub(r"<(thought|thinking|think)>.*$", "", cleaned, flags=re.IGNORECASE) # Remove channel protocol tokens, keep actual prose. cleaned = re.sub(r"<\|?channel\|?>", "", cleaned, flags=re.IGNORECASE) @@ -116,6 +119,7 @@ def _sanitize_visible_prose(content: str) -> str: if cleaned.strip().lower() in { "thought", "thinking", + "think", "analysis", "reasoning", "final", @@ -439,7 +443,7 @@ def extract_thinking_from_content(content: str) -> str: return "" match = re.search( - r"<(thought|thinking)>(.*?)", + r"<(thought|thinking|think)>(.*?)", content, re.DOTALL | re.IGNORECASE, ) @@ -568,7 +572,12 @@ def parse_stream_channel_fragments( cleaned_piece = _sanitize_visible_prose(piece) if cleaned_piece is None or cleaned_piece == "": continue - if cleaned_piece.strip().lower() in {"thought", "thinking", "analysis"}: + if cleaned_piece.strip().lower() in { + "thought", + "thinking", + "think", + "analysis", + }: continue events.append({"content": cleaned_piece}) diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx index cd1c6bdf..a51d1cb5 100644 --- a/src/frontend/App.tsx +++ b/src/frontend/App.tsx @@ -47,14 +47,13 @@ import { setErrorDispatcher } from './services/errorNotifier'; import { useChatStore, ChatStoreState } from './stores/chatStore'; import type { SessionMutation } from './features/chat'; -// eslint-disable-next-line max-lines-per-function const App: React.FC = () => { const { confirm, alert, confirmDialogState, handleConfirm, handleCancel } = useConfirmDialog(); const addToast = useToast(); - useEffect(() => { - setErrorDispatcher((msg: string) => addToast(msg, 'error')); + useEffect((): void => { + setErrorDispatcher((msg: string): void => addToast(msg, 'error')); }, [addToast]); const { @@ -85,7 +84,7 @@ const App: React.FC = () => { advanceBaselineToCurrentStory, patchSourcebook, isChapterLoading, - } = useStory({ confirm, alert: (msg: string) => void alert(msg) }); + } = useStory({ confirm, alert: (msg: string): undefined => void alert(msg) }); // Stable ref to avoid recreating callbacks that read story state during // streaming (e.g. onProseChunk). @@ -134,11 +133,12 @@ const App: React.FC = () => { }); const roleAvailability = useMemo( - () => resolveRoleAvailability(appSettings, modelConnectionStatus), + (): { writing: boolean; editing: boolean; chat: boolean } => + resolveRoleAvailability(appSettings, modelConnectionStatus), [appSettings, modelConnectionStatus] ); const imageActionsAvailable = useMemo( - () => + (): boolean => supportsImageActions(appSettings, detectedCapabilities, modelConnectionStatus), [appSettings, detectedCapabilities, modelConnectionStatus] ); @@ -161,7 +161,10 @@ const App: React.FC = () => { appearanceRef, } = useUIPanels(); - const openImagesDialog = useCallback(() => setIsImagesOpen(true), [setIsImagesOpen]); + const openImagesDialog = useCallback( + (): void => setIsImagesOpen(true), + [setIsImagesOpen] + ); const { viewMode, @@ -181,10 +184,14 @@ const App: React.FC = () => { const { editorSettings, setEditorSettings, currentTheme, isLight } = useEditorPreferences(); - const { openAndExpandStory, openSourcebookEntryDialog, openStoryMetadataDialog } = - useSidebarIntents({ - setEditorSettings, - }); + const { + openAndExpandStory, + openSourcebookEntryDialog, + openStoryMetadataDialog, + openChapterMetadataDialog, + } = useSidebarIntents({ + setEditorSettings, + }); // Get Active LLM Configs — memoized so hooks that receive these as params // don't re-run unnecessarily when unrelated appSettings fields change. @@ -232,7 +239,6 @@ const App: React.FC = () => { handleDeleteAllChats, onUpdateScratchpad, onDeleteScratchpad, - refreshChatList, } = useAppChatRuntime({ storyId: story.id, @@ -243,7 +249,7 @@ const App: React.FC = () => { currentChapterId, currentChapterContext, advanceBaselineToCurrentStory, - refreshProjects: async () => { + refreshProjects: async (): Promise => { await refreshProjectsRef.current?.(); }, refreshStory, @@ -254,6 +260,7 @@ const App: React.FC = () => { openAndExpandStory, openSourcebookEntryDialog, openStoryMetadataDialog, + openChapterMetadataDialog, }); // sessionMutations changes only when LLM tool calls complete (a few times per @@ -261,11 +268,11 @@ const App: React.FC = () => { // and per the explicit-mutation exception in the architecture decision. const sessionMutations = useChatStore((s: ChatStoreState) => s.sessionMutations); const sourcebookMutationEntryIds = useMemo( - () => + (): Set => new Set( sessionMutations .filter((m: SessionMutation) => m.type === 'sourcebook' && m.targetId) - .map((m: SessionMutation) => m.targetId as string) + .map((m: SessionMutation): string => m.targetId as string) ), [sessionMutations] ); @@ -276,7 +283,6 @@ const App: React.FC = () => { setSuggestionMode, isSuggesting, isSuggestionMode, - suggestCursor, handleTriggerSuggestions, handleKeyboardSuggestionAction, handleAcceptContinuation, @@ -301,7 +307,7 @@ const App: React.FC = () => { // Stabilize checkedSourcebookIds so useAiActions does not receive a new // array reference on every render when checkedEntries hasn't changed. const checkedSourcebookIdsMemo = useMemo( - () => Array.from(checkedEntries), + (): string[] => Array.from(checkedEntries), [checkedEntries] ); @@ -354,7 +360,7 @@ const App: React.FC = () => { currentChapterId, currentChapterContent: currentChapter?.content, storyLanguage: story.language, - refreshStory: async () => { + refreshStory: async (): Promise => { await refreshStory(); }, handleChapterSelect, @@ -414,7 +420,7 @@ const App: React.FC = () => { currentChapterId, handleChapterSelect, deleteChapter, - updateChapter: (id: string, partial: Record) => + updateChapter: (id: string, partial: Record): Promise => updateChapter(id, partial, true, true, true), updateBook, addChapter, diff --git a/src/frontend/components/ui/Collapsible.tsx b/src/frontend/components/ui/Collapsible.tsx index 6b24dcae..e84a6eee 100644 --- a/src/frontend/components/ui/Collapsible.tsx +++ b/src/frontend/components/ui/Collapsible.tsx @@ -41,14 +41,14 @@ export function useCollapsible( const isControlled = isExpandedProp !== undefined; const isExpanded = isControlled ? (isExpandedProp as boolean) : internalExpanded; - const toggle = useCallback(() => { + const toggle = useCallback((): void => { const next = !isExpanded; if (onExpandedChange) onExpandedChange(next); if (!isControlled) setInternalExpanded(next); }, [isExpanded, isControlled, onExpandedChange]); const setIsExpanded = useCallback( - (expanded: boolean) => { + (expanded: boolean): void => { if (onExpandedChange) onExpandedChange(expanded); if (!isControlled) setInternalExpanded(expanded); }, diff --git a/src/frontend/components/ui/JsonSyntaxView.tsx b/src/frontend/components/ui/JsonSyntaxView.tsx new file mode 100644 index 00000000..4dd54e5d --- /dev/null +++ b/src/frontend/components/ui/JsonSyntaxView.tsx @@ -0,0 +1,108 @@ +// Copyright (C) 2026 StableLlama +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version of the License. + +/** + * Purpose: Shared component for readable JSON rendering with lightweight + * syntax colouring for chat and debug payloads. + */ + +import React from 'react'; + +type JsonSyntaxViewProps = { + data: unknown; + className?: string; +}; + +const escapeJsonString = (value: string): string => + value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); + +const renderJsonValue = (value: unknown, indent: number = 0): React.ReactNode => { + if (value === null) { + return null; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return []; + } + + return ( + <> + [ + {value.map((item: unknown, index: number) => ( + + {'\n'} + {' '.repeat(indent + 1)} + {renderJsonValue(item, indent + 1)} + {index < value.length - 1 && ,} + + ))} + {'\n'} + {' '.repeat(indent)} + ] + + ); + } + + if (typeof value === 'object') { + const entries = value ? Object.entries(value as Record) : []; + if (entries.length === 0) { + return {'{}'}; + } + + return ( + <> + {'{'} + {entries.map(([key, child]: [string, unknown], index: number) => ( + + {'\n'} + {' '.repeat(indent + 1)} + "{key}" + : + {renderJsonValue(child, indent + 1)} + {index < entries.length - 1 && ( + , + )} + + ))} + {'\n'} + {' '.repeat(indent)} + {'}'} + + ); + } + + if (typeof value === 'string') { + return ( + "{escapeJsonString(value)}" + ); + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return {String(value)}; + } + + return {String(value)}; +}; + +export const JsonSyntaxView: React.FC = ({ + data, + className, +}: JsonSyntaxViewProps) => { + return ( +
+ {renderJsonValue(data)} +
+ ); +}; diff --git a/src/frontend/components/ui/Toast.tsx b/src/frontend/components/ui/Toast.tsx index 2f4d58cc..e9b3b5f6 100644 --- a/src/frontend/components/ui/Toast.tsx +++ b/src/frontend/components/ui/Toast.tsx @@ -24,7 +24,7 @@ export interface Toast { type ToastFn = (message: string, variant?: ToastVariant) => void; -const ToastContext = createContext(() => {}); +const ToastContext = createContext((): void => {}); /** Custom React hook that returns a toast dispatch function. */ export function useToast(): ToastFn { @@ -66,7 +66,7 @@ function ToastItem({ {ICONS[toast.variant]} {toast.message}
); -}); +} +/* eslint-enable complexity */ +export const Chat: React.FC = React.memo(ChatComponent); Chat.displayName = 'Chat'; diff --git a/src/frontend/features/chat/ModelSelector.tsx b/src/frontend/features/chat/ModelSelector.tsx index 4f67e2ca..43d33ef7 100644 --- a/src/frontend/features/chat/ModelSelector.tsx +++ b/src/frontend/features/chat/ModelSelector.tsx @@ -37,25 +37,25 @@ export const ModelSelector: React.FC = ({ onSelectorClick, options, label, - theme, + theme: _theme, connectionStatus = {}, detectedCapabilities = {}, labelColorClass = 'text-brand-gray-500', -}: ModelSelectorProps) => { +}: ModelSelectorProps): React.ReactElement => { const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); const containerRef = useRef(null); const { isLight } = useThemeClasses(); - const selectedOption = options.find((o: LLMConfig) => o.id === value); + const selectedOption = options.find((o: LLMConfig): boolean => o.id === value); // If the provided value (ID) is not in our current options (e.g., after duplication or name change), // but we find an option with the same name, we should probably switch to that ID. // This helps when the backend/dialog uses names as IDs but the UI uses stable IDs or vice-versa. - useEffect(() => { + useEffect((): void => { if (value && options.length > 0 && !selectedOption) { // Try finding by name if ID mismatch (common if names are used as human-readable IDs in some places) - const byName = options.find((o: LLMConfig) => o.name === value); + const byName = options.find((o: LLMConfig): boolean => o.name === value); if (byName && byName.id !== value) { onChange(byName.id); } @@ -64,9 +64,9 @@ export const ModelSelector: React.FC = ({ const activeOption = selectedOption || options[0]; - useClickOutside(containerRef, () => setIsOpen(false), isOpen); + useClickOutside(containerRef, (): void => setIsOpen(false), isOpen); - const getStatusIcon = (id: string) => { + const getStatusIcon = (id: string): React.ReactElement => { const status = connectionStatus[id] || 'idle'; if (status === 'loading') return ; @@ -82,7 +82,7 @@ export const ModelSelector: React.FC = ({ opt: LLMConfig, cap: 'isMultimodal' | 'supportsFunctionCalling', detectedKey: 'is_multimodal' | 'supports_function_calling' - ) => { + ): boolean => { if (opt[cap] === true) return true; if (opt[cap] === false) return false; // Auto (null/undefined) @@ -123,7 +123,7 @@ export const ModelSelector: React.FC = ({
setDraft(value)} + onChange={(value: string): void => setDraft(value)} language={storyLanguage} spellCheck={true} mode="markdown" @@ -100,7 +100,7 @@ export const ChatScratchpadDialog: React.FC = ({
))}
diff --git a/src/frontend/features/chat/hooks/useChatEditing.ts b/src/frontend/features/chat/hooks/useChatEditing.ts index d0c38121..216e9f60 100644 --- a/src/frontend/features/chat/hooks/useChatEditing.ts +++ b/src/frontend/features/chat/hooks/useChatEditing.ts @@ -32,13 +32,13 @@ export function useChatEditing( const editContentRef = useRef(editContent); editContentRef.current = editContent; - const handleStartEditing = useCallback((msg: ChatMessage) => { + const handleStartEditing = useCallback((msg: ChatMessage): void => { setEditingMessageId(msg.id); setEditContent(msg.text); }, []); const handleSaveEdit = useCallback( - (id: string) => { + (id: string): void => { if (editContentRef.current.trim()) { onEditMessage(id, editContentRef.current.trim()); setEditingMessageId(null); @@ -48,7 +48,7 @@ export function useChatEditing( [onEditMessage] ); - const handleCancelEdit = useCallback(() => { + const handleCancelEdit = useCallback((): void => { setEditingMessageId(null); setEditContent(''); }, []); diff --git a/src/frontend/features/chat/hooks/useChatMessages.ts b/src/frontend/features/chat/hooks/useChatMessages.ts index 023e7f2d..7e281adc 100644 --- a/src/frontend/features/chat/hooks/useChatMessages.ts +++ b/src/frontend/features/chat/hooks/useChatMessages.ts @@ -31,18 +31,18 @@ export function useChatMessages( const deferredMessages = useDeferredValue(messages); const [displayCount, setDisplayCount] = useState(INITIAL_DISPLAY); - useEffect(() => { + useEffect((): void => { setDisplayCount(INITIAL_DISPLAY); }, [currentSessionId]); - useEffect(() => { + useEffect((): (() => void) | undefined => { if (displayCount >= deferredMessages.length) return; - const raf = requestAnimationFrame(() => { - setDisplayCount((prev: number) => + const raf = requestAnimationFrame((): void => { + setDisplayCount((prev: number): number => Math.min(prev + INITIAL_DISPLAY, deferredMessages.length) ); }); - return () => cancelAnimationFrame(raf); + return (): void => cancelAnimationFrame(raf); }, [displayCount, deferredMessages.length]); const visibleMessages = deferredMessages.slice( diff --git a/src/frontend/features/chat/hooks/useChatScroll.test.ts b/src/frontend/features/chat/hooks/useChatScroll.test.ts new file mode 100644 index 00000000..d2067753 --- /dev/null +++ b/src/frontend/features/chat/hooks/useChatScroll.test.ts @@ -0,0 +1,214 @@ +// Copyright (C) 2026 StableLlama +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +/** + * Purpose: Regression tests for chat scroll reattachment and auto-scroll state. + */ + +// @vitest-environment jsdom + +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useChatScroll } from './useChatScroll'; + +const makeContainer = ( + scrollTop: number, + clientHeight: number, + scrollHeight: number +): HTMLDivElement => { + const el = document.createElement('div'); + Object.defineProperties(el, { + clientHeight: { value: clientHeight, configurable: true }, + scrollHeight: { value: scrollHeight, configurable: true }, + }); + el.scrollTop = scrollTop; + // jsdom does not implement scrollTo; provide a stub that sets scrollTop. + el.scrollTo = ({ top }: ScrollToOptions) => { + if (top !== undefined) el.scrollTop = top; + }; + return el; +}; + +const makeWheelEvent = (deltaY: number): WheelEvent => + ({ deltaY }) as unknown as WheelEvent; + +const makeTouchEvent = (clientY: number): TouchEvent => + ({ touches: [{ clientY }] }) as unknown as TouchEvent; + +describe('useChatScroll', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('deactivates auto-scroll when the user scrolls up even from the bottom', () => { + const { result } = renderHook(() => + useChatScroll({ + messages: [], + isLoading: false, + editingMessageId: null, + currentSessionId: null, + }) + ); + + const container = makeContainer(1100, 100, 1200); + result.current.scrollContainerRef.current = container; + + act(() => { + result.current.handleScroll(); + vi.runOnlyPendingTimers(); + }); + expect(result.current.isAtBottomRef.current).toBe(true); + + container.scrollTop = 1050; + act(() => { + result.current.handleScroll(); + vi.runOnlyPendingTimers(); + }); + expect(result.current.isAtBottomRef.current).toBe(false); + }); + + it('reattaches auto-scroll when the user scrolls down near the bottom', () => { + const { result } = renderHook(() => + useChatScroll({ + messages: [], + isLoading: false, + editingMessageId: null, + currentSessionId: null, + }) + ); + + const container = makeContainer(1100, 100, 1200); + result.current.scrollContainerRef.current = container; + + act(() => { + result.current.handleScroll(); + vi.runOnlyPendingTimers(); + }); + expect(result.current.isAtBottomRef.current).toBe(true); + + container.scrollTop = 1050; + act(() => { + result.current.handleScroll(); + vi.runOnlyPendingTimers(); + }); + expect(result.current.isAtBottomRef.current).toBe(false); + + container.scrollTop = 1090; + act(() => { + result.current.handleScroll(); + vi.runOnlyPendingTimers(); + }); + expect(result.current.isAtBottomRef.current).toBe(true); + }); + + it('deactivates auto-scroll on an upward wheel event', () => { + const { result } = renderHook(() => + useChatScroll({ + messages: [], + isLoading: false, + editingMessageId: null, + currentSessionId: null, + }) + ); + + const container = makeContainer(1100, 100, 1200); + result.current.scrollContainerRef.current = container; + + act(() => { + result.current.handleWheel(makeWheelEvent(-10)); + }); + expect(result.current.isAtBottomRef.current).toBe(false); + }); + + it('deactivates auto-scroll on an upward touch gesture', () => { + const { result } = renderHook(() => + useChatScroll({ + messages: [], + isLoading: false, + editingMessageId: null, + currentSessionId: null, + }) + ); + + const container = makeContainer(1100, 100, 1200); + result.current.scrollContainerRef.current = container; + + act(() => { + result.current.handleTouchStart(makeTouchEvent(400)); + result.current.handleTouchMove(makeTouchEvent(420)); + }); + expect(result.current.isAtBottomRef.current).toBe(false); + }); + + it('does not deactivate auto-scroll when a programmatic scrollToBottom fires its scroll event', () => { + const { result } = renderHook(() => + useChatScroll({ + messages: [], + isLoading: false, + editingMessageId: null, + currentSessionId: null, + }) + ); + + const container = makeContainer(1100, 100, 1200); + result.current.scrollContainerRef.current = container; + + act(() => { + result.current.handleScroll(); + }); + expect(result.current.isAtBottomRef.current).toBe(true); + + // Programmatic scroll to bottom, then the resulting scroll event. + act(() => { + result.current.scrollToBottom('auto'); + container.scrollTop = 1100; + result.current.handleScroll(); + }); + expect(result.current.isAtBottomRef.current).toBe(true); + }); + + it('deactivates when a coalesced scroll event delivers user position after a programmatic scroll', () => { + // Regression: browser coalesces programmatic scroll to 1100 with user's upward + // scroll to 700 into one event at 700. The hook must not treat that as "at bottom". + const { result } = renderHook(() => + useChatScroll({ + messages: [], + isLoading: false, + editingMessageId: null, + currentSessionId: null, + }) + ); + + const container = makeContainer(1100, 100, 1200); + result.current.scrollContainerRef.current = container; + + // Establish state at the bottom. + act(() => { + result.current.handleScroll(); + }); + expect(result.current.isAtBottomRef.current).toBe(true); + + // Programmatic scroll fires, browser coalesces with user's upward scroll → event at 700. + act(() => { + result.current.scrollToBottom('auto'); // sets isProgrammaticScrollRef=true + container.scrollTop = 700; // coalesced position + result.current.handleScroll(); // skipped — prevScrollTopRef stays at 1100 + }); + + // isProgrammaticScrollRef was consumed; next real scroll event at same position + // reveals the actual user position and deactivates auto-scroll. + act(() => { + result.current.handleScroll(); + }); + expect(result.current.isAtBottomRef.current).toBe(false); + }); +}); diff --git a/src/frontend/features/chat/hooks/useChatScroll.ts b/src/frontend/features/chat/hooks/useChatScroll.ts index e2530ae7..6ba5beec 100644 --- a/src/frontend/features/chat/hooks/useChatScroll.ts +++ b/src/frontend/features/chat/hooks/useChatScroll.ts @@ -9,8 +9,15 @@ * Purpose: Encapsulate chat scroll-to-bottom behavior and MutationObserver tracking. */ -import { useRef, useEffect } from 'react'; +import { + useRef, + useEffect, + useCallback, + type WheelEvent, + type TouchEvent, +} from 'react'; import { ChatMessage } from '../../../types'; +import { scrollDistanceFromBottom } from '../../../utils/scrollUtils'; interface UseChatScrollDeps { messages: ChatMessage[]; @@ -22,9 +29,19 @@ interface UseChatScrollDeps { interface UseChatScrollResult { scrollContainerRef: React.RefObject; handleScroll: () => void; + handleWheel: (event: WheelEvent) => void; + handleTouchStart: (event: TouchEvent) => void; + handleTouchMove: (event: TouchEvent) => void; scrollToBottom: (behavior?: ScrollBehavior) => void; + isAtBottomRef: React.MutableRefObject; } +/** + * Distance from the bottom (px) at or below which the viewport is considered + * "at the bottom" and auto-scroll re-attaches. + */ +const ATTACH_DISTANCE = 50; + /** Custom React hook that manages chat scroll. */ export function useChatScroll({ messages, @@ -34,23 +51,86 @@ export function useChatScroll({ }: UseChatScrollDeps): UseChatScrollResult { const scrollContainerRef = useRef(null); const isAtBottomRef = useRef(true); + const prevScrollTopRef = useRef(0); + const lastTouchYRef = useRef(null); + /** + * Set to true immediately before a programmatic scrollTo/scrollTop so that + * the resulting scroll event is skipped for user-intent detection. + * prevScrollTopRef is intentionally NOT updated on programmatic scrolls so + * that browser-coalesced user+programmatic events are handled correctly by + * the delta check. + */ + const isProgrammaticScrollRef = useRef(false); - const handleScroll = () => { - if (scrollContainerRef.current) { - const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; - // Consider "at bottom" if within 50px of the actual bottom to handle fast layouts - const isAtBottom = scrollHeight - scrollTop - clientHeight < 50; - isAtBottomRef.current = isAtBottom; - } - }; - - const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => { + const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth'): void => { if (!scrollContainerRef.current) return; const { scrollHeight } = scrollContainerRef.current; + if (behavior === 'auto' || behavior === 'instant') { + isProgrammaticScrollRef.current = true; + } scrollContainerRef.current.scrollTo({ top: scrollHeight, behavior }); - }; + }, []); + + const handleScroll = useCallback((): void => { + if (!scrollContainerRef.current) return; + const { scrollTop } = scrollContainerRef.current; + const distanceFromBottom = scrollDistanceFromBottom(scrollContainerRef.current); + const isAtBottom = distanceFromBottom < 24; + + // Skip direction logic for programmatic scrolls. + // prevScrollTopRef is intentionally NOT updated here. + if (isProgrammaticScrollRef.current) { + isProgrammaticScrollRef.current = false; + isAtBottomRef.current = isAtBottom; + return; + } + + const scrollDelta = scrollTop - prevScrollTopRef.current; + prevScrollTopRef.current = scrollTop; + + // Any upward user scroll immediately detaches auto-scroll. + if (scrollDelta < -2) { + isAtBottomRef.current = false; + } else if (distanceFromBottom < ATTACH_DISTANCE) { + // User scrolled down to near the bottom — re-attach auto-scroll. + isAtBottomRef.current = true; + } + }, []); + + const handleWheel = useCallback((event: WheelEvent): void => { + if (event.deltaY < 0) { + isAtBottomRef.current = false; + } else if (event.deltaY > 0 && scrollContainerRef.current) { + const distanceFromBottom = scrollDistanceFromBottom(scrollContainerRef.current); + if (distanceFromBottom < ATTACH_DISTANCE + 80) { + isAtBottomRef.current = true; + } + } + }, []); + + const handleTouchStart = useCallback((event: TouchEvent): void => { + lastTouchYRef.current = event.touches[0]?.clientY ?? null; + }, []); + + const handleTouchMove = useCallback((event: TouchEvent): void => { + const currentY = event.touches[0]?.clientY ?? null; + const previousY = lastTouchYRef.current; + lastTouchYRef.current = currentY; + if (previousY === null || currentY === null) return; - useEffect(() => { + // Positive deltaY = finger moved down = content scrolled up = user wants to see above. + const deltaY = currentY - previousY; + if (deltaY > 2) { + isAtBottomRef.current = false; + } else if (deltaY < -2 && scrollContainerRef.current) { + const distanceFromBottom = scrollDistanceFromBottom(scrollContainerRef.current); + if (distanceFromBottom < ATTACH_DISTANCE + 80) { + isAtBottomRef.current = true; + } + } + }, []); + + useEffect((): (() => void) | undefined => { const el = scrollContainerRef.current; if (!el) return undefined; @@ -59,10 +139,10 @@ export function useChatScroll({ // RAF-throttled so that rapid DOM mutations during streaming don't pile up // redundant scroll operations. let rafId: number | null = null; - const observer = new MutationObserver(() => { + const observer = new MutationObserver((): void => { if (!isAtBottomRef.current) return; if (rafId !== null) return; - rafId = requestAnimationFrame(() => { + rafId = requestAnimationFrame((): void => { rafId = null; scrollToBottom(isLoading ? 'auto' : 'smooth'); }); @@ -75,17 +155,25 @@ export function useChatScroll({ scrollToBottom(isLoading ? 'auto' : 'smooth'); } - return () => { + return (): void => { observer.disconnect(); if (rafId !== null) cancelAnimationFrame(rafId); }; - }, [messages, isLoading, editingMessageId]); + }, [messages, isLoading, editingMessageId, scrollToBottom]); // Always scroll to bottom on session switch - useEffect(() => { + useEffect((): void => { isAtBottomRef.current = true; scrollToBottom('auto'); - }, [currentSessionId]); + }, [currentSessionId, scrollToBottom]); - return { scrollContainerRef, handleScroll, scrollToBottom }; + return { + scrollContainerRef, + handleScroll, + handleWheel, + handleTouchStart, + handleTouchMove, + scrollToBottom, + isAtBottomRef, + }; } diff --git a/src/frontend/features/chat/hooks/useChatUIState.ts b/src/frontend/features/chat/hooks/useChatUIState.ts index 1b60913e..8cfec86a 100644 --- a/src/frontend/features/chat/hooks/useChatUIState.ts +++ b/src/frontend/features/chat/hooks/useChatUIState.ts @@ -31,11 +31,13 @@ export function useChatUIState(): UseChatUIStateResult { Record >({}); - const handleThinkingToggle = useCallback((id: string, next: boolean) => { - setThinkingProcessExpanded((prev: Record) => ({ - ...prev, - [id]: next, - })); + const handleThinkingToggle = useCallback((id: string, next: boolean): void => { + setThinkingProcessExpanded( + (prev: Record): { [x: string]: boolean } => ({ + ...prev, + [id]: next, + }) + ); }, []); return { diff --git a/src/frontend/features/chat/mutationToolRegistry.test.ts b/src/frontend/features/chat/mutationToolRegistry.test.ts new file mode 100644 index 00000000..ad21ab84 --- /dev/null +++ b/src/frontend/features/chat/mutationToolRegistry.test.ts @@ -0,0 +1,191 @@ +// Copyright (C) 2026 StableLlama +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +/** + * Purpose: Validate mutation tool registry mappings for chat tool events. + */ + +import { describe, expect, it } from 'vitest'; + +import { buildMetadataFields, MUTATION_TOOL_REGISTRY } from './mutationToolRegistry'; +import type { SessionMutation } from './components/MutationTags'; + +describe('mutationToolRegistry', () => { + it('produces sensible mutations for replace_in_project change_locations', () => { + const factory = MUTATION_TOOL_REGISTRY.replace_in_project; + expect(factory).toBeDefined(); + + const mutations = factory({ + args: {}, + result: { + change_locations: [ + { + type: 'metadata', + target_id: '1', + field: 'summary', + label: 'Chapter 1: The Dusty Discovery summary', + }, + { + type: 'sourcebook', + target_id: 'Fred', + field: 'description', + label: "Sourcebook 'Fred' Description", + }, + { + type: 'metadata', + target_id: 'story', + field: 'story_summary', + label: 'Story summary', + }, + ], + }, + }) as SessionMutation[]; + + expect(Array.isArray(mutations)).toBe(true); + expect(mutations).toHaveLength(3); + expect(mutations[0]).toMatchObject({ + type: 'metadata', + targetId: '1', + subType: 'summary', + label: 'Chapter 1: The Dusty Discovery summary', + }); + expect(mutations[1]).toMatchObject({ + type: 'sourcebook', + targetId: 'Fred', + label: "Sourcebook 'Fred' Description", + }); + expect(mutations[2]).toMatchObject({ + type: 'metadata', + label: 'Story summary', + subType: 'summary', + }); + }); + + it('falls back to changed_sections when change_locations are unavailable', () => { + const factory = MUTATION_TOOL_REGISTRY.replace_in_project; + const mutations = factory({ + args: {}, + result: { + changed_sections: [ + 'Chapter 1: The Dusty Discovery summary', + "Sourcebook 'Fred' Description", + 'Story summary', + ], + }, + }) as SessionMutation[]; + + expect(Array.isArray(mutations)).toBe(true); + expect(mutations).toHaveLength(3); + expect(mutations[0]).toMatchObject({ + type: 'chapter', + targetId: '1', + label: 'Chapter 1: The Dusty Discovery summary', + }); + expect(mutations[1]).toMatchObject({ + type: 'sourcebook', + targetId: 'Fred', + label: "Sourcebook 'Fred' Description", + }); + expect(mutations[2]).toMatchObject({ + type: 'metadata', + label: 'Story summary', + subType: 'summary', + }); + }); +}); + +describe('buildMetadataFields', () => { + it('detects patch variants as changed fields', () => { + const mutations = buildMetadataFields( + { + conflicts_patch: { operations: [{ op: 'update', index: 0, updates: {} }] }, + }, + false + ); + expect(mutations).toHaveLength(1); + expect(mutations[0]).toMatchObject({ type: 'metadata', subType: 'conflicts' }); + }); + + it('detects summary_patch and notes_patch', () => { + const mutations = buildMetadataFields( + { + summary_patch: { operation: 'append', value: ' extra' }, + notes_patch: { operation: 'replace', value: 'new' }, + }, + false + ); + expect(mutations).toHaveLength(2); + expect(mutations[0]).toMatchObject({ subType: 'summary' }); + expect(mutations[1]).toMatchObject({ subType: 'notes' }); + }); + + it('does not duplicate fields when both direct and patch args are present', () => { + const mutations = buildMetadataFields( + { conflicts: [], conflicts_patch: {} }, + false + ); + expect(mutations).toHaveLength(1); + expect(mutations[0]).toMatchObject({ subType: 'conflicts' }); + }); + + it('attaches chapter target id for update_chapter_metadata mutations', () => { + const factory = MUTATION_TOOL_REGISTRY.update_chapter_metadata; + const mutations = factory({ + args: { + chap_id: 2, + conflicts_patch: { operations: [{ op: 'update', index: 0, updates: {} }] }, + }, + result: {}, + }) as SessionMutation[]; + + expect(Array.isArray(mutations)).toBe(true); + expect(mutations).toHaveLength(1); + expect(mutations[0]).toMatchObject({ + type: 'metadata', + subType: 'conflicts', + targetId: '2', + label: 'Chapter 2 Conflicts', + }); + }); + + it('uses changed_fields from tool result and suppresses no-op tags', () => { + const factory = MUTATION_TOOL_REGISTRY.update_chapter_metadata; + + const changed = factory({ + args: { + chap_id: 2, + summary_patch: { operation: 'append', value: 'x' }, + notes_patch: { operation: 'append', value: 'y' }, + }, + result: { + changed_fields: ['notes'], + }, + }) as SessionMutation[]; + + expect(Array.isArray(changed)).toBe(true); + expect(changed).toHaveLength(1); + expect(changed[0]).toMatchObject({ + type: 'metadata', + subType: 'notes', + targetId: '2', + label: 'Chapter 2 Notes', + }); + + const noOp = factory({ + args: { + chap_id: 2, + summary_patch: { operation: 'append', value: '' }, + }, + result: { + changed_fields: [], + }, + }) as SessionMutation[]; + + expect(Array.isArray(noOp)).toBe(true); + expect(noOp).toHaveLength(0); + }); +}); diff --git a/src/frontend/features/chat/mutationToolRegistry.ts b/src/frontend/features/chat/mutationToolRegistry.ts index e6739219..8933d0f5 100644 --- a/src/frontend/features/chat/mutationToolRegistry.ts +++ b/src/frontend/features/chat/mutationToolRegistry.ts @@ -23,6 +23,13 @@ import { SessionMutation } from './components/MutationTags'; type MutCallResult = { args: Record; result: Record }; type MutFactory = (res: MutCallResult) => SessionMutation | SessionMutation[] | null; +type ReplaceChangeLocation = { + type: string; + target_id?: string; + field?: string; + label: string; +}; + /** Build sourcebook mutation. */ function buildSourcebookMutation( args: Record, @@ -57,20 +64,62 @@ function buildChapterMutation( /** Build metadata fields. */ export function buildMetadataFields( args: Record, - forceSummary: boolean + forceSummary: boolean, + targetId?: string, + targetLabelPrefix?: string, + changedFieldsInput?: unknown ): SessionMutation[] { + const normalizeChangedField = ( + field: unknown + ): 'summary' | 'notes' | 'private' | 'conflicts' | undefined => { + if (typeof field !== 'string') return undefined; + const normalized = field.toLowerCase(); + if (normalized.endsWith('summary')) return 'summary'; + if (normalized.endsWith('notes')) return 'notes'; + if (normalized.endsWith('private_notes') || normalized.endsWith('private notes')) + return 'private'; + if (normalized.includes('conflict')) return 'conflicts'; + return undefined; + }; + const changedFields: Array<'summary' | 'notes' | 'private' | 'conflicts'> = []; - if (forceSummary || args.summary !== undefined) changedFields.push('summary'); - if (args.notes !== undefined) changedFields.push('notes'); - if (args.private_notes !== undefined) changedFields.push('private'); - if (args.conflicts !== undefined) changedFields.push('conflicts'); - if (changedFields.length === 0) changedFields.push('summary'); + if (Array.isArray(changedFieldsInput)) { + changedFieldsInput.forEach((field: unknown): void => { + const mapped = normalizeChangedField(field); + if (mapped && !changedFields.includes(mapped)) { + changedFields.push(mapped); + } + }); + if (changedFields.length === 0 && !forceSummary) { + return []; + } + } else { + if (forceSummary || args.summary !== undefined || args.summary_patch !== undefined) + changedFields.push('summary'); + if (args.notes !== undefined || args.notes_patch !== undefined) + changedFields.push('notes'); + if (args.private_notes !== undefined) changedFields.push('private'); + if (args.conflicts !== undefined || args.conflicts_patch !== undefined) + changedFields.push('conflicts'); + if (changedFields.length === 0) changedFields.push('summary'); + } return changedFields.map( - (subType: 'summary' | 'notes' | 'conflicts' | 'private') => ({ + ( + subType: 'summary' | 'notes' | 'conflicts' | 'private' + ): { + id: string; + type: 'metadata'; + label: string; + subType: 'summary' | 'notes' | 'private' | 'conflicts'; + targetId?: string; + } => ({ id: `meta-${Date.now()}-${Math.random()}`, type: 'metadata' as const, - label: subType.charAt(0).toUpperCase() + subType.slice(1), + label: targetLabelPrefix + ? `${targetLabelPrefix} ${subType.charAt(0).toUpperCase() + subType.slice(1)}` + : subType.charAt(0).toUpperCase() + subType.slice(1), subType, + targetId, }) ); } @@ -85,14 +134,21 @@ export function buildMetadataFields( */ export const MUTATION_TOOL_REGISTRY: Record = { // --- Sourcebook tools --- - create_sourcebook_entry: ({ args, result }: MutCallResult) => + create_sourcebook_entry: ({ args, result }: MutCallResult): SessionMutation => buildSourcebookMutation(args, result), - update_sourcebook_entry: ({ args, result }: MutCallResult) => + update_sourcebook_entry: ({ args, result }: MutCallResult): SessionMutation => buildSourcebookMutation(args, result), - delete_sourcebook_entry: ({ args, result }: MutCallResult) => + delete_sourcebook_entry: ({ args, result }: MutCallResult): SessionMutation => buildSourcebookMutation(args, result), - add_sourcebook_relation: ({ args }: MutCallResult) => { + add_sourcebook_relation: ({ + args, + }: MutCallResult): { + id: string; + type: 'sourcebook'; + label: string; + targetId: string | undefined; + } => { const sourceId = args.source_id || args.sourceId || args.name_or_id || args.name; const targetId = args.target_id || args.targetId; const label = sourceId @@ -108,7 +164,14 @@ export const MUTATION_TOOL_REGISTRY: Record = { }; }, - remove_sourcebook_relation: ({ args }: MutCallResult) => { + remove_sourcebook_relation: ({ + args, + }: MutCallResult): { + id: string; + type: 'sourcebook'; + label: string; + targetId: string | undefined; + } => { const sourceId = args.source_id || args.sourceId || args.name_or_id || args.name; const targetId = args.target_id || args.targetId; const label = sourceId @@ -125,44 +188,64 @@ export const MUTATION_TOOL_REGISTRY: Record = { }, // --- Metadata tools (one badge per changed field) --- - update_story_metadata: ({ args }: MutCallResult) => buildMetadataFields(args, false), - update_chapter_metadata: ({ args }: MutCallResult) => - buildMetadataFields(args, false), - update_book_metadata: ({ args }: MutCallResult) => buildMetadataFields(args, false), - set_story_tags: () => buildMetadataFields({}, true), - set_story_summary: () => buildMetadataFields({}, true), - sync_story_summary: () => buildMetadataFields({}, true), - write_story_summary: () => buildMetadataFields({}, true), + update_story_metadata: ({ args, result }: MutCallResult): SessionMutation[] => + buildMetadataFields(args, false, undefined, undefined, result.changed_fields), + update_chapter_metadata: ({ args, result }: MutCallResult): SessionMutation[] => { + const chapId = args.chap_id; + const targetId = + typeof chapId === 'string' || typeof chapId === 'number' + ? String(chapId) + : undefined; + const labelPrefix = targetId ? `Chapter ${targetId}` : undefined; + return buildMetadataFields( + args, + false, + targetId, + labelPrefix, + result.changed_fields + ); + }, + update_book_metadata: ({ args, result }: MutCallResult): SessionMutation[] => + buildMetadataFields(args, false, undefined, undefined, result.changed_fields), + sync_story_summary: (): SessionMutation[] => buildMetadataFields({}, true), // --- Chapter prose tools --- - write_chapter_content: ({ args, result }: MutCallResult) => + write_chapter_content: ({ args, result }: MutCallResult): SessionMutation => buildChapterMutation(args, result), - replace_text_in_chapter: ({ args, result }: MutCallResult) => + replace_text_in_chapter: ({ args, result }: MutCallResult): SessionMutation => buildChapterMutation(args, result), - apply_chapter_replacements: ({ args, result }: MutCallResult) => + apply_chapter_replacements: ({ args, result }: MutCallResult): SessionMutation => buildChapterMutation(args, result), - write_chapter: ({ args, result }: MutCallResult) => + write_chapter: ({ args, result }: MutCallResult): SessionMutation => buildChapterMutation(args, result), // --- Story prose tools --- - write_story_content: () => ({ + write_story_content: (): { id: string; type: 'story'; label: string } => ({ id: `story-${Date.now()}-${Math.random()}`, type: 'story', label: 'Story prose', }), - call_editing_assistant: () => ({ + call_editing_assistant: (): { id: string; type: 'story'; label: string } => ({ id: `story-${Date.now()}-${Math.random()}`, type: 'story', label: 'Story prose', }), - call_writing_llm: () => ({ + call_writing_llm: (): { id: string; type: 'story'; label: string } => ({ id: `story-${Date.now()}-${Math.random()}`, type: 'story', label: 'Story prose', }), // --- Book tools --- - write_book_content: ({ args, result }: MutCallResult) => { + write_book_content: ({ + args, + result, + }: MutCallResult): { + id: string; + type: 'book'; + label: string; + targetId: string | undefined; + } => { const bookId = result.book_id || args.book_id; return { id: `book-${Date.now()}-${Math.random()}`, @@ -171,4 +254,149 @@ export const MUTATION_TOOL_REGISTRY: Record = { targetId: bookId as string | undefined, }; }, + + replace_in_project: ({ result }: MutCallResult) => { + const changeLocations = Array.isArray(result.change_locations) + ? result.change_locations + : []; + const changedSections = Array.isArray(result.changed_sections) + ? result.changed_sections.map(String) + : []; + + const parseMetadataSubType = ( + field: string + ): SessionMutation['subType'] | undefined => { + const normalized = field.toLowerCase(); + if (normalized.endsWith('summary')) return 'summary'; + if (normalized.endsWith('notes')) return 'notes'; + if (normalized.endsWith('private_notes') || normalized.endsWith('private notes')) + return 'private'; + if (normalized.includes('conflict')) return 'conflicts'; + return undefined; + }; + + const mutations = changeLocations.map((location: ReplaceChangeLocation) => { + const targetId = location.target_id; + switch (location.type) { + case 'chapter': + return { + id: `chap-replace-${targetId ?? 'unknown'}-${Date.now()}-${Math.random()}`, + type: 'chapter' as const, + label: location.label, + targetId: targetId as string | undefined, + }; + case 'sourcebook': + return { + id: `sb-replace-${targetId ?? 'unknown'}-${Date.now()}-${Math.random()}`, + type: 'sourcebook' as const, + label: location.label, + targetId: targetId as string | undefined, + }; + case 'book': + return { + id: `book-replace-${targetId ?? 'unknown'}-${Date.now()}-${Math.random()}`, + type: 'book' as const, + label: location.label, + targetId: targetId as string | undefined, + }; + case 'metadata': { + const subType = location.field + ? parseMetadataSubType(location.field) + : parseMetadataSubType(location.label); + return { + id: `meta-replace-${Date.now()}-${Math.random()}`, + type: 'metadata' as const, + label: location.label, + targetId: location.target_id as string | undefined, + subType, + }; + } + case 'story': + return { + id: `story-replace-${Date.now()}-${Math.random()}`, + type: 'story' as const, + label: location.label, + }; + default: + return { + id: `story-replace-${Date.now()}-${Math.random()}`, + type: 'story' as const, + label: location.label, + }; + } + }); + + if (mutations.length > 0) { + return mutations; + } + + const fallbackMutations = changedSections.map((section: string) => { + const chapterMatch = section.match(/Chapter\s+(\d+)/i); + if (chapterMatch) { + return { + id: `chap-replace-${chapterMatch[1]}-${Date.now()}-${Math.random()}`, + type: 'chapter' as const, + label: section, + targetId: chapterMatch[1], + }; + } + + const sourcebookMatch = section.match(/Sourcebook\s+'([^']+)'/i); + if (sourcebookMatch) { + return { + id: `sb-replace-${sourcebookMatch[1]}-${Date.now()}-${Math.random()}`, + type: 'sourcebook' as const, + label: section, + targetId: sourcebookMatch[1], + }; + } + + const bookMatch = section.match(/Book\s+'([^']+)'/i); + if (bookMatch) { + return { + id: `book-replace-${bookMatch[1]}-${Date.now()}-${Math.random()}`, + type: 'book' as const, + label: section, + targetId: bookMatch[1], + }; + } + + const storyMatch = section.match(/^Story\s+(.*)$/i); + if (storyMatch) { + const subType = parseMetadataSubType(storyMatch[1]); + return { + id: `story-replace-${Date.now()}-${Math.random()}`, + type: subType ? ('metadata' as const) : ('story' as const), + label: section, + subType, + }; + } + + const inferredSubType = parseMetadataSubType(section); + if (inferredSubType) { + return { + id: `story-replace-${Date.now()}-${Math.random()}`, + type: 'metadata' as const, + label: section, + subType: inferredSubType, + }; + } + + return { + id: `story-replace-${Date.now()}-${Math.random()}`, + type: 'story' as const, + label: section, + }; + }); + + return fallbackMutations.length > 0 + ? fallbackMutations + : [ + { + id: `story-replace-${Date.now()}-${Math.random()}`, + type: 'story', + label: 'Project replace', + }, + ]; + }, }; diff --git a/src/frontend/features/chat/useChatExecution.test.ts b/src/frontend/features/chat/useChatExecution.test.ts index 0fa0adbc..5068b8c2 100644 --- a/src/frontend/features/chat/useChatExecution.test.ts +++ b/src/frontend/features/chat/useChatExecution.test.ts @@ -119,6 +119,8 @@ describe('useChatExecution', () => { }); expect(pushExternalHistoryEntry).toHaveBeenCalledTimes(1); + expect(refreshProjects).toHaveBeenCalledTimes(1); + expect(refreshStory).toHaveBeenCalledTimes(1); const entry = pushExternalHistoryEntry.mock.calls[0][0]; expect(entry.label).toContain('AI tools'); @@ -140,6 +142,61 @@ describe('useChatExecution', () => { expect(api.chat.redoToolBatch).toHaveBeenNthCalledWith(2, 'batch2'); }); + it('creates an external history entry for story_changed mutations without a tool_batch', async () => { + const refreshProjects = vi.fn().mockResolvedValue(undefined); + const refreshStory = vi.fn().mockResolvedValue(undefined); + const pushExternalHistoryEntry = vi.fn(); + + const sendMessageMock = vi + .fn() + .mockResolvedValueOnce({ + text: '', + functionCalls: [{ id: 'c1', name: 'sync_story_summary', args: {} }], + }) + .mockResolvedValueOnce({ + text: 'Done', + functionCalls: [], + }); + + vi.mocked(createChatSession).mockReturnValue({ + sendMessage: sendMessageMock, + } as UnifiedChat); + + vi.mocked(api.chat.executeTools).mockResolvedValueOnce({ + ok: true, + appended_messages: [ + { content: 'ok', name: 'sync_story_summary', tool_call_id: 'c1' }, + ], + mutations: { + story_changed: true, + }, + }); + + const { result } = renderHook(() => + useChatExecution({ + getSystemPrompt: () => 'system', + activeChatConfig: { model: 'test', temperature: 0.5 }, + isChatAvailable: true, + getAllowWebSearch: () => false, + currentChapterId: '1', + getCurrentChatId: () => 'chat-1', + currentChapter: { id: '1', title: 'Intro' }, + refreshProjects, + refreshStory, + pushExternalHistoryEntry, + requestToolCallLoopAccess: vi.fn().mockResolvedValue('unlimited'), + }) + ); + + await act(async () => { + await result.current.handleSendMessage('Update summary'); + }); + + expect(pushExternalHistoryEntry).toHaveBeenCalledTimes(1); + expect(pushExternalHistoryEntry.mock.calls[0][0].label).toBe('AI tool changes'); + expect(pushExternalHistoryEntry.mock.calls[0][0].forceNewHistory).toBe(true); + }); + it('passes attachments through to the chat session payload', async () => { const refreshProjects = vi.fn().mockResolvedValue(undefined); const refreshStory = vi.fn().mockResolvedValue(undefined); diff --git a/src/frontend/features/chat/useChatExecution.ts b/src/frontend/features/chat/useChatExecution.ts index 7e0d358e..99ae87b3 100644 --- a/src/frontend/features/chat/useChatExecution.ts +++ b/src/frontend/features/chat/useChatExecution.ts @@ -145,7 +145,7 @@ export function useChatExecution({ ? [...historyBefore, contextMsg] : historyBefore; - setChatMessages((prev: ChatMessage[]) => [ + setChatMessages((prev: ChatMessage[]): ChatMessage[] => [ ...prev, ...(contextMsg ? [contextMsg] : []), { id: userMsgId, role: 'user', text, attachments }, diff --git a/src/frontend/features/chat/useChatMessageActions.ts b/src/frontend/features/chat/useChatMessageActions.ts index 0c754a19..bed23ec2 100644 --- a/src/frontend/features/chat/useChatMessageActions.ts +++ b/src/frontend/features/chat/useChatMessageActions.ts @@ -25,10 +25,11 @@ export function useChatMessageActions({ handleDeleteMessage: (id: string) => void; } { const handleEditMessage = useCallback( - (id: string, newText: string) => { - setChatMessages((previous: ChatMessage[]) => - previous.map((message: ChatMessage) => - message.id === id ? { ...message, text: newText } : message + (id: string, newText: string): void => { + setChatMessages((previous: ChatMessage[]): ChatMessage[] => + previous.map( + (message: ChatMessage): ChatMessage => + message.id === id ? { ...message, text: newText } : message ) ); }, @@ -36,9 +37,9 @@ export function useChatMessageActions({ ); const handleDeleteMessage = useCallback( - (id: string) => { - setChatMessages((previous: ChatMessage[]) => - previous.filter((message: ChatMessage) => message.id !== id) + (id: string): void => { + setChatMessages((previous: ChatMessage[]): ChatMessage[] => + previous.filter((message: ChatMessage): boolean => message.id !== id) ); }, [setChatMessages] diff --git a/src/frontend/features/chat/useChatSessionManagement.test.ts b/src/frontend/features/chat/useChatSessionManagement.test.ts index ec0f1473..12a9c186 100644 --- a/src/frontend/features/chat/useChatSessionManagement.test.ts +++ b/src/frontend/features/chat/useChatSessionManagement.test.ts @@ -48,6 +48,7 @@ describe('useChatSessionManagement', () => { chatHistoryList: [], currentChatId: null, incognitoSessions: [], + projectContextRevision: null, isIncognito: false, allowWebSearch: false, systemPrompt: '', @@ -56,7 +57,7 @@ describe('useChatSessionManagement', () => { }); it('creates an incognito session with expected defaults', async () => { - const getSystemPrompt = () => 'System Prompt'; + const getSystemPrompt = (): string => 'System Prompt'; const { result } = renderHook(() => useChatSessionManagement({ @@ -80,7 +81,7 @@ describe('useChatSessionManagement', () => { }); it('loads a persisted chat and applies prompt/search settings', async () => { - const getSystemPrompt = () => 'System Prompt'; + const getSystemPrompt = (): string => 'System Prompt'; vi.mocked(api.chat.load).mockResolvedValue({ id: 'chat-1', name: 'Saved Chat', @@ -88,6 +89,7 @@ describe('useChatSessionManagement', () => { systemPrompt: 'Saved prompt', allowWebSearch: true, scratchpad: 'My Scratch', + projectContextRevision: 17, } as ChatSession); const { result } = renderHook(() => @@ -110,13 +112,14 @@ describe('useChatSessionManagement', () => { expect(useChatStore.getState().isIncognito).toBe(false); expect(useChatStore.getState().systemPrompt).toBe('Saved prompt'); expect(useChatStore.getState().scratchpad).toBe('My Scratch'); + expect(useChatStore.getState().projectContextRevision).toBe(17); expect(useChatStore.getState().chatMessages).toEqual([ { id: 'm1', role: 'user', text: 'hello' }, ]); }); it('restores incognito scratchpad when selecting an incognito chat', async () => { - const getSystemPrompt = () => 'System Prompt'; + const getSystemPrompt = (): string => 'System Prompt'; useChatStore.setState({ incognitoSessions: [ @@ -128,6 +131,7 @@ describe('useChatSessionManagement', () => { isIncognito: true, allowWebSearch: true, scratchpad: 'Incognito memory', + projectContextRevision: 9, }, ], scratchpad: '', @@ -147,13 +151,61 @@ describe('useChatSessionManagement', () => { expect(useChatStore.getState().isIncognito).toBe(true); expect(useChatStore.getState().currentChatId).toBe('inc-1'); expect(useChatStore.getState().scratchpad).toBe('Incognito memory'); + expect(useChatStore.getState().projectContextRevision).toBe(9); + }); + + it('clears session mutation tags when a new chat is started', () => { + const getSystemPrompt = (): string => 'System Prompt'; + useChatStore.setState({ + sessionMutations: [{ type: 'chapter', label: 'Updated chapter', targetId: '1' }], + }); + + const { result } = renderHook(() => + useChatSessionManagement({ + storyId: '', + getSystemPrompt, + }) + ); + + act(() => { + result.current.handleNewChat(false); + }); + + expect(useChatStore.getState().sessionMutations).toEqual([]); + }); + + it('clears session mutation tags when selecting a persisted chat', async () => { + const getSystemPrompt = (): string => 'System Prompt'; + useChatStore.setState({ + sessionMutations: [{ type: 'chapter', label: 'Updated chapter', targetId: '1' }], + }); + vi.mocked(api.chat.load).mockResolvedValue({ + id: 'chat-1', + name: 'Saved Chat', + messages: [], + systemPrompt: 'Saved prompt', + allowWebSearch: false, + scratchpad: '', + } as ChatSession); + + const { result } = renderHook(() => + useChatSessionManagement({ + storyId: '', + getSystemPrompt, + }) + ); + + await act(async () => { + await result.current.handleSelectChat('chat-1'); + }); + + expect(useChatStore.getState().sessionMutations).toEqual([]); }); it('auto-saves non-incognito scratchpad updates even without messages', async () => { vi.useFakeTimers(); try { - const getSystemPrompt = () => 'System Prompt'; - + const getSystemPrompt = (): string => 'System Prompt'; const { result } = renderHook(() => useChatSessionManagement({ storyId: '', @@ -180,6 +232,7 @@ describe('useChatSessionManagement', () => { messages: [], allowWebSearch: false, scratchpad: 'Persistent memory text', + projectContextRevision: null, }) ); } finally { diff --git a/src/frontend/features/chat/useChatSessionManagement.ts b/src/frontend/features/chat/useChatSessionManagement.ts index 65e0a71f..8b19677b 100644 --- a/src/frontend/features/chat/useChatSessionManagement.ts +++ b/src/frontend/features/chat/useChatSessionManagement.ts @@ -21,6 +21,7 @@ import { v4 as uuidv4 } from 'uuid'; import { ChatSession, ChatMessage } from '../../types'; import { api } from '../../services/api'; import { useChatStore, ChatStoreState } from '../../stores/chatStore'; +import { useStoryStore } from '../../stores/storyStore'; type UseChatSessionManagementParams = { storyId: string; @@ -52,15 +53,17 @@ export function useChatSessionManagement({ setSystemPrompt, setScratchpad, setIncognitoSessions, + setSessionMutations, + setProjectContextRevision, // Setters are stable — read via getState() to avoid subscribing to every token. } = useChatStore.getState(); // Update systemPrompt when the project changes. - useEffect(() => { + useEffect((): void => { setSystemPrompt(getSystemPrompt()); }, [storyId, getSystemPrompt, setSystemPrompt]); - const refreshChatList = useCallback(async () => { + const refreshChatList = useCallback(async (): Promise => { try { const chats = await api.chat.list(); setChatHistoryList(chats); @@ -70,9 +73,10 @@ export function useChatSessionManagement({ }, []); const handleNewChat = useCallback( - (incognito: boolean = false) => { + (incognito: boolean = false): void => { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const newId = incognito ? uuidv4() : `chat-${timestamp}`; + const projectContextRevision = useStoryStore.getState().story.lastUpdated ?? null; if (incognito) { const newSession: ChatSession = { id: newId, @@ -82,21 +86,28 @@ export function useChatSessionManagement({ isIncognito: true, allowWebSearch: false, scratchpad: '', + projectContextRevision, }; - setIncognitoSessions((prev: ChatSession[]) => [newSession, ...prev]); + setIncognitoSessions((prev: ChatSession[]): ChatSession[] => [ + newSession, + ...prev, + ]); setChatMessages([]); setIsIncognito(true); setCurrentChatId(newId); setAllowWebSearch(false); setScratchpad(''); + setProjectContextRevision(projectContextRevision); } else { setChatMessages([]); setIsIncognito(false); setCurrentChatId(newId); setAllowWebSearch(false); setScratchpad(''); + setProjectContextRevision(projectContextRevision); } setSystemPrompt(getSystemPrompt()); + setSessionMutations([]); }, [ getSystemPrompt, @@ -106,20 +117,22 @@ export function useChatSessionManagement({ setCurrentChatId, setAllowWebSearch, setScratchpad, + setProjectContextRevision, setSystemPrompt, ] ); const handleSelectChat = useCallback( - async (id: string) => { + async (id: string): Promise => { const incognito = useChatStore .getState() - .incognitoSessions.find((session: ChatSession) => session.id === id); + .incognitoSessions.find((session: ChatSession): boolean => session.id === id); if (incognito) { setChatMessages(incognito.messages || []); setCurrentChatId(id); setIsIncognito(true); setScratchpad(incognito.scratchpad || ''); + setProjectContextRevision(incognito.projectContextRevision ?? null); if (incognito.systemPrompt) { setSystemPrompt(incognito.systemPrompt); } @@ -130,16 +143,18 @@ export function useChatSessionManagement({ try { const chat = await api.chat.load(id); if (chat) { - startTransition(() => { + startTransition((): void => { setChatMessages(chat.messages || []); setCurrentChatId(id); setIsIncognito(false); setScratchpad(chat.scratchpad || ''); + setProjectContextRevision(chat.projectContextRevision ?? null); if (chat.systemPrompt) { setSystemPrompt(chat.systemPrompt); } setAllowWebSearch(chat.allowWebSearch || false); }); + setSessionMutations([]); } } catch (error) { console.error('Failed to load chat', error); @@ -150,19 +165,23 @@ export function useChatSessionManagement({ setCurrentChatId, setIsIncognito, setScratchpad, + setProjectContextRevision, setSystemPrompt, setAllowWebSearch, ] ); const handleUpdateScratchpad = useCallback( - (content: string) => { + (content: string): void => { setScratchpad(content); const { currentChatId, isIncognito } = useChatStore.getState(); if (isIncognito && currentChatId) { - setIncognitoSessions((prev: ChatSession[]) => - prev.map((session: ChatSession) => - session.id === currentChatId ? { ...session, scratchpad: content } : session + setIncognitoSessions((prev: ChatSession[]): ChatSession[] => + prev.map( + (session: ChatSession): ChatSession => + session.id === currentChatId + ? { ...session, scratchpad: content } + : session ) ); } @@ -170,16 +189,18 @@ export function useChatSessionManagement({ [setScratchpad, setIncognitoSessions] ); - const handleDeleteScratchpad = useCallback(() => { + const handleDeleteScratchpad = useCallback((): void => { handleUpdateScratchpad(''); }, [handleUpdateScratchpad]); const handleDeleteChat = useCallback( - async (id: string) => { + async (id: string): Promise => { const { incognitoSessions, currentChatId } = useChatStore.getState(); - if (incognitoSessions.some((session: ChatSession) => session.id === id)) { - setIncognitoSessions((prev: ChatSession[]) => - prev.filter((session: ChatSession) => session.id !== id) + if ( + incognitoSessions.some((session: ChatSession): boolean => session.id === id) + ) { + setIncognitoSessions((prev: ChatSession[]): ChatSession[] => + prev.filter((session: ChatSession): boolean => session.id !== id) ); if (currentChatId === id) { handleNewChat(); @@ -200,7 +221,7 @@ export function useChatSessionManagement({ [handleNewChat, refreshChatList, setIncognitoSessions] ); - const handleDeleteAllChats = useCallback(async () => { + const handleDeleteAllChats = useCallback(async (): Promise => { if ( !confirm( 'Are you sure you want to delete ALL chats (including incognito)? This cannot be undone.' @@ -222,10 +243,10 @@ export function useChatSessionManagement({ // --------------------------------------------------------------------------- // Initial chat load // --------------------------------------------------------------------------- - useEffect(() => { + useEffect((): void => { const { currentChatId, isIncognito } = useChatStore.getState(); if (storyId && !currentChatId && !isIncognito) { - const loadInitialChats = async () => { + const loadInitialChats = async (): Promise => { try { const chats = await api.chat.list(); startTransition(() => setChatHistoryList(chats)); @@ -248,11 +269,11 @@ export function useChatSessionManagement({ // re-renders up to App.tsx). chatStore.subscribe() fires imperatively // without triggering any React render cycle. // --------------------------------------------------------------------------- - useEffect(() => { + useEffect((): (() => void) => { let timeout: ReturnType | undefined; const unsubscribe = useChatStore.subscribe( - (state: ChatStoreState, prevState: ChatStoreState) => { + (state: ChatStoreState, prevState: ChatStoreState): void => { // Only fire when persisted session fields actually changed. if ( state.chatMessages === prevState.chatMessages && @@ -276,6 +297,7 @@ export function useChatSessionManagement({ systemPrompt, scratchpad, allowWebSearch, + projectContextRevision, } = state; if (!currentChatId || isChatLoading) { @@ -284,25 +306,29 @@ export function useChatSessionManagement({ if (isIncognito) { const firstUserMsg = chatMessages.find( - (message: ChatMessage) => message.role === 'user' + (message: ChatMessage): boolean => message.role === 'user' ); const name = firstUserMsg?.text?.substring(0, 40) || 'Incognito Chat'; - useChatStore.getState().setIncognitoSessions((prev: ChatSession[]) => - prev.map((session: ChatSession) => - session.id === currentChatId - ? { - ...session, - name, - messages: chatMessages, - systemPrompt, - allowWebSearch, - scratchpad, - } - : session - ) - ); + useChatStore + .getState() + .setIncognitoSessions((prev: ChatSession[]): ChatSession[] => + prev.map( + (session: ChatSession): ChatSession => + session.id === currentChatId + ? { + ...session, + name, + messages: chatMessages, + systemPrompt, + allowWebSearch, + scratchpad, + projectContextRevision, + } + : session + ) + ); } else { - timeout = setTimeout(async () => { + timeout = setTimeout(async (): Promise => { try { const { chatMessages: msgs, @@ -310,9 +336,12 @@ export function useChatSessionManagement({ systemPrompt: sp, allowWebSearch: aws, scratchpad: sc, + projectContextRevision: pcr, } = useChatStore.getState(); if (!cid) return; - const firstUserMsg = msgs.find((m: ChatMessage) => m.role === 'user'); + const firstUserMsg = msgs.find( + (m: ChatMessage): boolean => m.role === 'user' + ); const name = firstUserMsg?.text?.substring(0, 40) || 'Untitled Chat'; await api.chat.save(cid, { name, @@ -320,6 +349,7 @@ export function useChatSessionManagement({ systemPrompt: sp, allowWebSearch: aws, scratchpad: sc, + projectContextRevision: pcr, }); refreshChatList(); } catch (error) { @@ -330,7 +360,7 @@ export function useChatSessionManagement({ } ); - return () => { + return (): void => { clearTimeout(timeout); unsubscribe(); }; diff --git a/src/frontend/features/checkpoints/CheckpointsMenu.test.tsx b/src/frontend/features/checkpoints/CheckpointsMenu.test.tsx index 36181d77..f0bf7e8f 100644 --- a/src/frontend/features/checkpoints/CheckpointsMenu.test.tsx +++ b/src/frontend/features/checkpoints/CheckpointsMenu.test.tsx @@ -13,7 +13,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import React from 'react'; import { renderToString } from 'react-dom/server'; import { CheckpointsMenu } from './CheckpointsMenu'; -import { api } from '../../services/api'; // Mock the ThemeContext because CheckpointsMenu uses useTheme vi.mock('../layout/ThemeContext', () => ({ diff --git a/src/frontend/features/checkpoints/CheckpointsMenu.tsx b/src/frontend/features/checkpoints/CheckpointsMenu.tsx index a14375f2..0f685f68 100644 --- a/src/frontend/features/checkpoints/CheckpointsMenu.tsx +++ b/src/frontend/features/checkpoints/CheckpointsMenu.tsx @@ -10,7 +10,7 @@ */ import React, { useEffect, useRef, useState } from 'react'; -import { Save, ChevronDown, Download, Trash2, Plus } from 'lucide-react'; +import { Save, ChevronDown, Trash2, Plus } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useTheme } from '../layout/ThemeContext'; import { Button } from '../../components/ui/Button'; @@ -39,7 +39,7 @@ export const CheckpointsMenu: React.FC = ({ const menuRef = useRef(null); - const fetchCheckpoints = async () => { + const fetchCheckpoints = async (): Promise => { try { const res = await api.checkpoints.list(); setCheckpoints(res.checkpoints || []); @@ -48,13 +48,13 @@ export const CheckpointsMenu: React.FC = ({ } }; - useEffect(() => { + useEffect((): void => { if (isOpen) { fetchCheckpoints(); } }, [isOpen]); - useEffect(() => { + useEffect((): void => { // If external state indicates no changes, reset our session backup flag. // This handles cases where user undoes everything manually. if (!hasUnsavedChanges) { @@ -62,18 +62,18 @@ export const CheckpointsMenu: React.FC = ({ } }, [hasUnsavedChanges]); - useEffect(() => { - const onDocumentClick = (event: MouseEvent) => { + useEffect((): (() => void) => { + const onDocumentClick = (event: MouseEvent): void => { const target = event.target as Node; if (menuRef.current && !menuRef.current.contains(target)) { setIsOpen(false); } }; document.addEventListener('mousedown', onDocumentClick); - return () => document.removeEventListener('mousedown', onDocumentClick); + return (): void => document.removeEventListener('mousedown', onDocumentClick); }, []); - const handleCreate = async () => { + const handleCreate = async (): Promise => { setIsCreating(true); try { await api.checkpoints.create(); @@ -87,7 +87,7 @@ export const CheckpointsMenu: React.FC = ({ } }; - const handleLoad = async (timestamp: string) => { + const handleLoad = async (timestamp: string): Promise => { const reallyHasUnsavedWork = hasUnsavedChanges && !isStateAlreadyBackedUpInSession; if (reallyHasUnsavedWork) { const sure = await confirm({ @@ -110,7 +110,10 @@ export const CheckpointsMenu: React.FC = ({ } }; - const handleDelete = async (e: React.MouseEvent, timestamp: string) => { + const handleDelete = async ( + e: React.MouseEvent, + timestamp: string + ): Promise => { e.stopPropagation(); const sure = await confirm({ title: t('Delete Checkpoint'), @@ -141,7 +144,7 @@ export const CheckpointsMenu: React.FC = ({ theme={currentTheme} variant="ghost" size="sm" - onClick={() => setIsOpen((open: boolean) => !open)} + onClick={(): void => setIsOpen((open: boolean): boolean => !open)} title={t('Checkpoints')} className="px-2 border-l" > @@ -177,7 +180,7 @@ export const CheckpointsMenu: React.FC = ({ + + + + +
+ +
+ +); + +interface StreamingAggregatedViewProps { + response: NonNullable; + theme: AppTheme; + isLight: boolean; + borderMain: string; + t: (key: string) => string; +} + +const StreamingAggregatedView: React.FC = ({ + response, + theme, + isLight, + borderMain, + t, +}: StreamingAggregatedViewProps) => ( +
+
+ {t('status_code')}: {response.status_code} +
+ {Boolean(response.error) && ( +
+ {t('error')}: +
+ {typeof response.error === 'string' + ? response.error + : JSON.stringify(response.error, null, 2)} +
+
+ )} + {response.thinking && ( +
+ {t('thinking')}: +
+ {response.thinking} +
+
+ )} +
+ {t('full_content')}: +
+ {response.full_content} +
+
+ {response.tool_calls && ( +
+ {t('tool_calls')}: +
+ +
+
+ )} +
+ {t('metadata')}: + +
+
+
+); + +interface DebugLogEntryDetailsProps { + log: LogEntry; + streamMode: 'chunks' | 'aggregated'; + theme: AppTheme; + isLight: boolean; + borderMain: string; + t: (key: string) => string; +} + +const DebugLogEntryDetails: React.FC = ({ + log, + streamMode, + theme, + isLight, + borderMain, + t, +}: DebugLogEntryDetailsProps) => { + const codeBlockBg = isLight ? 'bg-brand-gray-100' : 'bg-brand-gray-900'; + const isStreaming = log.response?.streaming === true; + return ( +
+ {log.caller_id && ( +
+

+ {t('Caller')} +

+
+
+ {log.caller_id} ({t(getCallerOrigin(log.caller_id))}) +
+
+
+ )} +
+

+ {t('Request')} +

+
+ +
+
+
+

+ {t('Response')} +

+
+ {isStreaming && streamMode === 'aggregated' && log.response ? ( + + ) : ( + + )} +
+
+
+ ); +}; + +interface DebugLogEntryCardProps { + log: LogEntry; + isExpanded: boolean; + onToggle: () => void; + streamMode: 'chunks' | 'aggregated'; + theme: AppTheme; + isLight: boolean; + textMain: string; + borderMain: string; + bgSecondary: string; + t: (key: string) => string; +} + +const DebugLogEntryCard: React.FC = ({ + log, + isExpanded, + onToggle, + streamMode, + theme, + isLight, + textMain, + borderMain, + bgSecondary, + t, +}: DebugLogEntryCardProps) => { + // Pre-compute optional chains so they don't add to JSX complexity + const requestMethod = log.request?.method; + const requestUrl = log.request ? getLastUrlSegment(log.request.url) : ''; + const statusCode = log.response?.status_code; + const methodClass = + requestMethod === 'POST' + ? 'bg-green-500/10 text-green-500' + : 'bg-blue-500/10 text-blue-500'; + const modelTypeClass = + log.model_type === 'EDITING' + ? 'bg-fuchsia-500/10 text-fuchsia-500 border-fuchsia-500/20' + : log.model_type === 'WRITING' + ? 'bg-violet-500/10 text-violet-500 border-violet-500/20' + : 'bg-blue-500/10 text-blue-500 border-blue-500/20'; + const statusCodeClass = + statusCode != null && statusCode >= 200 && statusCode < 300 + ? 'bg-green-500/10 text-green-500' + : 'bg-red-500/10 text-red-500'; + + return ( +
+
+
+ +
+ {log.caller_id && ( + + {t('Caller')}: {log.caller_id} ({t(getCallerOrigin(log.caller_id))}) + + )} + + {t('Start')}: {new Date(log.timestamp_start).toLocaleTimeString()} + + {log.timestamp_end && ( + + {t('End')}: {new Date(log.timestamp_end).toLocaleTimeString()} + + )} +
+
+
+ + {isExpanded && ( + + )} +
+ ); +}; + +const getLastUrlSegment = (url: string): string => url.split('/').pop() ?? ''; + +// ─── Main component ────────────────────────────────────────────────────────── + export const DebugLogs: React.FC = ({ isOpen, onClose, theme, -}: DebugLogsProps) => { +}: DebugLogsProps): React.ReactElement | null => { const { t } = useTranslation(); const [logs, setLogs] = useState([]); const [expandedLogs, setExpandedLogs] = useState>({}); @@ -166,13 +522,7 @@ export const DebugLogs: React.FC = ({ const borderMain = isLight ? 'border-brand-gray-200' : 'border-brand-gray-800'; const bgSecondary = isLight ? 'bg-brand-gray-50' : 'bg-brand-gray-900'; - const scrollToBottom = () => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }; - - const fetchLogs = async () => { + const fetchLogs = async (): Promise => { setIsLoading(true); try { const data = await api.debug.getLogs(); @@ -184,7 +534,7 @@ export const DebugLogs: React.FC = ({ } }; - const clearLogs = async () => { + const clearLogs = async (): Promise => { if (!(await confirm(t('Are you sure you want to clear all logs?')))) return; try { await api.debug.clearLogs(); @@ -194,25 +544,28 @@ export const DebugLogs: React.FC = ({ } }; - useEffect(() => { - if (isOpen) { - fetchLogs(); - } + const toggleExpand = (id: string): void => { + setExpandedLogs((prev: Record): { [x: string]: boolean } => ({ + ...prev, + [id]: !prev[id], + })); + }; + + useEffect((): void => { + if (isOpen) fetchLogs(); }, [isOpen]); - useEffect(() => { + useEffect((): (() => void) | undefined => { if (isOpen && logs.length > 0) { - // Defer scroll until layout settles so height calculations are accurate. - const timeoutId = setTimeout(scrollToBottom, 50); - return () => clearTimeout(timeoutId); + const timeoutId = setTimeout((): void => { + if (scrollRef.current) + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + }, 50); + return (): void => clearTimeout(timeoutId); } return undefined; }, [isOpen, logs.length]); - const toggleExpand = (id: string) => { - setExpandedLogs((prev: Record) => ({ ...prev, [id]: !prev[id] })); - }; - if (!isOpen) return null; return ( @@ -227,82 +580,19 @@ export const DebugLogs: React.FC = ({ aria-labelledby="debug-logs-title" className={`flex-1 flex flex-col rounded-xl shadow-2xl overflow-hidden border ${borderMain} ${bgMain}`} > - {/* Header */} -
-
-
- -
-
-

- {t('LLM Communication Logs')} -

-

- {t('Debug view for all LLM requests and responses')} -

-
-
-
-
- - -
- - -
- -
-
+ - {/* Content */}
{logs.length === 0 ? (
@@ -310,207 +600,20 @@ export const DebugLogs: React.FC = ({

{t('No LLM communications logged yet.')}

) : ( - logs.map((log: DebugLogEntry, idx: number) => ( -
( + -
-
- -
- {log.caller_id && ( - - {t('Caller')}: {log.caller_id} ( - {t(getCallerOrigin(log.caller_id))}) - - )} - - {t('Start')}:{' '} - {new Date(log.timestamp_start).toLocaleTimeString()} - - {log.timestamp_end && ( - - {t('End')}: {new Date(log.timestamp_end).toLocaleTimeString()} - - )} -
-
-
- - {expandedLogs[log.id] && ( -
- {log.caller_id && ( -
-

- {t('Caller')} -

-
-
- {log.caller_id} ({t(getCallerOrigin(log.caller_id))}) -
-
-
- )} -
-

- {t('Request')} -

-
- -
-
-
-

- {t('Response')} -

-
- {log.response?.streaming && streamMode === 'aggregated' ? ( -
-
- {t('status_code')}:{' '} - {log.response.status_code} -
- {Boolean(log.response.error) && ( -
- {t('error')}: -
- {typeof log.response.error === 'string' - ? log.response.error - : JSON.stringify(log.response.error, null, 2)} -
-
- )} - {log.response.thinking && ( -
- {t('thinking')}: -
- {log.response.thinking} -
-
- )} -
- - {t('full_content')}: - -
- {log.response.full_content} -
-
- {log.response.tool_calls && ( -
- - {t('tool_calls')}: - -
- -
-
- )} -
- {t('metadata')}: - -
-
- ) : ( - - )} -
-
-
- )} -
+ log={log} + isExpanded={expandedLogs[log.id]} + onToggle={(): void => toggleExpand(log.id)} + streamMode={streamMode} + theme={theme} + isLight={isLight} + textMain={textMain} + borderMain={borderMain} + bgSecondary={bgSecondary} + t={t} + /> )) )}
diff --git a/src/frontend/features/editor/CodeMirrorEditor.test.tsx b/src/frontend/features/editor/CodeMirrorEditor.test.tsx index 59a9179d..b3244143 100644 --- a/src/frontend/features/editor/CodeMirrorEditor.test.tsx +++ b/src/frontend/features/editor/CodeMirrorEditor.test.tsx @@ -503,16 +503,16 @@ describe('CodeMirrorEditor', () => { }); }); - describe('softbreak: Backspace removes line-break " \\n" entirely', () => { + describe('softbreak: Backspace removes line-break " \\n" and joins with single space', () => { // "a \nb" — lb=1; cursor must be > lb to trigger (pos 2..4) it('cursor at lb+1', async () => { - expect(await softbreakKey('a \nb', 2, 'Backspace')).toBe('ab'); + expect(await softbreakKey('a \nb', 2, 'Backspace')).toBe('a b'); }); it('cursor at lb+2', async () => { - expect(await softbreakKey('a \nb', 3, 'Backspace')).toBe('ab'); + expect(await softbreakKey('a \nb', 3, 'Backspace')).toBe('a b'); }); it('cursor at lb+3 (just after \\n)', async () => { - expect(await softbreakKey('a \nb', 4, 'Backspace')).toBe('ab'); + expect(await softbreakKey('a \nb', 4, 'Backspace')).toBe('a b'); }); it('cursor at lb+0 does NOT intercept (let default Backspace run)', async () => { // Cursor before the sequence: default Backspace deletes 'a' @@ -611,16 +611,16 @@ describe('CodeMirrorEditor', () => { }); }); - describe('softbreak: Delete removes line-break " \\n" entirely', () => { + describe('softbreak: Delete removes line-break " \\n" and joins with single space', () => { // "a \nb" — lb=1; cursor must be <= lb+2 to trigger (pos 1..3) it('cursor at lb+0 (before first space)', async () => { - expect(await softbreakKey('a \nb', 1, 'Delete')).toBe('ab'); + expect(await softbreakKey('a \nb', 1, 'Delete')).toBe('a b'); }); it('cursor at lb+1 (between spaces)', async () => { - expect(await softbreakKey('a \nb', 2, 'Delete')).toBe('ab'); + expect(await softbreakKey('a \nb', 2, 'Delete')).toBe('a b'); }); it('cursor at lb+2 (before \\n)', async () => { - expect(await softbreakKey('a \nb', 3, 'Delete')).toBe('ab'); + expect(await softbreakKey('a \nb', 3, 'Delete')).toBe('a b'); }); it('cursor at lb+3 does NOT intercept (let default Delete run)', async () => { // Cursor after the sequence: default Delete removes 'b' diff --git a/src/frontend/features/editor/CodeMirrorEditor.tsx b/src/frontend/features/editor/CodeMirrorEditor.tsx index 3ddef843..2bce10b0 100644 --- a/src/frontend/features/editor/CodeMirrorEditor.tsx +++ b/src/frontend/features/editor/CodeMirrorEditor.tsx @@ -320,7 +320,6 @@ export const CodeMirrorEditor = React.forwardRef< EditorView | null, CodeMirrorEditorProps >( - // eslint-disable-next-line max-lines-per-function ( { value, @@ -351,8 +350,7 @@ export const CodeMirrorEditor = React.forwardRef< : (viewModeProp ?? (legacyMode === 'markdown' ? 'markdown' : 'raw')); // Derive enter behavior from viewMode unless explicitly overridden - const enterBehavior = - enterBehaviorProp ?? (viewMode === 'raw' ? 'newline' : 'softbreak'); + const enterBehavior = enterBehaviorProp ?? 'softbreak'; // Derive CodeMirror language mode from viewMode const mode: 'plain' | 'markdown' = viewMode === 'raw' ? 'plain' : 'markdown'; @@ -437,7 +435,7 @@ export const CodeMirrorEditor = React.forwardRef< _fB: number, _tB: number, ins: import('@codemirror/state').Text - ) => { + ): void => { if (toA !== fromA || ins.length !== 1) { safeInsert = false; return; @@ -467,7 +465,10 @@ export const CodeMirrorEditor = React.forwardRef< return Decoration.set(decs, true); } }, - { decorations: (v: { decorations: DecorationSet }) => v.decorations } + { + decorations: (v: { decorations: DecorationSet }): DecorationSet => + v.decorations, + } ); const buildSearchHighlightExtension = ( @@ -506,7 +507,7 @@ export const CodeMirrorEditor = React.forwardRef< // ── Mount / unmount ───────────────────────────────────────────────────── // ── Mount / unmount ───────────────────────────────────────────────────── - useEffect(() => { + useEffect((): (() => void) | undefined => { if (!containerRef.current) return undefined; const extensions: Extension[] = [ @@ -529,7 +530,7 @@ export const CodeMirrorEditor = React.forwardRef< { key: 'Ctrl-f', mac: 'Cmd-f', - run: () => { + run: (): boolean => { onOpenSearchRef.current?.(); return true; }, @@ -557,7 +558,7 @@ export const CodeMirrorEditor = React.forwardRef< placeholderCompartment.current.of(buildPlaceholderExtension(placeholder)), mdDecorationCompartment.current.of(buildMdDecorationExtension(viewMode)), selectionBgCompartment.current.of(buildSelectionBgExtension(selectionBg)), - EditorView.updateListener.of((update: ViewUpdate) => { + EditorView.updateListener.of((update: ViewUpdate): void => { if (update.docChanged) { const isExternalSync = update.transactions.some((tx: Transaction) => tx.annotation(externalValueSyncAnnotation) @@ -591,7 +592,7 @@ export const CodeMirrorEditor = React.forwardRef< (ref as React.MutableRefObject).current = view; } - return () => { + return (): void => { view.destroy(); viewRef.current = null; if (typeof ref === 'function') { @@ -604,13 +605,13 @@ export const CodeMirrorEditor = React.forwardRef< // ── Dynamic prop updates via Compartment.reconfigure ──────────────────── - useEffect(() => { + useEffect((): void => { viewRef.current?.dispatch({ effects: languageCompartment.current.reconfigure(buildLanguageExtension(mode)), }); }, [mode]); - useEffect(() => { + useEffect((): void => { viewRef.current?.dispatch({ effects: mdDecorationCompartment.current.reconfigure( buildMdDecorationExtension(viewMode) @@ -618,7 +619,7 @@ export const CodeMirrorEditor = React.forwardRef< }); }, [viewMode]); - useEffect(() => { + useEffect((): void => { viewRef.current?.dispatch({ effects: wsCompartment.current.reconfigure( buildWsExtension(showWhitespace, baselineValue, showDiff, streamingMode) @@ -626,7 +627,7 @@ export const CodeMirrorEditor = React.forwardRef< }); }, [showWhitespace, baselineValue, showDiff, streamingMode]); - useEffect(() => { + useEffect((): void => { viewRef.current?.dispatch({ effects: enterCompartment.current.reconfigure( buildEnterExtension(enterBehavior) @@ -634,7 +635,7 @@ export const CodeMirrorEditor = React.forwardRef< }); }, [enterBehavior]); - useEffect(() => { + useEffect((): void => { viewRef.current?.dispatch({ effects: selectionBgCompartment.current.reconfigure( buildSelectionBgExtension(selectionBg) @@ -642,7 +643,7 @@ export const CodeMirrorEditor = React.forwardRef< }); }, [selectionBg]); - useEffect(() => { + useEffect((): void => { viewRef.current?.dispatch({ effects: placeholderCompartment.current.reconfigure( buildPlaceholderExtension(placeholder) @@ -650,7 +651,7 @@ export const CodeMirrorEditor = React.forwardRef< }); }, [placeholder]); - useEffect(() => { + useEffect((): void => { viewRef.current?.dispatch({ effects: attributesCompartment.current.reconfigure( buildAttributesExtension(language, spellCheck, placeholder) @@ -664,7 +665,7 @@ export const CodeMirrorEditor = React.forwardRef< // same render the plugin is reconfigured with the correct baseline BEFORE // the content dispatch fires — ensuring the first painted frame already // shows the correct diff decorations rather than missing them. - useLayoutEffect(() => { + useLayoutEffect((): void => { viewRef.current?.dispatch({ effects: diffCompartment.current.reconfigure( buildDiffExtension(baselineValue, showDiff, streamingMode, showWhitespace) @@ -672,7 +673,7 @@ export const CodeMirrorEditor = React.forwardRef< }); }, [baselineValue, showDiff, streamingMode, showWhitespace]); - useEffect(() => { + useEffect((): void => { viewRef.current?.dispatch({ effects: searchHighlightCompartment.current.reconfigure( buildSearchHighlightExtension(searchHighlightRanges) @@ -688,7 +689,7 @@ export const CodeMirrorEditor = React.forwardRef< // synchronously in the same commit phase as the React render, so that any // sibling layout effects that measure scrollHeight see the new content // height immediately — eliminating one-frame flicker during LLM streaming. - useLayoutEffect(() => { + useLayoutEffect((): void => { const view = viewRef.current; if (!view) return; const docStr = view.state.doc.toString(); diff --git a/src/frontend/features/editor/Editor.sync.test.tsx b/src/frontend/features/editor/Editor.sync.test.tsx index f7db36f8..ffc58a10 100644 --- a/src/frontend/features/editor/Editor.sync.test.tsx +++ b/src/frontend/features/editor/Editor.sync.test.tsx @@ -301,7 +301,7 @@ describe('Editor diff highlighting – smart-quote regression', () => { // just the quote-character positions. expect(cmContent?.innerHTML).toContain('goodbye'); // Verify the post-streaming diff decorates a meaningful amount of content. - const countInserted = (html: string) => + const countInserted = (html: string): number => (html.match(/class="cm-diff-inserted"/g) ?? []).length; expect(countInserted(cmContent?.innerHTML ?? '')).toBeGreaterThan(0); }); diff --git a/src/frontend/features/editor/Editor.tsx b/src/frontend/features/editor/Editor.tsx index 8c379420..866faa63 100644 --- a/src/frontend/features/editor/Editor.tsx +++ b/src/frontend/features/editor/Editor.tsx @@ -45,6 +45,8 @@ import { import { useEditorScroll } from './hooks/useEditorScroll'; import { useEditorFormatting } from './hooks/useEditorFormatting'; +const STREAM_FOLLOW_ATTACH_DISTANCE_PX = 200; + // URL sanitizer — re-exported for backward compat with Editor.url.test.ts export { isSafeImageUrl } from './editorUtils'; import { isSafeImageUrl } from './editorUtils'; @@ -100,7 +102,6 @@ export interface EditorHandle { /* eslint-disable complexity */ export const Editor = React.memo( React.forwardRef( - // eslint-disable-next-line max-lines-per-function ( { chapter, @@ -138,6 +139,7 @@ export const Editor = React.memo( // Local content/title state so the editor div always gets the latest // typed value immediately, while the parent onChange (API call) is debounced. const [localContent, setLocalContent] = useState(chapter.content); + const deferredStreamingContentRef = useRef(null); // Ref that always holds the current content without triggering re-renders. // Used in callbacks that need the latest value at call time (e.g. suggestion // hotkeys) so those callbacks don't need localContent in their deps arrays. @@ -155,52 +157,75 @@ export const Editor = React.memo( const savedBaselineRef = useRef(baselineContent); const lastChapterIdRef = useRef(chapter.id); - useEffect(() => { + useEffect((): void => { const isChapterSwitch = chapter.id !== lastChapterIdRef.current; - if (!isChapterSwitch) return; - - lastChapterIdRef.current = chapter.id; - prevBaselineRef.current = baselineContent; - setLocalBaseline(baselineContent); - if (baselineContent !== undefined && baselineContent !== chapter.content) { - savedBaselineRef.current = baselineContent; - } else if (baselineContent === undefined) { - savedBaselineRef.current = undefined; + if (isChapterSwitch) { + lastChapterIdRef.current = chapter.id; + prevBaselineRef.current = baselineContent; + setLocalBaseline(baselineContent); + if (baselineContent !== undefined && baselineContent !== chapter.content) { + savedBaselineRef.current = baselineContent; + } else if (baselineContent === undefined) { + savedBaselineRef.current = undefined; + } + return; } - }, [chapter.id, baselineContent, chapter.content]); - if (baselineContent !== prevBaselineRef.current) { - prevBaselineRef.current = baselineContent; - setLocalBaseline(baselineContent); - // Only preserve as the real AI baseline when baselineContent differs from - // chapter.content. When isUserEdit=true, pushState sets baselineContent - // equal to chapter.content (no diff), so we must not overwrite the saved - // AI baseline with the user-edited value — otherwise Ctrl+Z would restore - // that wrong baseline instead of the original AI-written baseline. - if (baselineContent !== undefined && baselineContent !== chapter.content) { - savedBaselineRef.current = baselineContent; + if (baselineContent !== prevBaselineRef.current) { + prevBaselineRef.current = baselineContent; + setLocalBaseline(baselineContent); + // Only preserve as the real AI baseline when baselineContent differs from + // chapter.content. When isUserEdit=true, pushState sets baselineContent + // equal to chapter.content (no diff), so we must not overwrite the saved + // AI baseline with the user-edited value — otherwise Ctrl+Z would restore + // that wrong baseline instead of the original AI-written baseline. + if (baselineContent !== undefined && baselineContent !== chapter.content) { + savedBaselineRef.current = baselineContent; + } else if (baselineContent === undefined) { + savedBaselineRef.current = undefined; + } } - } + }, [chapter.id, baselineContent, chapter.content]); const isChatStreaming = useChatStore( - (s: ChatStoreState) => s.isProseStreamingFromChat + (s: ChatStoreState): boolean => s.isProseStreamingFromChat + ); + // True after the user stops chat mid-write: streaming has ended but we + // keep streamingMode=true so the prefix-based green highlight stays visible + // (as it appeared during streaming) rather than switching to LCS diff. + const isChatStreamingFrozen = useChatStore( + (s: ChatStoreState): boolean => s.isProseStreamingFrozen ); // Subscribe to the ephemeral streaming slot — only this editor instance // re-renders on each chunk, not the entire component tree. - const streamingContent = useStoryStore((s: StoryStoreState) => + const streamingContent = useStoryStore((s: StoryStoreState): string | null => s.streamingContent?.chapterId === chapter.id ? s.streamingContent.content : null ); + const streamingWriteMode = useStoryStore((s: StoryStoreState): string | null => + s.streamingContent?.chapterId === chapter.id + ? (s.streamingContent.writeMode ?? 'append') + : null + ); const proseStreamingActive = (aiControls.isProseStreaming ?? false) || isChatStreaming; + const isReplaceStreaming = + proseStreamingActive && streamingWriteMode === 'replace'; + // streamingModeActive keeps streamingMode=true even after active streaming + // ends (frozen state) so the green prefix-diff stays visible. + const streamingModeActive = proseStreamingActive || isChatStreamingFrozen; // Keep local state in sync when the chapter changes externally (chapter // switch, AI update, undo/redo). Use chapter.id as the primary trigger // for chapter switches; also watch chapter.content so AI insertions and // undo/redo (which can change content without changing id) are reflected. - useEffect(() => { + useEffect((): void => { const isChapterSwitch = chapter.id !== lastChapterIdRef.current; lastChapterIdRef.current = chapter.id; + if (isChapterSwitch) { + deferredStreamingContentRef.current = null; + } + // During active streaming the streaming-slot effect below owns // localContent; skip the chapter.content sync to avoid flashing the // pre-AI baseline content on every chunk. @@ -213,7 +238,7 @@ export const Editor = React.memo( const shouldDeferStreamingSync = proseStreamingActive && isDetachedFromBottomRef.current && - distanceFromBottomRef.current > 120 && + distanceFromBottomRef.current > STREAM_FOLLOW_ATTACH_DISTANCE_PX && !isChapterSwitch; if (isChapterSwitch || (!editorFocused && !shouldDeferStreamingSync)) { @@ -224,14 +249,46 @@ export const Editor = React.memo( // Push each streamed chunk directly into the editor's local state so // only this component re-renders — story.chapters stays untouched. - useEffect(() => { + useEffect((): void => { if (streamingContent !== null) { + const container = scrollContainerRef.current; + const liveDistanceFromBottom = container + ? container.scrollHeight - container.scrollTop - container.clientHeight + : Number.POSITIVE_INFINITY; + const isLiveAtBottom = liveDistanceFromBottom <= 50; + + const shouldDeferStreamingChunk = + proseStreamingActive && + isDetachedFromBottomRef.current && + distanceFromBottomRef.current > STREAM_FOLLOW_ATTACH_DISTANCE_PX && + !isLiveAtBottom; + + // While detached from the bottom, freeze chunk-by-chunk updates so + // stream geometry changes cannot pull the viewport unexpectedly. + if (shouldDeferStreamingChunk) { + deferredStreamingContentRef.current = streamingContent; + return; + } + + deferredStreamingContentRef.current = null; localContentRef.current = streamingContent; setLocalContent(streamingContent); } - }, [streamingContent]); + }, [streamingContent, proseStreamingActive]); + + // If the stream ends while a chunk was deferred, flush the latest deferred + // content immediately so the editor doesn't lag until a later model update. + useEffect((): void => { + if (proseStreamingActive) return; + const deferred = deferredStreamingContentRef.current; + if (deferred === null) return; + + deferredStreamingContentRef.current = null; + localContentRef.current = deferred; + setLocalContent(deferred); + }, [proseStreamingActive]); - useEffect(() => { + useEffect((): void => { setLocalTitle(chapter.title); }, [chapter.id, chapter.title]); @@ -256,12 +313,16 @@ export const Editor = React.memo( const { scrollContainerRef, handleScroll, + handleWheel, + handleTouchStart, + handleTouchMove, scrollMainContentToBottom, isDetachedFromBottomRef, distanceFromBottomRef, } = useEditorScroll({ localContent, - isProseStreaming, + isProseStreaming: proseStreamingActive, + isReplaceStreaming, chapterId: chapter.id, }); @@ -439,14 +500,14 @@ export const Editor = React.memo( ] ); - useEffect(() => { + useEffect((): (() => void) => { // Capture shortcuts globally so suggestion controls remain reachable // while focus moves across editor-adjacent UI. const onKeyDown = (e: KeyboardEvent): void => { maybeHandleSuggestionHotkey(e); }; window.addEventListener('keydown', onKeyDown, true); - return () => window.removeEventListener('keydown', onKeyDown, true); + return (): void => window.removeEventListener('keydown', onKeyDown, true); }, [maybeHandleSuggestionHotkey]); const format = (type: string): void => { @@ -532,13 +593,13 @@ export const Editor = React.memo( }; useImperativeHandle(ref, () => ({ - insertImage: (filename: string, url: string, altText?: string) => + insertImage: (filename: string, url: string, altText?: string): void => insertImageMarkdown(filename, url, altText), - focus: () => { + focus: (): void => { editorViewRef.current?.focus(); }, - format: (type: string) => format(type), - jumpToPosition: (start: number, end: number) => { + format: (type: string): void => format(type), + jumpToPosition: (start: number, end: number): void => { const view = editorViewRef.current; if (!view) return; view.dispatch({ @@ -603,11 +664,13 @@ export const Editor = React.memo( ? 'bg-brand-gray-50 border-t border-brand-gray-200' : 'bg-brand-gray-900 border-t border-brand-gray-800'; const hasContinuationOptions = continuations.some( - (option: string) => option && option.trim().length > 0 + (option: string): boolean | '' => option && option.trim().length > 0 ); const shouldShowContinuationPanel = isSuggestionMode || hasContinuationOptions; const displayedContinuations = - continuations.length > 0 ? continuations : Array.from({ length: 2 }, () => ''); + continuations.length > 0 + ? continuations + : Array.from({ length: 2 }, (): string => ''); const isChapterEmpty = !chapter.content || chapter.content.trim().length === 0; // We need to scroll in a few scenarios: @@ -626,26 +689,26 @@ export const Editor = React.memo( // panel is already present; the guard below prevents scrolling unless an // LLM action is active or the panel just opened. const prevHasContinuationRef = useRef(hasContinuationOptions); - useEffect(() => { + useEffect((): (() => void) | undefined => { const justOpened = !prevHasContinuationRef.current && hasContinuationOptions; prevHasContinuationRef.current = hasContinuationOptions; - if (isProseStreaming) return undefined; + if (proseStreamingActive) return undefined; if (!(isAiLoading || isSuggesting || justOpened)) return undefined; - const raf = window.requestAnimationFrame(() => { + const raf = window.requestAnimationFrame((): void => { if (!isDetachedFromBottomRef.current) { scrollMainContentToBottom(); } }); - return () => { + return (): void => { window.cancelAnimationFrame(raf); }; }, [ continuations, isAiLoading, isSuggesting, - isProseStreaming, + proseStreamingActive, hasContinuationOptions, scrollMainContentToBottom, ]); @@ -671,7 +734,7 @@ export const Editor = React.memo( localContentRef, onSuggestionButtonClick: handleSuggestionButtonClick, onAcceptContinuation, - onRegenerate: (cursor: number, content: string) => + onRegenerate: (cursor: number, content: string): void => suggestionControls.onKeyboardSuggestionAction?.( 'regenerate', cursor, @@ -691,6 +754,9 @@ export const Editor = React.memo( className="flex-1 overflow-y-auto px-4 py-6 md:py-8 flex flex-col items-center relative" style={{ overflowAnchor: 'none' }} onScroll={handleScroll} + onWheel={handleWheel} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} > {isDragging && (
@@ -727,12 +793,12 @@ export const Editor = React.memo( value={localTitle} onChange={( e: React.ChangeEvent - ) => { + ): void => { const val = e.target.value.replace(/\n/g, ''); setLocalTitle(val); if (titleDebounceRef.current) clearTimeout(titleDebounceRef.current); - titleDebounceRef.current = setTimeout(() => { + titleDebounceRef.current = setTimeout((): void => { onChange(chapter.id, { title: val }); }, DEBOUNCE_MS); }} @@ -762,7 +828,7 @@ export const Editor = React.memo( language={language} spellCheck={spellCheck} onOpenSearch={onOpenSearch} - onChange={(val: string, isUndoRedo?: boolean) => { + onChange={(val: string, isUndoRedo?: boolean): void => { setLocalContent(val); localContentRef.current = val; // Clear diff immediately on user input so typed text is @@ -782,7 +848,7 @@ export const Editor = React.memo( if (contentDebounceRef.current) { clearTimeout(contentDebounceRef.current); } - contentDebounceRef.current = setTimeout(() => { + contentDebounceRef.current = setTimeout((): void => { onChange(chapter.id, { content: val }, isUndoRedo); }, DEBOUNCE_MS); }} @@ -796,10 +862,10 @@ export const Editor = React.memo( } showWhitespace={showWhitespace} showDiff={settings.showDiff} - streamingMode={proseStreamingActive} + streamingMode={streamingModeActive} baselineValue={localBaseline} searchHighlightRanges={chapterSearchHighlightRanges} - enterBehavior={viewMode === 'raw' ? 'newline' : 'softbreak'} + enterBehavior="softbreak" selectionBg={selectionBg} placeholder={ chapter.scope === 'story' diff --git a/src/frontend/features/editor/EditorMobileToolbar.test.tsx b/src/frontend/features/editor/EditorMobileToolbar.test.tsx new file mode 100644 index 00000000..8f1dc5f5 --- /dev/null +++ b/src/frontend/features/editor/EditorMobileToolbar.test.tsx @@ -0,0 +1,153 @@ +// Copyright (C) 2026 StableLlama +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +/** + * Tests width-priority behavior for editor mobile toolbar overflow handling. + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { EditorProvider, type EditorContextValue } from './EditorContext'; +import { EditorMobileToolbar } from './EditorMobileToolbar'; + +const onAiAction = vi.fn(); + +const baseContext: EditorContextValue = { + theme: 'light', + toolbarBg: 'bg-white', + footerBg: 'bg-white', + textMuted: 'text-brand-gray-600', + chapterScope: 'chapter', + isAiLoading: false, + isWritingAvailable: true, + writingUnavailableReason: 'Unavailable', + isChapterEmpty: false, + onAiAction, + shouldShowContinuationPanel: false, + displayedContinuations: [], + suggestionMode: 'continuation', + onSuggestionModeChange: vi.fn(), + isSuggesting: false, + localContentRef: { current: '' }, + onSuggestionButtonClick: vi.fn(), + onAcceptContinuation: vi.fn(), + onRegenerate: vi.fn(), +}; + +let measuredWidth = 480; + +const rectFromWidth = (width: number): DOMRect => + ({ + width, + height: 56, + top: 0, + left: 0, + bottom: 56, + right: width, + x: 0, + y: 0, + toJSON: (): Record => ({}), + }) as DOMRect; + +const renderToolbar = (): void => { + render( + + + + ); +}; + +describe('EditorMobileToolbar', () => { + beforeEach(() => { + onAiAction.mockReset(); + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation( + (): DOMRect => rectFromWidth(measuredWidth) + ); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('keeps full layout on wide widths', async () => { + measuredWidth = 500; + renderToolbar(); + + await waitFor(() => { + expect(screen.getByText('Chapter AI')).toBeTruthy(); + }); + + expect(screen.getByRole('button', { name: 'Extend' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Rewrite' })).toBeTruthy(); + }); + + it('drops the label before dropping action buttons', async () => { + measuredWidth = 340; + renderToolbar(); + + await waitFor(() => { + expect(screen.queryByText('Chapter AI')).toBeNull(); + }); + + expect(screen.getByRole('button', { name: 'Extend' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Rewrite' })).toBeTruthy(); + }); + + it('moves rewrite into overflow menu at split widths', async () => { + measuredWidth = 280; + renderToolbar(); + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Rewrite' })).toBeNull(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'More AI actions' })); + + expect(screen.getByRole('button', { name: 'Rewrite' })).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Rewrite' })); + expect(onAiAction).toHaveBeenCalledWith('chapter', 'rewrite'); + }); + + it('keeps both actions accessible in menu-only mode', async () => { + measuredWidth = 220; + renderToolbar(); + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Rewrite' })).toBeNull(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'AI' })); + + expect(screen.getByRole('button', { name: 'Extend' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Rewrite' })).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: 'Extend' })); + expect(onAiAction).toHaveBeenCalledWith('chapter', 'extend'); + }); + + it('recomputes layout after resize events when ResizeObserver is unavailable', async () => { + measuredWidth = 500; + renderToolbar(); + + await waitFor(() => { + expect(screen.getByText('Chapter AI')).toBeTruthy(); + }); + + measuredWidth = 220; + fireEvent(window, new Event('resize')); + + await waitFor(() => { + expect(screen.queryByText('Chapter AI')).toBeNull(); + expect(screen.getByRole('button', { name: 'AI' })).toBeTruthy(); + }); + }); +}); diff --git a/src/frontend/features/editor/EditorMobileToolbar.tsx b/src/frontend/features/editor/EditorMobileToolbar.tsx index 2bc454f0..af6d97eb 100644 --- a/src/frontend/features/editor/EditorMobileToolbar.tsx +++ b/src/frontend/features/editor/EditorMobileToolbar.tsx @@ -11,11 +11,43 @@ */ import React from 'react'; -import { Wand2, FileEdit } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChevronDown, FileEdit, MoreHorizontal, Wand2 } from 'lucide-react'; import { Button } from '../../components/ui/Button'; +import { useClickOutside } from '../../utils/hooks'; import { useEditorContext } from './EditorContext'; +type ToolbarLayoutMode = 'full' | 'compact' | 'split' | 'menu'; + +type MenuAction = { + key: string; + label: string; + icon: React.ReactElement; + onClick: () => void; + disabled: boolean; + title: string; +}; + +const FULL_LAYOUT_MIN_WIDTH = 420; +const COMPACT_LAYOUT_MIN_WIDTH = 330; +const SPLIT_LAYOUT_MIN_WIDTH = 250; + +const getLayoutMode = (width: number): ToolbarLayoutMode => { + if (width >= FULL_LAYOUT_MIN_WIDTH) { + return 'full'; + } + if (width >= COMPACT_LAYOUT_MIN_WIDTH) { + return 'compact'; + } + if (width >= SPLIT_LAYOUT_MIN_WIDTH) { + return 'split'; + } + return 'menu'; +}; + export const EditorMobileToolbar: React.FC = () => { + const { t } = useTranslation(); const { theme, toolbarBg, @@ -28,64 +60,218 @@ export const EditorMobileToolbar: React.FC = () => { onAiAction, } = useEditorContext(); + const rootRef = useRef(null); + const menuRef = useRef(null); + const [menuOpen, setMenuOpen] = useState(false); + const [toolbarWidth, setToolbarWidth] = useState(0); + + useEffect(() => { + const element = rootRef.current; + if (!element) { + return; + } + + const syncWidth = (): void => { + setToolbarWidth(element.getBoundingClientRect().width); + }; + + syncWidth(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', syncWidth); + return (): void => window.removeEventListener('resize', syncWidth); + } + + const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => { + const nextWidth = entries[0]?.contentRect.width; + if (typeof nextWidth === 'number') { + setToolbarWidth(nextWidth); + } + }); + observer.observe(element); + + return (): void => observer.disconnect(); + }, []); + + useClickOutside(menuRef, () => setMenuOpen(false), menuOpen); + + const layoutMode = getLayoutMode(toolbarWidth); + const showLabel = layoutMode === 'full'; + const showInlineRewrite = layoutMode === 'full' || layoutMode === 'compact'; + const showInlineExtend = layoutMode !== 'menu'; + const showMenuTrigger = layoutMode === 'split' || layoutMode === 'menu'; + const iconOnlyInlineActions = layoutMode === 'split' && toolbarWidth < 290; + + const aiSectionLabel = chapterScope === 'story' ? t('Story AI') : t('Chapter AI'); + + const extendButtonTitle = !isWritingAvailable + ? writingUnavailableReason + : chapterScope === 'story' + ? t('Extend Story Draft (WRITING model)') + : t('Extend Chapter (WRITING model)'); + + const rewriteButtonTitle = !isWritingAvailable + ? writingUnavailableReason + : isChapterEmpty + ? t('Chapter is empty; cannot rewrite existing text.') + : chapterScope === 'story' + ? t('Rewrite Story Draft (WRITING model)') + : t('Rewrite Chapter (WRITING model)'); + + const menuActions = useMemo( + () => [ + { + key: 'extend', + label: t('Extend'), + icon: , + onClick: (): void => onAiAction('chapter', 'extend'), + disabled: isAiLoading || !isWritingAvailable, + title: extendButtonTitle, + }, + { + key: 'rewrite', + label: t('Rewrite'), + icon: , + onClick: (): void => onAiAction('chapter', 'rewrite'), + disabled: isAiLoading || !isWritingAvailable || isChapterEmpty, + title: rewriteButtonTitle, + }, + ], + [ + extendButtonTitle, + isAiLoading, + isChapterEmpty, + isWritingAvailable, + onAiAction, + rewriteButtonTitle, + t, + ] + ); + + const visibleMenuActions = menuActions.filter((action: MenuAction) => { + if (layoutMode === 'split') { + return action.key === 'rewrite'; + } + if (layoutMode === 'menu') { + return true; + } + return false; + }); + return ( -
-
-
- {/* Mobile Toolbar Left Items */} -
-
+
+
+
- - {chapterScope === 'story' ? 'Story AI' : 'Chapter AI'} - -
- - + {showLabel && ( + <> + + {aiSectionLabel} + +
+ + )} + + {showInlineExtend && ( + + )} + + {showInlineRewrite && ( + + )} + + {showMenuTrigger && ( +
+ + + {menuOpen && ( + <> + + ))} +
+ + )} +
+ )}
diff --git a/src/frontend/features/editor/EditorSuggestionPanel.tsx b/src/frontend/features/editor/EditorSuggestionPanel.tsx index a38117a2..08b4a413 100644 --- a/src/frontend/features/editor/EditorSuggestionPanel.tsx +++ b/src/frontend/features/editor/EditorSuggestionPanel.tsx @@ -50,7 +50,7 @@ export const EditorSuggestionPanel: React.FC = () => { ) => - setLanguage(e.target.value) - } + onChange={( + e: React.ChangeEvent + ): void => setLanguage(e.target.value)} > {languages.map((lng: string) => (