feat(api): add system prompt support for LLM completion
Separate prompt generation into system and user prompts for better LLM instruction following. Backend now builds a detailed system prompt with constraints for math formatting, code block handling, boundary newlines, and OCR safety, while user prompt contains context and completion state flags. Added corresponding tests for both modules.
This commit is contained in:
+16
-3
@@ -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,
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+256
-71
@@ -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 <br>, <br/>, <br\>.
|
||||
Filter out potential web-scraping or legacy artifacts like <br>, <br/>, <br\\>.
|
||||
"""
|
||||
import re
|
||||
br_pattern = re.compile(r'<br\s*/?\s*\\?>', re.IGNORECASE)
|
||||
clean_prefix = br_pattern.sub('', prefix or "")
|
||||
clean_suffix = br_pattern.sub('', suffix or "")
|
||||
br_pattern = re.compile(r"<br\s*/?\s*\\?>", 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 <OCR:...>.
|
||||
- Never output any OCR tag.
|
||||
- Never output strings containing <OCR: or > as OCR artifacts."""
|
||||
return system_prompt.strip()
|
||||
|
||||
|
||||
INLINE_EXAMPLES = """[EX01] Prose continuation
|
||||
<PREFIX>The quick brown fox </PREFIX>
|
||||
<SUFFIX>jumps over the lazy dog.</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
moved quietly and then
|
||||
|
||||
[EX02] Avoid repeating suffix beginning
|
||||
<PREFIX>Our launch plan starts with </PREFIX>
|
||||
<SUFFIX>phase one, followed by phase two.</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
careful internal testing before
|
||||
|
||||
[EX03] Continue markdown checklist
|
||||
<PREFIX>## TODO
|
||||
- [ ] Buy milk
|
||||
- [ ] </PREFIX>
|
||||
<SUFFIX></SUFFIX>
|
||||
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
|
||||
<PREFIX>Parse this JSON payload in Python:</PREFIX>
|
||||
<SUFFIX></SUFFIX>
|
||||
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
|
||||
<PREFIX>```python
|
||||
def add(a, b):
|
||||
return </PREFIX>
|
||||
<SUFFIX>
|
||||
```</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
a + b
|
||||
|
||||
[EX06] Inline math must use $...$
|
||||
<PREFIX>The derivative of x^2 is </PREFIX>
|
||||
<SUFFIX>.</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
$2x$
|
||||
|
||||
[EX07] Block math must use $$...$$
|
||||
<PREFIX>We can write the Gaussian integral as:</PREFIX>
|
||||
<SUFFIX></SUFFIX>
|
||||
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
|
||||
<PREFIX>Deployment steps:</PREFIX>
|
||||
<SUFFIX></SUFFIX>
|
||||
Expected OUTPUT:
|
||||
|
||||
- Build artifact
|
||||
- Deploy service
|
||||
|
||||
[EX09] Suffix misses boundary newline; add newline at output end
|
||||
SUFFIX_STARTS_WITH_NEWLINE=false
|
||||
<PREFIX>Summary paragraph complete.</PREFIX>
|
||||
<SUFFIX>## Next Section</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
|
||||
|
||||
[EX10] OCR metadata exists but must never be emitted
|
||||
<PREFIX> <OCR:equation y = mx + b>
|
||||
The relationship is </PREFIX>
|
||||
<SUFFIX>.</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
$y = mx + b$
|
||||
|
||||
[EX11] Continue markdown table with correct row shape
|
||||
<PREFIX>| Name | Score |
|
||||
| --- | --- |
|
||||
| Alice | 92 |
|
||||
| Bob | </PREFIX>
|
||||
<SUFFIX></SUFFIX>
|
||||
Expected OUTPUT:
|
||||
88 |
|
||||
|
||||
[EX12] Mixed text + math + code in one insertion
|
||||
CURSOR_IN_FENCED_CODE_BLOCK=false
|
||||
<PREFIX>Use the area formula and provide a tiny JS helper.</PREFIX>
|
||||
<SUFFIX></SUFFIX>
|
||||
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.  <OCR:description>.
|
||||
- The <OCR:...> is hidden context describing image content.
|
||||
- Never copy, rewrite, or emit OCR tags in output.
|
||||
- Never output <OCR: or >.
|
||||
|
||||
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:description>.
|
||||
- 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:
|
||||
<PREFIX>The quick brown fox </PREFIX>
|
||||
<SUFFIX>jumps over the lazy dog.</SUFFIX>
|
||||
Output: "moved quietly and then "
|
||||
|
||||
<PREFIX>## TODO\\n- [ ] Buy milk\\n- [ ] </PREFIX>
|
||||
<SUFFIX></SUFFIX>
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -2,6 +2,35 @@
|
||||
<div class="editor-container">
|
||||
<div ref="root" class="milkdown-editor"></div>
|
||||
|
||||
<div class="history-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="history-btn"
|
||||
:disabled="!canUndo"
|
||||
:aria-label="undoLabel"
|
||||
:title="undoLabel"
|
||||
@click="handleUndo"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 14 4 9l5-5"/>
|
||||
<path d="M4 9h11a4 4 0 1 1 0 8h-1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="history-btn"
|
||||
:disabled="!canRedo"
|
||||
:aria-label="redoLabel"
|
||||
:title="redoLabel"
|
||||
@click="handleRedo"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="m15 14 5-5-5-5"/>
|
||||
<path d="M20 9H9a4 4 0 1 0 0 8h1"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
@@ -106,7 +135,8 @@ import { replaceAll } from '@milkdown/kit/utils'
|
||||
import { Crepe } from '@milkdown/crepe'
|
||||
import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
|
||||
import { Selection } from '@milkdown/prose/state'
|
||||
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, COPILOT_PLUGIN_KEY, SIZE_LIMIT, checkSizeLimit, clearGhostSuggestion } from '../plugins/copilotPlugin'
|
||||
import { undo, redo, undoDepth, redoDepth } from '@milkdown/prose/history'
|
||||
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, interruptCopilot, COPILOT_PLUGIN_KEY, SIZE_LIMIT, checkSizeLimit, clearGhostSuggestion } from '../plugins/copilotPlugin'
|
||||
import { fetchSuggestion } from '../utils/api.js'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { OCR_URL } from '../utils/config.js'
|
||||
@@ -124,8 +154,12 @@ const contentSize = ref(0)
|
||||
const showImageDropdown = ref(false)
|
||||
const showUrlDialog = ref(false)
|
||||
const imageUrl = ref('')
|
||||
const canUndo = ref(false)
|
||||
const canRedo = ref(false)
|
||||
const isOverLimit = computed(() => 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);
|
||||
}
|
||||
</style>
|
||||
|
||||
+119
-39
@@ -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<CopilotState>({
|
||||
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<CopilotState>({
|
||||
|
||||
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<CopilotState>({
|
||||
|
||||
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<CopilotState>({
|
||||
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 }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user