diff --git a/backend/llm.py b/backend/llm.py index c111beb..548ab4b 100644 --- a/backend/llm.py +++ b/backend/llm.py @@ -54,26 +54,39 @@ def _extract_message(response) -> tuple[str, str]: return content, thinking -async def call_ollama(prompt: str, *, tag: str = "default", temperature: float = 0.7, thinking: str = None) -> dict: +async def call_ollama( + prompt: str, + *, + system_prompt: str = None, + tag: str = "default", + temperature: float = 0.7, + thinking: str = None, +) -> dict: """ 调用 Ollama API 并返回 content 和 thinking。 """ start = time.perf_counter() start_dt = datetime.now() logger.info( - "[LLM][%s] request model=%s host=%s prompt_chars=%d temp=%.2f thinking=%s", + "[LLM][%s] request model=%s host=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s", tag, OLLAMA_MODEL, OLLAMA_HOST, len(prompt), + len(system_prompt or ""), temperature, thinking, ) try: + messages = [] + if system_prompt and system_prompt.strip(): + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + kwargs = { "model": OLLAMA_MODEL, - "messages": [{'role': 'user', 'content': prompt}], + "messages": messages, "stream": False, "options": { 'temperature': temperature, diff --git a/backend/main.py b/backend/main.py index 6b630af..3803ff6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8,7 +8,7 @@ import base64 import uuid import logging -from prompt import build_prompt, prepare_prompt_context +from prompt import build_completion_prompts, prepare_prompt_context from llm import call_ollama, call_vlm_ocr from geoip import get_ip_location_text @@ -98,7 +98,7 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s logger.info("[%s] llm_input_prefix=%r", request_id, llm_prefix) logger.info("[%s] llm_input_suffix=%r", request_id, llm_suffix) - prompt = build_prompt( + system_prompt, user_prompt = build_completion_prompts( req.prefix, req.suffix, req.languageId, @@ -107,7 +107,8 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s preferences=req.user_preferences ) result = await call_ollama( - prompt, + user_prompt, + system_prompt=system_prompt, tag=f"{request_id}-primary", temperature=0.7, thinking=req.model_thinking if req.model_thinking != "none" else None diff --git a/backend/prompt.py b/backend/prompt.py index 10ed6db..17cba2c 100644 --- a/backend/prompt.py +++ b/backend/prompt.py @@ -1,27 +1,40 @@ +from datetime import datetime, timedelta, timezone +import re from typing import Tuple -from datetime import datetime, timezone, timedelta + def _get_current_datetime(timezone_pref: str = "auto") -> str: - # Default to UTC+8 if auto or not specified + # Default to UTC+8 if auto or not specified. offset = 8 tz_info = " (UTC+8)" - - if timezone_pref and timezone_pref != 'auto': - # Try to parse something like "UTC+8" or "GMT+8" - import re - match = re.search(r'([+-])(\d+)', timezone_pref) + + if timezone_pref and timezone_pref != "auto": + # Parse values like "UTC+8" or "GMT-5". + match = re.search(r"([+-])(\d+)", timezone_pref) if match: sign = match.group(1) hours = int(match.group(2)) - offset = hours if sign == '+' else -hours + offset = hours if sign == "+" else -hours tz_info = f" ({timezone_pref})" else: tz_info = f" ({timezone_pref})" now = datetime.now(timezone(timedelta(hours=offset))) - weekdays = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"] + weekdays = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ] weekday = weekdays[now.weekday()] - return f"{now.year}年{now.month}月{now.day}日 {weekday} {now.hour:02d}:{now.minute:02d}:{now.second:02d}{tz_info}" + return ( + f"{now.year}-{now.month:02d}-{now.day:02d} " + f"{weekday} {now.hour:02d}:{now.minute:02d}:{now.second:02d}{tz_info}" + ) + def _sanitize_language_id(language_id: str) -> str: if not language_id: @@ -34,98 +47,247 @@ def _sanitize_language_id(language_id: str) -> str: return value or "markdown" +def _normalize_newlines(text: str) -> str: + return (text or "").replace("\r\n", "\n").replace("\r", "\n") + + def _prepare_context(prefix: str, suffix: str) -> Tuple[str, str]: """ Prepare prefix/suffix for model completion context. - Filter out potential web-scraping or legacy artifacts like , , . + Filter out potential web-scraping or legacy artifacts like , , . """ - import re - br_pattern = re.compile(r'', re.IGNORECASE) - clean_prefix = br_pattern.sub('', prefix or "") - clean_suffix = br_pattern.sub('', suffix or "") + br_pattern = re.compile(r"", re.IGNORECASE) + clean_prefix = br_pattern.sub("", prefix or "") + clean_suffix = br_pattern.sub("", suffix or "") return clean_prefix, clean_suffix +FENCE_LINE_RE = re.compile(r"^[ \t]*```.*$") + + +def _cursor_in_fenced_code_block(prefix: str) -> bool: + """ + Determine whether the cursor is currently inside a fenced code block. + The state is computed by toggling on each markdown fence line that matches: + ^[ \t]*```.*$ + """ + normalized = _normalize_newlines(prefix) + in_fence = False + for line in normalized.split("\n"): + if FENCE_LINE_RE.match(line): + in_fence = not in_fence + return in_fence + + def prepare_prompt_context(prefix: str, suffix: str) -> Tuple[str, str]: return _prepare_context(prefix, suffix) -def build_prompt( - prefix: str, - suffix: str, - language_id: str = "markdown", +def build_inline_system_prompt(language_id: str = "markdown") -> str: + safe_language_id = _sanitize_language_id(language_id) + system_prompt = f"""You are an inline completion engine for a {safe_language_id} editor with ghost-text suggestions. + +Return only the insertion text that should be placed between PREFIX and SUFFIX. + +Hard constraints you must follow: +1) Output-only contract: +- Output insertion text only. +- No explanations, no meta labels, no wrapper quotes around the whole answer. + +2) Strict math formatting (KaTeX): +- If you output any math expression, it must be strict KaTeX-compatible math. +- Every formula must be wrapped with either $...$ (inline) or $$...$$ (block). +- Never output bare formulas without $ or $$ wrappers. + +3) Strict code formatting: +- Read CURSOR_IN_FENCED_CODE_BLOCK from the user prompt. +- If CURSOR_IN_FENCED_CODE_BLOCK=true: + - You are already inside a fenced code block. + - Never output triple backticks. + - Output code lines only. +- If CURSOR_IN_FENCED_CODE_BLOCK=false: + - Any code output must be in a fenced code block with a language tag: + ```{{language}} + ... + ``` + - Do not output code snippets as inline backticks. + - Choose the language tag from context (no default fallback tag instruction). + +4) Boundary newline repair: +- Read PREFIX_ENDS_WITH_NEWLINE and SUFFIX_STARTS_WITH_NEWLINE from the user prompt. +- Carefully reason about whether OUTPUT should start or end with a newline. +- If PREFIX lacks a required boundary newline, add it at OUTPUT start. +- If SUFFIX lacks a required boundary newline, add it at OUTPUT end. +- Ensure PREFIX + OUTPUT + SUFFIX is structurally natural. + +5) Context stitching: +- Do not repeat text that already appears at the start of SUFFIX. +- Preserve nearby language, tone, punctuation, indentation, and markdown structure. +- Continue existing structures naturally (lists, tables, block quotes, headings). + +6) OCR safety: +- PREFIX may include hidden OCR metadata tags like . +- Never output any OCR tag. +- Never output strings containing as OCR artifacts.""" + return system_prompt.strip() + + +INLINE_EXAMPLES = """[EX01] Prose continuation +The quick brown fox +jumps over the lazy dog. +Expected OUTPUT: +moved quietly and then + +[EX02] Avoid repeating suffix beginning +Our launch plan starts with +phase one, followed by phase two. +Expected OUTPUT: +careful internal testing before + +[EX03] Continue markdown checklist +## TODO +- [ ] Buy milk +- [ ] + +Expected OUTPUT: +Write release notes and share draft with team + +[EX04] Cursor outside code block, code must use fenced block +CURSOR_IN_FENCED_CODE_BLOCK=false +Parse this JSON payload in Python: + +Expected OUTPUT: +```python +import json +data = json.loads(payload) +``` + +[EX05] Cursor inside fenced code block, do not output fences +CURSOR_IN_FENCED_CODE_BLOCK=true +```python +def add(a, b): + return + +``` +Expected OUTPUT: +a + b + +[EX06] Inline math must use $...$ +The derivative of x^2 is +. +Expected OUTPUT: +$2x$ + +[EX07] Block math must use $$...$$ +We can write the Gaussian integral as: + +Expected OUTPUT: +$$ +\\int_{-\\infty}^{\\infty} e^{-x^2}\\,dx = \\sqrt{\\pi} +$$ + +[EX08] Prefix misses boundary newline; add newline at output start +PREFIX_ENDS_WITH_NEWLINE=false +Deployment steps: + +Expected OUTPUT: + +- Build artifact +- Deploy service + +[EX09] Suffix misses boundary newline; add newline at output end +SUFFIX_STARTS_WITH_NEWLINE=false +Summary paragraph complete. +## Next Section +Expected OUTPUT: + + +[EX10] OCR metadata exists but must never be emitted + +The relationship is +. +Expected OUTPUT: +$y = mx + b$ + +[EX11] Continue markdown table with correct row shape +| Name | Score | +| --- | --- | +| Alice | 92 | +| Bob | + +Expected OUTPUT: +88 | + +[EX12] Mixed text + math + code in one insertion +CURSOR_IN_FENCED_CODE_BLOCK=false +Use the area formula and provide a tiny JS helper. + +Expected OUTPUT: +The area is $A = \\pi r^2$. + +```javascript +const area = (r) => Math.PI * r * r; +```""" + + +def build_completion_prompts( + prefix: str, + suffix: str, + language_id: str = "markdown", location: str = "", thinking_level: str = "low", - preferences: object = None -) -> str: + preferences: object = None, +) -> Tuple[str, str]: safe_language_id = _sanitize_language_id(language_id) recent_prefix, recent_suffix = _prepare_context(prefix, suffix) + recent_prefix = _normalize_newlines(recent_prefix) + recent_suffix = _normalize_newlines(recent_suffix) + + cursor_in_fenced_code_block = _cursor_in_fenced_code_block(recent_prefix) + prefix_ends_with_newline = recent_prefix.endswith("\n") + suffix_starts_with_newline = recent_suffix.startswith("\n") + tz_pref = preferences.timezone if preferences else "auto" current_time = _get_current_datetime(tz_pref) location_info = f"\nUser location: {location}" if location else "" - + pref_info = [] if preferences: - if preferences.language and preferences.language != 'auto': + if preferences.language and preferences.language != "auto": pref_info.append(f"Preferred language: {preferences.language}") - if preferences.currency and preferences.currency != 'auto': + if preferences.currency and preferences.currency != "auto": pref_info.append(f"Preferred currency: {preferences.currency}") - + preferences_instruction = "\n".join(pref_info) if preferences_instruction: preferences_instruction = f"\nUser Preferences:\n{preferences_instruction}" - prompt = f"""Current time: {current_time}{location_info}{preferences_instruction} + user_prompt = f"""Current time: {current_time}{location_info}{preferences_instruction} +Reasoning hint: {thinking_level} +Editor language id: {safe_language_id} -You are an inline completion engine for a {safe_language_id} editor with ghost-text suggestions. +Completion state flags: +- CURSOR_IN_FENCED_CODE_BLOCK: {"true" if cursor_in_fenced_code_block else "false"} +- PREFIX_ENDS_WITH_NEWLINE: {"true" if prefix_ends_with_newline else "false"} +- SUFFIX_STARTS_WITH_NEWLINE: {"true" if suffix_starts_with_newline else "false"} -Your job: -- Return ONLY the text that should be inserted at the cursor between PREFIX and SUFFIX. -- Prefer a meaningful, non-empty insertion with moderate length. -- Avoid overly short outputs with little information value. +Task: +- Produce the best insertion text at the cursor between PREFIX and SUFFIX. +- Keep insertion meaningful and non-empty. +- Keep insertion concise unless structure requires more content. -Important context: -- PREFIX may contain OCR metadata inline after images, e.g.  . -- The is hidden context describing image content. -- Never copy, rewrite, or emit OCR tags in output. -- Never output . - -Hard rules: -1. Seamless join: - PREFIX + OUTPUT + SUFFIX must read naturally as one continuous document. -2. No suffix repetition: - Do NOT repeat text that already appears at the start of SUFFIX. -3. Balanced length: - Prefer concise but meaningful continuation, not ultra-short fragments. - Default target is 10-500 characters and 1-20 lines for plain prose. - You may be longer when structure requires it (lists, tables, code blocks, math blocks). -4. Avoid trivial output: - Do not output only punctuation or filler such as ".", ",", ";", ":". - Do not output just one token unless it is structurally necessary. -5. Preserve local style: - Match nearby language, tone, punctuation, spacing, and indentation. -6. Markdown awareness: - Continue active list/checkbox/ordered-list patterns when applicable. - Preserve indentation in nested list/code contexts. - You may output full markdown structures when context needs them: headings, lists, tables, fenced code blocks, blockquotes, and LaTeX ($...$ / $$...$$). - Close obvious unclosed inline markdown markers only when needed to bridge. -7. Strict output format: - Output insertion text only. - No explanations, labels, or wrapper quotes around the whole output. - Markdown syntax is allowed when it is the intended insertion (including fenced code blocks and LaTeX). +Context notes: +- PREFIX may include OCR metadata after image markdown, e.g.  . +- OCR metadata is hidden context and must never be copied into output. +- Preserve local style and formatting. Decision policy: -- If PREFIX already connects naturally to SUFFIX, add a brief but useful continuation when possible. -- If uncertain, prefer a complete short phrase or sentence with clear meaning. +- Prioritize seamless join: PREFIX + OUTPUT + SUFFIX must read naturally. +- Do not repeat SUFFIX-leading text. +- If uncertain, prefer a complete short phrase/sentence with clear meaning. -Examples: -The quick brown fox -jumps over the lazy dog. -Output: "moved quietly and then " - -## TODO\\n- [ ] Buy milk\\n- [ ] - -Output: "Write release notes and share draft with team" +Comprehensive examples: +{INLINE_EXAMPLES} Now produce the insertion. @@ -139,4 +301,27 @@ Now produce the insertion. Output:""" - return prompt.strip() + system_prompt = build_inline_system_prompt(safe_language_id) + return system_prompt.strip(), user_prompt.strip() + + +def build_prompt( + prefix: str, + suffix: str, + language_id: str = "markdown", + location: str = "", + thinking_level: str = "low", + preferences: object = None, +) -> str: + """ + Backward-compatible helper. Returns only the user prompt body. + """ + _, user_prompt = build_completion_prompts( + prefix=prefix, + suffix=suffix, + language_id=language_id, + location=location, + thinking_level=thinking_level, + preferences=preferences, + ) + return user_prompt diff --git a/backend/tests/test_llm.py b/backend/tests/test_llm.py new file mode 100644 index 0000000..59c713d --- /dev/null +++ b/backend/tests/test_llm.py @@ -0,0 +1,65 @@ +import asyncio +import importlib +import sys +from pathlib import Path + +import pytest + + +BACKEND_DIR = Path(__file__).resolve().parents[1] +if str(BACKEND_DIR) not in sys.path: + sys.path.insert(0, str(BACKEND_DIR)) + +try: + llm = importlib.import_module("llm") +except ModuleNotFoundError: + pytest.skip("llm module dependencies are not available", allow_module_level=True) + + +def test_call_ollama_messages_roles_with_system(monkeypatch): + captured = {} + + async def fake_chat(**kwargs): + captured["messages"] = kwargs["messages"] + return {"message": {"content": "ok", "thinking": ""}} + + monkeypatch.setattr(llm.client, "chat", fake_chat) + + result = asyncio.run( + llm.call_ollama( + "user prompt body", + system_prompt="system prompt body", + tag="test", + temperature=0.1, + ) + ) + + assert result["content"] == "ok" + assert captured["messages"][0]["role"] == "system" + assert captured["messages"][0]["content"] == "system prompt body" + assert captured["messages"][1]["role"] == "user" + assert captured["messages"][1]["content"] == "user prompt body" + + +def test_call_ollama_messages_roles_without_system(monkeypatch): + captured = {} + + async def fake_chat(**kwargs): + captured["messages"] = kwargs["messages"] + return {"message": {"content": "ok", "thinking": ""}} + + monkeypatch.setattr(llm.client, "chat", fake_chat) + + result = asyncio.run( + llm.call_ollama( + "user prompt only", + system_prompt="", + tag="test-no-system", + temperature=0.1, + ) + ) + + assert result["content"] == "ok" + assert len(captured["messages"]) == 1 + assert captured["messages"][0]["role"] == "user" + assert captured["messages"][0]["content"] == "user prompt only" diff --git a/backend/tests/test_prompt.py b/backend/tests/test_prompt.py new file mode 100644 index 0000000..8a2daa7 --- /dev/null +++ b/backend/tests/test_prompt.py @@ -0,0 +1,56 @@ +import sys +from pathlib import Path + + +BACKEND_DIR = Path(__file__).resolve().parents[1] +if str(BACKEND_DIR) not in sys.path: + sys.path.insert(0, str(BACKEND_DIR)) + +import prompt # noqa: E402 + + +def test_prompt_builds_system_and_user(): + system_prompt, user_prompt = prompt.build_completion_prompts( + prefix="The result is ", + suffix="for this dataset.", + language_id="markdown", + ) + + assert "Hard constraints you must follow" in system_prompt + assert "strict KaTeX-compatible math" in system_prompt + assert "$...$" in system_prompt + assert "$$...$$" in system_prompt + assert "```{language}" in system_prompt + assert "CURSOR_IN_FENCED_CODE_BLOCK" in user_prompt + assert "PREFIX_ENDS_WITH_NEWLINE" in user_prompt + assert "SUFFIX_STARTS_WITH_NEWLINE" in user_prompt + + +def test_cursor_in_fence_detection(): + assert prompt._cursor_in_fenced_code_block("") is False + assert prompt._cursor_in_fenced_code_block("```python\nprint('x')\n") is True + assert prompt._cursor_in_fenced_code_block("```python\nprint('x')\n```\n") is False + assert prompt._cursor_in_fenced_code_block("text ```not-a-fence``` tail") is False + + +def test_newline_flags(): + _, user_prompt_a = prompt.build_completion_prompts( + prefix="Hello", + suffix="World", + ) + assert "CURSOR_IN_FENCED_CODE_BLOCK: false" in user_prompt_a + assert "PREFIX_ENDS_WITH_NEWLINE: false" in user_prompt_a + assert "SUFFIX_STARTS_WITH_NEWLINE: false" in user_prompt_a + + _, user_prompt_b = prompt.build_completion_prompts( + prefix="Hello\n", + suffix="\nWorld", + ) + assert "PREFIX_ENDS_WITH_NEWLINE: true" in user_prompt_b + assert "SUFFIX_STARTS_WITH_NEWLINE: true" in user_prompt_b + + +def test_examples_coverage(): + _, user_prompt = prompt.build_completion_prompts(prefix="", suffix="") + for ex in range(1, 13): + assert f"[EX{ex:02d}]" in user_prompt diff --git a/src/components/MilkdownEditor.vue b/src/components/MilkdownEditor.vue index 447e5ae..16f9aa7 100644 --- a/src/components/MilkdownEditor.vue +++ b/src/components/MilkdownEditor.vue @@ -2,6 +2,35 @@ + + + + + + + + + + + + + + + contentSize.value > SIZE_LIMIT) const sizeInKB = computed(() => Math.floor(contentSize.value / 1024)) +const undoLabel = computed(() => t('undo') || 'Undo') +const redoLabel = computed(() => t('redo') || 'Redo') const aiButtonLabel = computed(() => { if (isOverLimit.value) return t('docTooLarge') return aiEnabled.value ? t('disableAI') : t('enableAI') @@ -133,6 +167,7 @@ const aiButtonLabel = computed(() => { let crepe = null let markdownSyncTimer = null +let rootResizeObserver = null const objectUrls = new Set() const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock']) @@ -220,6 +255,38 @@ const clearCurrentGhost = () => { }) } +const updateEditorTailSpace = () => { + if (!root.value) return + const viewportHeight = root.value.clientHeight + const tailSpace = Math.max(viewportHeight - 32, 160) + root.value.style.setProperty('--editor-tail-space', `${tailSpace}px`) +} + +const updateHistoryState = (view) => { + canUndo.value = undoDepth(view.state) > 0 + canRedo.value = redoDepth(view.state) > 0 +} + +const runHistoryCommand = (command) => { + if (!crepe) return + crepe.editor.action((ctx) => { + const view = ctx.get(editorViewCtx) + interruptCopilot(view) + clearCurrentSuggestion(view) + command(view.state, (tr) => view.dispatch(tr), view) + updateHistoryState(view) + view.focus() + }) +} + +const handleUndo = () => { + runHistoryCommand(undo) +} + +const handleRedo = () => { + runHistoryCommand(redo) +} + const performOCR = async (file, cacheKey, imageHash = '') => { if (!aiEnabled.value) return @@ -264,6 +331,13 @@ const performOCR = async (file, cacheKey, imageHash = '') => { onMounted(async () => { if (!root.value) throw new Error('root.value is null') + updateEditorTailSpace() + if (typeof ResizeObserver !== 'undefined') { + rootResizeObserver = new ResizeObserver(() => { + updateEditorTailSpace() + }) + rootResizeObserver.observe(root.value) + } crepe = new Crepe({ root: root.value, @@ -337,8 +411,10 @@ onMounted(async () => { crepe.on((listener) => { listener.updated((ctx, doc) => { + const view = ctx.get(editorViewCtx) syncObjectUrls(doc) refreshSizeAndLimit(ctx) + updateHistoryState(view) scheduleMarkdownSync() }) }) @@ -347,6 +423,7 @@ onMounted(async () => { const view = ctx.get(editorViewCtx) setCopilotEnabled(view, aiEnabled.value) refreshSizeAndLimit(ctx) + updateHistoryState(view) }) scheduleMarkdownSync() }) @@ -479,6 +556,11 @@ onUnmounted(() => { markdownSyncTimer = null } + if (rootResizeObserver) { + rootResizeObserver.disconnect() + rootResizeObserver = null + } + for (const url of Array.from(objectUrls)) { revokeObjectUrl(url) } @@ -499,6 +581,47 @@ onUnmounted(() => { overflow: hidden; } +.history-buttons { + position: fixed; + top: calc(16px + env(safe-area-inset-top)); + right: calc(16px + env(safe-area-inset-right)); + display: flex; + gap: 6px; + z-index: 9000; +} + +.history-btn { + width: 34px; + height: 34px; + padding: 8px; + border: 1px solid var(--panel-border); + border-radius: 8px; + background: var(--btn-bg); + color: var(--btn-fg); + box-shadow: var(--panel-shadow); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.72; +} + +.history-btn:hover:not(:disabled) { + background-color: var(--btn-hover-bg); + color: var(--btn-hover-fg); + border-color: var(--btn-hover-bg); + opacity: 1; +} + +.history-btn:disabled { + background-color: var(--btn-disabled-bg); + color: var(--btn-disabled-fg); + border-color: var(--btn-disabled-bg); + cursor: not-allowed; + opacity: 0.6; + box-shadow: none; +} + .action-buttons { position: fixed; bottom: 20px; @@ -709,6 +832,7 @@ onUnmounted(() => { } .milkdown-editor { + --editor-tail-space: calc(100vh - 32px); width: 100%; height: 100%; background-color: transparent !important; @@ -740,7 +864,7 @@ onUnmounted(() => { .milkdown-editor :deep(.ProseMirror) { margin: 0 !important; - padding: 0 !important; + padding: 0 0 var(--editor-tail-space) 0 !important; } .milkdown-editor :deep(.ProseMirror img) { @@ -809,6 +933,7 @@ onUnmounted(() => { color: var(--ghost-text); opacity: 0.72; pointer-events: auto; + transition: color 0.12s ease, opacity 0.12s ease, background-color 0.12s ease; } .copilot-ghost-text.copilot-loading { @@ -836,6 +961,7 @@ onUnmounted(() => { .copilot-ghost-block { color: var(--ghost-text); opacity: 0.72; + transition: color 0.12s ease, opacity 0.12s ease, background-color 0.12s ease; } .copilot-ghost-block code, @@ -844,4 +970,9 @@ onUnmounted(() => { color: inherit; opacity: inherit; } + +.copilot-ghost-block pre, +.copilot-ghost-block code { + background-color: var(--ghost-code-bg); +} diff --git a/src/plugins/copilotPlugin.ts b/src/plugins/copilotPlugin.ts index a0ee43b..bda505e 100644 --- a/src/plugins/copilotPlugin.ts +++ b/src/plugins/copilotPlugin.ts @@ -1,9 +1,9 @@ import { Plugin, PluginKey, Selection } from '@milkdown/prose/state' import { $prose, $ctx, $markSchema } from '@milkdown/kit/utils' import { parserCtx, serializerCtx } from '@milkdown/kit/core' -import { Node as ProseNode, DOMParser, DOMSerializer } from '@milkdown/prose/model' +import { Node as ProseNode, Slice } from '@milkdown/prose/model' import type { Ctx } from '@milkdown/kit/core' -import type { EditorView } from '@milkdown/prose/view' +import { Decoration, DecorationSet, type EditorView } from '@milkdown/prose/view' import { getOcrCache, OCR_SIZE_LIMIT, extractTextFromOCR } from '../utils/ocrCache' const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot') @@ -75,31 +75,16 @@ function clearRuntimeRequests(runtime: CopilotRuntime, invalidateRequest = true) } } -function findGhostRangeByMarks(view: EditorView): { from: number; to: number } | null { - const markType = view.state.schema.marks.copilot_ghost - if (!markType) return null - - let from = Number.POSITIVE_INFINITY - let to = -1 - - view.state.doc.descendants((node, pos) => { - if (node.isText && node.marks.some((m: any) => m.type === markType)) { - from = Math.min(from, pos) - to = Math.max(to, pos + node.nodeSize) - } - return true - }) - - if (!Number.isFinite(from) || to <= from) return null - return { from, to } +function getGhostState(view: EditorView): CopilotState | null { + const state = COPILOT_PLUGIN_KEY.getState(view.state) as CopilotState | undefined + if (!state || !state.suggestion || state.from >= state.to) return null + return state } function getGhostRange(view: EditorView): { from: number; to: number } | null { - const state = COPILOT_PLUGIN_KEY.getState(view.state) - if (state && state.from < state.to) { - return { from: state.from, to: state.to } - } - return findGhostRangeByMarks(view) + const state = getGhostState(view) + if (!state) return null + return { from: state.from, to: state.to } } function hasGhostText(view: EditorView): boolean { @@ -110,8 +95,13 @@ function clearGhostText(view: EditorView): boolean { const range = getGhostRange(view) if (!range) return false + const maxPos = view.state.doc.content.size + const from = Math.max(0, Math.min(range.from, maxPos)) + const to = Math.max(from, Math.min(range.to, maxPos)) + if (to <= from) return false + const tr = view.state.tr - .delete(range.from, range.to) + .delete(from, to) .setMeta(COPILOT_PLUGIN_KEY, { ...initialState }) view.dispatch(tr) return true @@ -124,21 +114,40 @@ function getCursorBeforeGhostInsert(tr: any, from: number): number { function insertParsedMarkdownSlice( tr: any, - schema: any, from: number, parsedDoc: ProseNode ): { from: number; to: number } | null { if (parsedDoc.content.size <= 0) return null const insertPos = tr.mapping.map(from, -1) - const dom = DOMSerializer.fromSchema(schema).serializeFragment(parsedDoc.content) - const parsedSlice = DOMParser.fromSchema(schema).parseSlice(dom) + const parsedSlice = Slice.maxOpen(parsedDoc.content) if (!parsedSlice || parsedSlice.size <= 0) return null tr.replaceRange(insertPos, insertPos, parsedSlice) - const endPos = Math.min(insertPos + parsedSlice.size, tr.doc.content.size) - if (endPos <= insertPos) return null - return { from: insertPos, to: endPos } + const startPos = tr.mapping.map(insertPos, -1) + const endPos = tr.mapping.map(insertPos, 1) + if (endPos <= startPos) return null + return { from: startPos, to: endPos } +} + +function createGhostDecorations(doc: ProseNode, from: number, to: number) { + if (to <= from) return DecorationSet.empty + + const decorations = [ + Decoration.inline(from, to, { class: 'copilot-ghost-text', 'data-copilot-ghost': '' }) + ] + + doc.nodesBetween(from, to, (node, pos) => { + if (!node.isBlock) return true + const nodeFrom = pos + const nodeTo = pos + node.nodeSize + if (nodeFrom >= from && nodeTo <= to) { + decorations.push(Decoration.node(nodeFrom, nodeTo, { class: 'copilot-ghost-block' })) + } + return true + }) + + return DecorationSet.create(doc, decorations) } function addGhostMarksToTextNodes(tr: any, from: number, to: number, markType: any) { @@ -184,8 +193,7 @@ function normalizeSuggestionText(raw: string): string { async function insertGhostText(view: EditorView, suggestion: string, from: number, ctx: Ctx) { if (!suggestion) return - const schema = view.state.schema - const markType = schema.marks.copilot_ghost + const markType = view.state.schema.marks.copilot_ghost if (!markType) { console.error('[Copilot] copilot_ghost mark not found in schema') @@ -202,7 +210,7 @@ async function insertGhostText(view: EditorView, suggestion: string, from: numbe } const tr = view.state.tr - const insertedRange = insertParsedMarkdownSlice(tr, schema, from, parsedDoc) + const insertedRange = insertParsedMarkdownSlice(tr, from, parsedDoc) if (!insertedRange) { console.warn('[Copilot] parsed markdown insertion failed, falling back to plain text') @@ -386,8 +394,10 @@ function acceptSuggestion(view: EditorView) { const tr = view.state.tr const doc = tr.doc - const from = range.from - const to = range.to + const maxPos = doc.content.size + const from = Math.max(0, Math.min(range.from, maxPos)) + const to = Math.max(from, Math.min(range.to, maxPos)) + if (to <= from) return false const markType = view.state.schema.marks.copilot_ghost if (!markType) return false @@ -423,14 +433,31 @@ export const copilotPlugin = $prose((ctx) => new Plugin({ return meta } - if (tr.docChanged && value.suggestion) { - return { ...initialState } + if (tr.docChanged && value.suggestion && value.from < value.to) { + const fromResult = tr.mapping.mapResult(value.from, -1) + const toResult = tr.mapping.mapResult(value.to, 1) + const mappedFrom = Math.max(0, fromResult.pos) + const mappedTo = Math.max(mappedFrom, toResult.pos) + if (mappedTo <= mappedFrom) { + return { ...initialState } + } + return { ...value, from: mappedFrom, to: mappedTo } } return value } }, props: { + decorations: (state) => { + const ghost = COPILOT_PLUGIN_KEY.getState(state) as CopilotState | undefined + if (!ghost || !ghost.suggestion || ghost.from >= ghost.to) return null + + const maxPos = state.doc.content.size + const from = Math.max(0, Math.min(ghost.from, maxPos)) + const to = Math.max(from, Math.min(ghost.to, maxPos)) + if (to <= from) return null + return createGhostDecorations(state.doc, from, to) + }, handleKeyDown: (view, event) => { const hasGhost = hasGhostText(view) @@ -450,6 +477,24 @@ export const copilotPlugin = $prose((ctx) => new Plugin({ return false }, + handleTextInput: (view) => { + if (hasGhostText(view)) { + clearGhostText(view) + } + return false + }, + handlePaste: (view) => { + if (hasGhostText(view)) { + clearGhostText(view) + } + return false + }, + handleDrop: (view) => { + if (hasGhostText(view)) { + clearGhostText(view) + } + return false + }, handleClick: (view, pos) => { const range = getGhostRange(view) if (!range) return false @@ -460,6 +505,26 @@ export const copilotPlugin = $prose((ctx) => new Plugin({ clearGhostText(view) return false + }, + handleDOMEvents: { + compositionstart: (view) => { + if (hasGhostText(view)) { + clearGhostText(view) + } + return false + }, + beforeinput: (view, event) => { + if (!hasGhostText(view)) return false + const inputType = (event as InputEvent).inputType || '' + if ( + inputType.startsWith('insert') || + inputType.startsWith('delete') || + inputType.startsWith('format') + ) { + clearGhostText(view) + } + return false + } } }, view: (view) => { @@ -540,6 +605,16 @@ export const copilotPlugin = $prose((ctx) => new Plugin({ return } + const prevGhost = COPILOT_PLUGIN_KEY.getState(prevState) as CopilotState | undefined + const nextGhost = COPILOT_PLUGIN_KEY.getState(nextView.state) as CopilotState | undefined + const prevHasGhost = Boolean(prevGhost?.suggestion && prevGhost.from < prevGhost.to) + const nextHasGhost = Boolean(nextGhost?.suggestion && nextGhost.from < nextGhost.to) + if (docChanged && prevHasGhost && nextHasGhost) { + clearGhostText(nextView) + clearRuntimeRequests(runtime) + return + } + const ghostRange = getGhostRange(nextView) if (ghostRange) { const { from, to } = nextView.state.selection @@ -586,10 +661,15 @@ export function setCopilotEnabled(view: EditorView, value: boolean): void { } } +export function interruptCopilot(view: EditorView): void { + const runtime = runtimeByView.get(view) + if (!runtime) return + clearRuntimeRequests(runtime) +} + export function checkSizeLimit(view: EditorView): { size: number; overLimit: boolean } { const size = view.state.doc.content.size return { size, overLimit: size > SIZE_LIMIT } } export { SIZE_LIMIT } -