feat(api): add completion request cancellation and mermaid rendering

Add support for cancelling in-progress LLM completion requests via new /v1/completions/cancel endpoint with task tracking. Implement mermaid diagram rendering in the Milkdown editor with a new mermaidPlugin. Update copilotPlugin to properly abort requests with descriptive reasons. Refactor settings panel to handle system theme changes reactively. Add camera capture support for image uploads.
This commit is contained in:
2026-02-25 19:00:17 +08:00
parent e28125079c
commit 637456ee34
13 changed files with 2013 additions and 147 deletions
+12
View File
@@ -1,6 +1,7 @@
import os import os
import time import time
import logging import logging
import asyncio
from datetime import datetime from datetime import datetime
import ollama import ollama
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -97,6 +98,17 @@ async def call_ollama(
kwargs["think"] = thinking kwargs["think"] = thinking
response = await client.chat(**kwargs) response = await client.chat(**kwargs)
except asyncio.CancelledError:
elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now()
logger.info(
"[LLM][%s] call_time [%s --> %s]",
tag,
start_dt.strftime("%H:%M:%S"),
end_dt.strftime("%H:%M:%S"),
)
logger.warning("[LLM][%s] request cancelled after %.1fms", tag, elapsed_ms)
raise
except Exception: except Exception:
elapsed_ms = (time.perf_counter() - start) * 1000 elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now() end_dt = datetime.now()
+137 -52
View File
@@ -1,16 +1,19 @@
from fastapi import FastAPI, Request, HTTPException, Security import asyncio
from fastapi.security import APIKeyHeader
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse, JSONResponse
from pydantic import BaseModel
import json
import base64 import base64
import uuid import json
import logging import logging
import uuid
from typing import Optional
from fastapi import FastAPI, HTTPException, Request, Security
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.security import APIKeyHeader
from pydantic import BaseModel
from prompt import build_completion_prompts, prepare_prompt_context
from llm import call_ollama, call_vlm_ocr
from geoip import get_ip_location_text from geoip import get_ip_location_text
from llm import call_ollama, call_vlm_ocr
from prompt import build_completion_prompts, prepare_prompt_context
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -20,44 +23,54 @@ logger = logging.getLogger("api")
app = FastAPI() app = FastAPI()
ACTIVE_COMPLETIONS: dict[str, asyncio.Task] = {}
ACTIVE_COMPLETIONS_LOCK = asyncio.Lock()
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*", "X-API-Key", "X-Client-IP"], allow_headers=["*", "X-API-Key", "X-Client-IP", "X-Request-Id"],
) )
API_KEY = "your-secret-key-here" # 建议从环境变量读取 API_KEY = "your-secret-key-here"
api_key_header = APIKeyHeader(name="X-API-Key") api_key_header = APIKeyHeader(name="X-API-Key")
async def get_api_key(api_key: str = Security(api_key_header)): async def get_api_key(api_key: str = Security(api_key_header)):
if api_key != API_KEY: if api_key != API_KEY:
raise HTTPException( raise HTTPException(
status_code=403, status_code=403,
detail="Could not validate credentials" detail="Could not validate credentials",
) )
return api_key return api_key
from typing import Optional
class UserPreferences(BaseModel): class UserPreferences(BaseModel):
language: str = 'auto' language: str = "auto"
currency: str = 'auto' currency: str = "auto"
timezone: str = 'auto' timezone: str = "auto"
class CompletionRequest(BaseModel): class CompletionRequest(BaseModel):
prefix: str prefix: str
suffix: str suffix: str
languageId: str = 'markdown' languageId: str = "markdown"
model_thinking: str = 'low' model_thinking: str = "low"
privacy_mode: bool = False privacy_mode: bool = False
user_preferences: Optional[UserPreferences] = None user_preferences: Optional[UserPreferences] = None
class CancelCompletionRequest(BaseModel):
request_id: str
reason: str = "abort"
class OCRRequest(BaseModel): class OCRRequest(BaseModel):
image: str image: str
filename: str = "image.jpg" filename: str = "image.jpg"
language: str = 'auto' language: str = "auto"
def _preview(text: str, limit: int = 80) -> str: def _preview(text: str, limit: int = 80) -> str:
@@ -66,73 +79,143 @@ def _preview(text: str, limit: int = 80) -> str:
return value return value
return value[:limit] + "..." return value[:limit] + "..."
def _sse_payload(payload: dict) -> str:
return f"data: {json.dumps(payload)}\n\n"
def get_client_ip(request: Request) -> str: def get_client_ip(request: Request) -> str:
return request.headers.get("X-Client-IP") or request.client.host if request.client else "unknown" if request.client:
return request.headers.get("X-Client-IP") or request.client.host
return request.headers.get("X-Client-IP") or "unknown"
@app.post("/v1/completions") @app.post("/v1/completions")
async def create_completion(request: Request, req: CompletionRequest, api_key: str = Security(get_api_key)): async def create_completion(request: Request, req: CompletionRequest, api_key: str = Security(get_api_key)):
request_id = str(uuid.uuid4())[:8] request_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
request_tag = request_id[:8]
inference_task: Optional[asyncio.Task] = None
client_ip = "hidden" client_ip = "hidden"
location = "" location = ""
if not req.privacy_mode: if not req.privacy_mode:
client_ip = get_client_ip(request) client_ip = get_client_ip(request)
# 查询 IP 归属地
location = get_ip_location_text(client_ip) location = get_ip_location_text(client_ip)
if location: if location:
logger.info("[%s] client_location=%s", request_id, location) logger.info("[%s] client_location=%s", request_tag, location)
try: try:
logger.info( logger.info(
"[%s] /v1/completions client_ip=%s prefix_chars=%d suffix_chars=%d lang=%s thinking=%s privacy=%s", "[%s] /v1/completions request_id=%s client_ip=%s prefix_chars=%d suffix_chars=%d lang=%s thinking=%s privacy=%s",
request_tag,
request_id, request_id,
client_ip, client_ip,
len(req.prefix or ""), len(req.prefix or ""),
len(req.suffix or ""), len(req.suffix or ""),
req.languageId, req.languageId,
req.model_thinking, req.model_thinking,
req.privacy_mode req.privacy_mode,
)
llm_prefix, llm_suffix = prepare_prompt_context(req.prefix or "", req.suffix or "")
logger.info("[%s] llm_input_prefix=%r", request_id, llm_prefix)
logger.info("[%s] llm_input_suffix=%r", request_id, llm_suffix)
system_prompt, user_prompt = build_completion_prompts(
req.prefix,
req.suffix,
req.languageId,
location=location,
thinking_level=req.model_thinking,
preferences=req.user_preferences
)
result = await call_ollama(
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
) )
llm_prefix, llm_suffix = prepare_prompt_context(req.prefix or "", req.suffix or "")
logger.info("[%s] llm_input_prefix=%r", request_tag, llm_prefix)
logger.info("[%s] llm_input_suffix=%r", request_tag, llm_suffix)
system_prompt, user_prompt = build_completion_prompts(
req.prefix,
req.suffix,
req.languageId,
location=location,
thinking_level=req.model_thinking,
preferences=req.user_preferences,
)
inference_task = asyncio.create_task(
call_ollama(
user_prompt,
system_prompt=system_prompt,
tag=f"{request_tag}-primary",
temperature=0.7,
thinking=req.model_thinking if req.model_thinking != "none" else None,
)
)
async with ACTIVE_COMPLETIONS_LOCK:
existing = ACTIVE_COMPLETIONS.get(request_id)
if existing and not existing.done():
existing.cancel()
ACTIVE_COMPLETIONS[request_id] = inference_task
result = await inference_task
content = result["content"] or "" content = result["content"] or ""
if not content.strip(): if not content.strip():
logger.warning("[%s] primary returned empty content, returning empty result", request_id) logger.warning("[%s] primary returned empty content, returning empty result", request_tag)
logger.info( logger.info(
"[%s] completion resolved source=primary content_chars=%d content_preview='%s'", "[%s] completion resolved source=primary request_id=%s content_chars=%d content_preview='%s'",
request_tag,
request_id, request_id,
len(content), len(content),
_preview(content, 120), _preview(content, 120),
) )
async def generate(): async def generate():
yield f"data: {json.dumps({'content': content})}\n\n" yield _sse_payload({"content": content})
yield f"data: {json.dumps({'done': True})}\n\n" yield _sse_payload({"done": True})
return StreamingResponse(generate(), media_type="text/event-stream") return StreamingResponse(generate(), media_type="text/event-stream")
except asyncio.CancelledError:
logger.info("[%s] /v1/completions cancelled request_id=%s", request_tag, request_id)
async def cancelled():
yield _sse_payload({"cancelled": True, "request_id": request_id, "done": True})
return StreamingResponse(cancelled(), media_type="text/event-stream")
except Exception as e: except Exception as e:
logger.exception("[%s] /v1/completions failed: %s", request_id, e) logger.exception("[%s] /v1/completions failed request_id=%s: %s", request_tag, request_id, e)
return JSONResponse(content={"error": str(e)}, status_code=500) return JSONResponse(content={"error": str(e)}, status_code=500)
finally:
async with ACTIVE_COMPLETIONS_LOCK:
active = ACTIVE_COMPLETIONS.get(request_id)
if active is not None and active is inference_task:
ACTIVE_COMPLETIONS.pop(request_id, None)
@app.post("/v1/completions/cancel")
async def cancel_completion(req: CancelCompletionRequest, api_key: str = Security(get_api_key)):
request_tag = str(uuid.uuid4())[:8]
request_id = req.request_id or ""
async with ACTIVE_COMPLETIONS_LOCK:
task = ACTIVE_COMPLETIONS.get(request_id)
if task is None:
logger.info(
"[%s] /v1/completions/cancel request_id=%s status=not_found reason=%s",
request_tag,
request_id,
req.reason,
)
return {"cancelled": False, "status": "not_found"}
if task.done():
logger.info(
"[%s] /v1/completions/cancel request_id=%s status=already_done reason=%s",
request_tag,
request_id,
req.reason,
)
return {"cancelled": False, "status": "already_done"}
task.cancel()
logger.info(
"[%s] /v1/completions/cancel request_id=%s status=ok reason=%s",
request_tag,
request_id,
req.reason,
)
return {"cancelled": True, "status": "ok"}
@app.post("/v1/ocr") @app.post("/v1/ocr")
async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)): async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
@@ -159,6 +242,8 @@ async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
logger.exception("[%s] /v1/ocr failed: %s", request_id, e) logger.exception("[%s] /v1/ocr failed: %s", request_id, e)
return JSONResponse(content={"error": str(e)}, status_code=500) return JSONResponse(content={"error": str(e)}, status_code=500)
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001) uvicorn.run(app, host="0.0.0.0", port=8001)
+94 -7
View File
@@ -63,6 +63,14 @@ def _prepare_context(prefix: str, suffix: str) -> Tuple[str, str]:
FENCE_LINE_RE = re.compile(r"^[ \t]*```.*$") FENCE_LINE_RE = re.compile(r"^[ \t]*```.*$")
FENCE_INFO_RE = re.compile(r"^[ \t]*```[ \t]*(.*)$")
MERMAID_CONTEXT_RE = re.compile(
r"```[ \t]*mermaid\b|"
r"\b(flowchart|sequencediagram|classdiagram|statediagram(?:-v2)?|"
r"erdiagram|journey|gantt|pie|mindmap|timeline|gitgraph|quadrantchart|xychart-beta)\b|"
r"\bgraph[ \t]+(TD|TB|BT|RL|LR)\b",
re.IGNORECASE,
)
def _cursor_in_fenced_code_block(prefix: str) -> bool: def _cursor_in_fenced_code_block(prefix: str) -> bool:
@@ -71,12 +79,48 @@ def _cursor_in_fenced_code_block(prefix: str) -> bool:
The state is computed by toggling on each markdown fence line that matches: The state is computed by toggling on each markdown fence line that matches:
^[ \t]*```.*$ ^[ \t]*```.*$
""" """
return _active_fence_language(prefix) != "none"
def _active_fence_language(prefix: str) -> str:
"""
Return active fence language at cursor based on prefix.
- "none": cursor is outside fenced code block
- "unknown": cursor is inside a fence without language tag
- "<language>": cursor is inside a fenced block with language tag
"""
normalized = _normalize_newlines(prefix) normalized = _normalize_newlines(prefix)
in_fence = False in_fence = False
active_language = "none"
for line in normalized.split("\n"): for line in normalized.split("\n"):
if FENCE_LINE_RE.match(line): if FENCE_LINE_RE.match(line):
in_fence = not in_fence if in_fence:
return in_fence in_fence = False
active_language = "none"
else:
info_match = FENCE_INFO_RE.match(line)
info = info_match.group(1).strip() if info_match else ""
if not info:
active_language = "unknown"
else:
first_token = info.split()[0]
lang_chars = []
for ch in first_token.strip():
if ch.isalnum() or ch in "-_+.":
lang_chars.append(ch)
active_language = "".join(lang_chars)[:32].lower() or "unknown"
in_fence = True
return active_language if in_fence else "none"
def _is_mermaid_context(prefix: str, suffix: str, cursor_fence_language: str) -> bool:
if cursor_fence_language == "mermaid":
return True
prefix_tail = (prefix or "")[-1200:]
suffix_head = (suffix or "")[:400]
combined = f"{prefix_tail}\n{suffix_head}"
return MERMAID_CONTEXT_RE.search(combined) is not None
def prepare_prompt_context(prefix: str, suffix: str) -> Tuple[str, str]: def prepare_prompt_context(prefix: str, suffix: str) -> Tuple[str, str]:
@@ -113,22 +157,36 @@ Hard constraints you must follow:
- Do not output code snippets as inline backticks. - Do not output code snippets as inline backticks.
- Choose the language tag from context (no default fallback tag instruction). - Choose the language tag from context (no default fallback tag instruction).
4) Boundary newline repair: 4) Mermaid-specific completion rules:
- Read CURSOR_FENCE_LANGUAGE and MERMAID_CONTEXT from the user prompt.
- If CURSOR_FENCE_LANGUAGE=mermaid:
- Output Mermaid statements only.
- Never output triple backticks.
- Never output prose explanations.
- If CURSOR_IN_FENCED_CODE_BLOCK=false and MERMAID_CONTEXT=true:
- Output a complete Mermaid fenced block:
```mermaid
...
```
- Keep Mermaid syntax valid and concise.
- Never mix Mermaid code and explanatory narration in one output.
5) Boundary newline repair:
- Read PREFIX_ENDS_WITH_NEWLINE and SUFFIX_STARTS_WITH_NEWLINE from the user prompt. - 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. - 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 PREFIX lacks a required boundary newline, add it at OUTPUT start.
- If SUFFIX lacks a required boundary newline, add it at OUTPUT end. - If SUFFIX lacks a required boundary newline, add it at OUTPUT end.
- Ensure PREFIX + OUTPUT + SUFFIX is structurally natural. - Ensure PREFIX + OUTPUT + SUFFIX is structurally natural.
5) Context stitching: 6) Context stitching:
- Do not repeat text that already appears at the start of SUFFIX. - Do not repeat text that already appears at the start of SUFFIX.
- Preserve nearby language, tone, punctuation, indentation, and markdown structure. - Preserve nearby language, tone, punctuation, indentation, and markdown structure.
- Continue existing structures naturally (lists, tables, block quotes, headings). - Continue existing structures naturally (lists, tables, block quotes, headings).
6) OCR safety: 7) OCR safety:
- PREFIX may include hidden OCR metadata tags like <OCR:...>. - PREFIX may include hidden OCR metadata tags like <OCR:...>.
- Never output any OCR tag. - Never output any OCR tag.
- Never output strings containing <OCR: or > as OCR artifacts.""" - Never output OCR tag fragments such as <OCR:...>."""
return system_prompt.strip() return system_prompt.strip()
@@ -227,6 +285,29 @@ The area is $A = \\pi r^2$.
```javascript ```javascript
const area = (r) => Math.PI * r * r; const area = (r) => Math.PI * r * r;
```
[EX13] Cursor inside mermaid fence: no backticks, mermaid lines only
CURSOR_IN_FENCED_CODE_BLOCK=true
CURSOR_FENCE_LANGUAGE=mermaid
<PREFIX>```mermaid
flowchart TD
A[Start] --> </PREFIX>
<SUFFIX>
```</SUFFIX>
Expected OUTPUT:
B{Valid?}
B -->|Yes| C[Done]
[EX14] Mermaid context outside fence: return full mermaid block
CURSOR_IN_FENCED_CODE_BLOCK=false
MERMAID_CONTEXT=true
<PREFIX>Please provide a simple release pipeline diagram.</PREFIX>
<SUFFIX></SUFFIX>
Expected OUTPUT:
```mermaid
flowchart LR
Build --> Test --> Deploy
```""" ```"""
@@ -243,7 +324,11 @@ def build_completion_prompts(
recent_prefix = _normalize_newlines(recent_prefix) recent_prefix = _normalize_newlines(recent_prefix)
recent_suffix = _normalize_newlines(recent_suffix) recent_suffix = _normalize_newlines(recent_suffix)
cursor_in_fenced_code_block = _cursor_in_fenced_code_block(recent_prefix) cursor_fence_language = _active_fence_language(recent_prefix)
cursor_in_fenced_code_block = cursor_fence_language != "none"
mermaid_context = _is_mermaid_context(
recent_prefix, recent_suffix, cursor_fence_language
)
prefix_ends_with_newline = recent_prefix.endswith("\n") prefix_ends_with_newline = recent_prefix.endswith("\n")
suffix_starts_with_newline = recent_suffix.startswith("\n") suffix_starts_with_newline = recent_suffix.startswith("\n")
@@ -268,6 +353,8 @@ Editor language id: {safe_language_id}
Completion state flags: Completion state flags:
- CURSOR_IN_FENCED_CODE_BLOCK: {"true" if cursor_in_fenced_code_block else "false"} - CURSOR_IN_FENCED_CODE_BLOCK: {"true" if cursor_in_fenced_code_block else "false"}
- CURSOR_FENCE_LANGUAGE: {cursor_fence_language}
- MERMAID_CONTEXT: {"true" if mermaid_context else "false"}
- PREFIX_ENDS_WITH_NEWLINE: {"true" if prefix_ends_with_newline 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"} - SUFFIX_STARTS_WITH_NEWLINE: {"true" if suffix_starts_with_newline else "false"}
+118
View File
@@ -0,0 +1,118 @@
import asyncio
import importlib
import sys
import threading
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
try:
main = importlib.import_module("main")
except ModuleNotFoundError:
pytest.skip("main module dependencies are not available", allow_module_level=True)
API_KEY_HEADERS = {"X-API-Key": "your-secret-key-here"}
def _completion_payload():
return {
"prefix": "hello",
"suffix": "",
"languageId": "markdown",
"model_thinking": "low",
"privacy_mode": True,
}
def test_cancel_endpoint_cancels_running_task(monkeypatch):
main.ACTIVE_COMPLETIONS.clear()
started = threading.Event()
cancelled = threading.Event()
async def fake_call_ollama(*args, **kwargs):
started.set()
try:
while True:
await asyncio.sleep(0.05)
except asyncio.CancelledError:
cancelled.set()
raise
monkeypatch.setattr(main, "call_ollama", fake_call_ollama)
monkeypatch.setattr(main, "build_completion_prompts", lambda *a, **k: ("system", "user"))
monkeypatch.setattr(main, "prepare_prompt_context", lambda *a, **k: ("prefix", "suffix"))
with TestClient(main.app) as client:
request_id = "req-cancel-1"
completion_headers = {**API_KEY_HEADERS, "X-Request-Id": request_id}
response_box = {}
def send_completion():
response_box["response"] = client.post(
"/v1/completions",
headers=completion_headers,
json=_completion_payload(),
)
completion_thread = threading.Thread(target=send_completion, daemon=True)
completion_thread.start()
assert started.wait(timeout=2.0)
cancel_response = client.post(
"/v1/completions/cancel",
headers=API_KEY_HEADERS,
json={"request_id": request_id, "reason": "superseded"},
)
assert cancel_response.status_code == 200
assert cancel_response.json() == {"cancelled": True, "status": "ok"}
completion_thread.join(timeout=5.0)
assert not completion_thread.is_alive()
assert cancelled.wait(timeout=2.0)
completion_response = response_box["response"]
assert completion_response.status_code == 200
assert '"cancelled": true' in completion_response.text
def test_cancel_not_found():
main.ACTIVE_COMPLETIONS.clear()
with TestClient(main.app) as client:
response = client.post(
"/v1/completions/cancel",
headers=API_KEY_HEADERS,
json={"request_id": "missing", "reason": "abort"},
)
assert response.status_code == 200
assert response.json() == {"cancelled": False, "status": "not_found"}
def test_completion_normal_flow(monkeypatch):
main.ACTIVE_COMPLETIONS.clear()
async def fake_call_ollama(*args, **kwargs):
return {"content": "completion text", "think": ""}
monkeypatch.setattr(main, "call_ollama", fake_call_ollama)
monkeypatch.setattr(main, "build_completion_prompts", lambda *a, **k: ("system", "user"))
monkeypatch.setattr(main, "prepare_prompt_context", lambda *a, **k: ("prefix", "suffix"))
with TestClient(main.app) as client:
response = client.post(
"/v1/completions",
headers=API_KEY_HEADERS,
json=_completion_payload(),
)
assert response.status_code == 200
assert '"content": "completion text"' in response.text
assert '"done": true' in response.text
assert main.ACTIVE_COMPLETIONS == {}
+36 -1
View File
@@ -21,7 +21,13 @@ def test_prompt_builds_system_and_user():
assert "$...$" in system_prompt assert "$...$" in system_prompt
assert "$$...$$" in system_prompt assert "$$...$$" in system_prompt
assert "```{language}" in system_prompt assert "```{language}" in system_prompt
assert "Mermaid-specific completion rules" in system_prompt
assert "CURSOR_FENCE_LANGUAGE" in system_prompt
assert "MERMAID_CONTEXT" in system_prompt
assert "Output Mermaid statements only." in system_prompt
assert "CURSOR_IN_FENCED_CODE_BLOCK" in user_prompt assert "CURSOR_IN_FENCED_CODE_BLOCK" in user_prompt
assert "CURSOR_FENCE_LANGUAGE" in user_prompt
assert "MERMAID_CONTEXT" in user_prompt
assert "PREFIX_ENDS_WITH_NEWLINE" in user_prompt assert "PREFIX_ENDS_WITH_NEWLINE" in user_prompt
assert "SUFFIX_STARTS_WITH_NEWLINE" in user_prompt assert "SUFFIX_STARTS_WITH_NEWLINE" in user_prompt
@@ -33,12 +39,22 @@ def test_cursor_in_fence_detection():
assert prompt._cursor_in_fenced_code_block("text ```not-a-fence``` tail") is False assert prompt._cursor_in_fenced_code_block("text ```not-a-fence``` tail") is False
def test_active_fence_language_detection():
assert prompt._active_fence_language("") == "none"
assert prompt._active_fence_language("```mermaid\nflowchart TD\nA-->B\n") == "mermaid"
assert prompt._active_fence_language("```python\nprint('x')\n") == "python"
assert prompt._active_fence_language("```\nline\n") == "unknown"
assert prompt._active_fence_language("```mermaid\nA-->B\n```\n") == "none"
def test_newline_flags(): def test_newline_flags():
_, user_prompt_a = prompt.build_completion_prompts( _, user_prompt_a = prompt.build_completion_prompts(
prefix="Hello", prefix="Hello",
suffix="World", suffix="World",
) )
assert "CURSOR_IN_FENCED_CODE_BLOCK: false" in user_prompt_a assert "CURSOR_IN_FENCED_CODE_BLOCK: false" in user_prompt_a
assert "CURSOR_FENCE_LANGUAGE: none" in user_prompt_a
assert "MERMAID_CONTEXT: false" in user_prompt_a
assert "PREFIX_ENDS_WITH_NEWLINE: 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 assert "SUFFIX_STARTS_WITH_NEWLINE: false" in user_prompt_a
@@ -46,11 +62,30 @@ def test_newline_flags():
prefix="Hello\n", prefix="Hello\n",
suffix="\nWorld", suffix="\nWorld",
) )
assert "CURSOR_FENCE_LANGUAGE: none" in user_prompt_b
assert "PREFIX_ENDS_WITH_NEWLINE: true" in user_prompt_b assert "PREFIX_ENDS_WITH_NEWLINE: true" in user_prompt_b
assert "SUFFIX_STARTS_WITH_NEWLINE: true" in user_prompt_b assert "SUFFIX_STARTS_WITH_NEWLINE: true" in user_prompt_b
def test_mermaid_context_flags():
_, prompt_in_mermaid = prompt.build_completion_prompts(
prefix="```mermaid\nflowchart TD\nA --> ",
suffix="\n```",
)
assert "CURSOR_IN_FENCED_CODE_BLOCK: true" in prompt_in_mermaid
assert "CURSOR_FENCE_LANGUAGE: mermaid" in prompt_in_mermaid
assert "MERMAID_CONTEXT: true" in prompt_in_mermaid
_, prompt_mermaid_keyword = prompt.build_completion_prompts(
prefix="Please draw a mermaid flowchart for deploy pipeline.",
suffix="",
)
assert "CURSOR_IN_FENCED_CODE_BLOCK: false" in prompt_mermaid_keyword
assert "CURSOR_FENCE_LANGUAGE: none" in prompt_mermaid_keyword
assert "MERMAID_CONTEXT: true" in prompt_mermaid_keyword
def test_examples_coverage(): def test_examples_coverage():
_, user_prompt = prompt.build_completion_prompts(prefix="", suffix="") _, user_prompt = prompt.build_completion_prompts(prefix="", suffix="")
for ex in range(1, 13): for ex in range(1, 15):
assert f"[EX{ex:02d}]" in user_prompt assert f"[EX{ex:02d}]" in user_prompt
+1192 -2
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -17,6 +17,7 @@
"katex": "^0.16.9", "katex": "^0.16.9",
"markdown-it": "^13.0.0", "markdown-it": "^13.0.0",
"markdown-it-math": "^3.0.2", "markdown-it-math": "^3.0.2",
"mermaid": "^11.12.3",
"pinia": "^2.3.1", "pinia": "^2.3.1",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"vue": "^3.5.24", "vue": "^3.5.24",
+113 -43
View File
@@ -1,4 +1,4 @@
<template> <template>
<div class="editor-container"> <div class="editor-container">
<div ref="root" class="milkdown-editor"></div> <div ref="root" class="milkdown-editor"></div>
@@ -46,7 +46,7 @@
</svg> </svg>
<span class="btn-tooltip">{{ t('importMd') }}</span> <span class="btn-tooltip">{{ t('importMd') }}</span>
</button> </button>
<input type="file" ref="fileInputRef" @change="handleFileUpload" accept=".md" style="display:none"> <input type="file" ref="fileInputRef" @change="handleFileUpload" accept=".md,text/markdown,text/x-markdown" style="display:none">
<button <button
type="button" type="button"
@@ -79,11 +79,13 @@
<span class="btn-tooltip">{{ t('uploadImg') }}</span> <span class="btn-tooltip">{{ t('uploadImg') }}</span>
</button> </button>
<div v-if="showImageDropdown" class="image-dropdown"> <div v-if="showImageDropdown" class="image-dropdown">
<button v-if="supportsCameraCapture" type="button" @click="triggerCameraCapture">{{ cameraUploadLabel }}</button>
<button type="button" @click="triggerImageUpload">{{ t('uploadImg') }}</button> <button type="button" @click="triggerImageUpload">{{ t('uploadImg') }}</button>
<button type="button" @click="showUrlDialog = true; showImageDropdown = false">{{ t('insertUrl') }}</button> <button type="button" @click="showUrlDialog = true; showImageDropdown = false">{{ t('insertUrl') }}</button>
</div> </div>
</div> </div>
<input type="file" ref="imageInputRef" @change="handleImageUpload" accept="image/*" style="display:none"> <input type="file" ref="imageInputRef" @change="handleImageUpload" accept="image/*" style="display:none">
<input type="file" ref="cameraInputRef" @change="handleImageUpload" accept="image/*" capture="environment" style="display:none">
<button <button
type="button" type="button"
@@ -137,6 +139,7 @@ import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
import { Selection } from '@milkdown/prose/state' import { Selection } from '@milkdown/prose/state'
import { undo, redo, undoDepth, redoDepth } from '@milkdown/prose/history' 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 { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, interruptCopilot, COPILOT_PLUGIN_KEY, SIZE_LIMIT, checkSizeLimit, clearGhostSuggestion } from '../plugins/copilotPlugin'
import { mermaidRenderPreview, codeBlockConfig } from '../plugins/mermaidPlugin'
import { fetchSuggestion } from '../utils/api.js' import { fetchSuggestion } from '../utils/api.js'
import { useSettingsStore } from '../stores/settings' import { useSettingsStore } from '../stores/settings'
import { OCR_URL } from '../utils/config.js' import { OCR_URL } from '../utils/config.js'
@@ -149,6 +152,7 @@ const t = (key) => settings.t[key]
const root = ref(null) const root = ref(null)
const fileInputRef = ref(null) const fileInputRef = ref(null)
const imageInputRef = ref(null) const imageInputRef = ref(null)
const cameraInputRef = ref(null)
const aiEnabled = ref(true) const aiEnabled = ref(true)
const contentSize = ref(0) const contentSize = ref(0)
const showImageDropdown = ref(false) const showImageDropdown = ref(false)
@@ -160,6 +164,12 @@ const isOverLimit = computed(() => contentSize.value > SIZE_LIMIT)
const sizeInKB = computed(() => Math.floor(contentSize.value / 1024)) const sizeInKB = computed(() => Math.floor(contentSize.value / 1024))
const undoLabel = computed(() => t('undo') || 'Undo') const undoLabel = computed(() => t('undo') || 'Undo')
const redoLabel = computed(() => t('redo') || 'Redo') const redoLabel = computed(() => t('redo') || 'Redo')
const cameraUploadLabel = computed(() => t('cameraUpload') || 'Use Camera')
const supportsCameraCapture = computed(() => {
if (typeof navigator === 'undefined') return false
const ua = navigator.userAgent || ''
return /Android|iPhone|iPad|iPod|Mobile/i.test(ua)
})
const aiButtonLabel = computed(() => { const aiButtonLabel = computed(() => {
if (isOverLimit.value) return t('docTooLarge') if (isOverLimit.value) return t('docTooLarge')
return aiEnabled.value ? t('disableAI') : t('enableAI') return aiEnabled.value ? t('disableAI') : t('enableAI')
@@ -170,6 +180,8 @@ let markdownSyncTimer = null
let rootResizeObserver = null let rootResizeObserver = null
const objectUrls = new Set() const objectUrls = new Set()
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock']) const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
const MARKDOWN_EXT_RE = /\.md$/i
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif|avif)$/i
const revokeObjectUrl = (url) => { const revokeObjectUrl = (url) => {
if (!objectUrls.has(url)) return if (!objectUrls.has(url)) return
@@ -287,6 +299,29 @@ const handleRedo = () => {
runHistoryCommand(redo) runHistoryCommand(redo)
} }
const isMarkdownFile = (file) => {
if (!file) return false
const name = (file.name || '').toLowerCase()
const type = (file.type || '').toLowerCase()
return MARKDOWN_EXT_RE.test(name) || type === 'text/markdown' || type === 'text/x-markdown'
}
const isImageFile = (file) => {
if (!file) return false
const name = (file.name || '').toLowerCase()
const type = (file.type || '').toLowerCase()
return type.startsWith('image/') || IMAGE_EXT_RE.test(name)
}
const warnUnsupportedUploadType = () => {
alert(t('uploadFileTypeWarning') || 'Only Markdown (.md) files and image files are supported.')
}
const warnImageTooLarge = () => {
const limitMB = Math.floor(IMAGE_SIZE_LIMIT / 1024 / 1024)
alert(t('imgTooLarge') || `Image too large. Max ${limitMB}MB.`)
}
const performOCR = async (file, cacheKey, imageHash = '') => { const performOCR = async (file, cacheKey, imageHash = '') => {
if (!aiEnabled.value) return if (!aiEnabled.value) return
@@ -329,6 +364,34 @@ const performOCR = async (file, cacheKey, imageHash = '') => {
reader.readAsDataURL(file) reader.readAsDataURL(file)
} }
const prepareImageFile = async (file) => {
if (!isImageFile(file)) {
warnUnsupportedUploadType()
return null
}
if (file.size > IMAGE_SIZE_LIMIT) {
warnImageTooLarge()
return null
}
const objectUrl = URL.createObjectURL(file)
objectUrls.add(objectUrl)
const arrayBuffer = await file.arrayBuffer()
const imageBytes = new Uint8Array(arrayBuffer)
const imageHash = await calculateImageHash(imageBytes)
const existingOcr = getOcrByHash(imageHash)
if (!existingOcr) {
performOCR(file, objectUrl, imageHash)
} else {
setOcrCache(objectUrl, existingOcr)
setOcrCache(file.name, existingOcr)
}
return objectUrl
}
onMounted(async () => { onMounted(async () => {
if (!root.value) throw new Error('root.value is null') if (!root.value) throw new Error('root.value is null')
updateEditorTailSpace() updateEditorTailSpace()
@@ -341,12 +404,11 @@ onMounted(async () => {
crepe = new Crepe({ crepe = new Crepe({
root: root.value, root: root.value,
defaultValue: '# 欢迎来到LLM-IN-TEXT\n\n一个即时LLM系统\n\n在下面开始你的创作...', defaultValue: '# 娆㈣繋鏉ュ埌LLM-IN-TEXT\n\n涓€涓嵆鏃禠LM绯荤粺\n\n鍦ㄤ笅闈㈠紑濮嬩綘鐨勫垱浣?..',
features: { features: {
[Crepe.Feature.Latex]: true, [Crepe.Feature.Latex]: true,
[Crepe.Feature.ImageBlock]: true, [Crepe.Feature.ImageBlock]: true,
[Crepe.Feature.Table]: true, [Crepe.Feature.Table]: true,
[Crepe.Feature.Diagram]: true,
[Crepe.Feature.ListCheck]: true, [Crepe.Feature.ListCheck]: true,
}, },
featureConfigs: { featureConfigs: {
@@ -356,22 +418,8 @@ onMounted(async () => {
}, },
[Crepe.Feature.ImageBlock]: { [Crepe.Feature.ImageBlock]: {
onUpload: async (file) => { onUpload: async (file) => {
if (file.size > IMAGE_SIZE_LIMIT) { const objectUrl = await prepareImageFile(file)
alert(`图片大小不能超过 ${Math.floor(IMAGE_SIZE_LIMIT / 1024 / 1024)}MB`) if (!objectUrl) return null
return null
}
const objectUrl = URL.createObjectURL(file)
objectUrls.add(objectUrl)
const arrayBuffer = await file.arrayBuffer()
const imageBytes = new Uint8Array(arrayBuffer)
const imageHash = await calculateImageHash(imageBytes)
const existingOcr = getOcrByHash(imageHash)
if (!existingOcr) {
performOCR(file, objectUrl, imageHash)
} else {
setOcrCache(objectUrl, existingOcr)
setOcrCache(file.name, existingOcr)
}
clearCurrentGhost() clearCurrentGhost()
return objectUrl return objectUrl
} }
@@ -391,6 +439,13 @@ onMounted(async () => {
}) })
}) })
crepe.editor.config((ctx) => {
ctx.update(codeBlockConfig.key, (prev) => ({
...prev,
renderPreview: mermaidRenderPreview,
}))
})
// Watch for debounce changes // Watch for debounce changes
watch(() => settings.debounceMs, (newVal) => { watch(() => settings.debounceMs, (newVal) => {
if (!crepe) return if (!crepe) return
@@ -406,6 +461,7 @@ onMounted(async () => {
crepe.editor.use(copilotConfigCtx) crepe.editor.use(copilotConfigCtx)
crepe.editor.use(copilotGhostMark) crepe.editor.use(copilotGhostMark)
crepe.editor.use(copilotPlugin) crepe.editor.use(copilotPlugin)
await crepe.create() await crepe.create()
@@ -440,8 +496,12 @@ const exportMarkdown = async () => {
const blob = new Blob([markdown], { type: 'text/markdown' }) const blob = new Blob([markdown], { type: 'text/markdown' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
const now = new Date()
const pad = (n) => String(n).padStart(2, '0')
const datePart = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
const timePart = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
a.href = url a.href = url
a.download = `document-${Date.now()}.md` a.download = `save${datePart}${timePart}.md`
document.body.appendChild(a) document.body.appendChild(a)
a.click() a.click()
a.remove() a.remove()
@@ -453,8 +513,25 @@ const triggerUpload = () => {
} }
const handleFileUpload = async (event) => { const handleFileUpload = async (event) => {
const file = event.target.files?.[0] const input = event.target
const file = input.files?.[0]
if (!file) return if (!file) return
if (isImageFile(file)) {
const objectUrl = await prepareImageFile(file)
if (objectUrl) {
clearCurrentGhost()
insertImageAtCursor(objectUrl)
}
input.value = ''
return
}
if (!isMarkdownFile(file)) {
warnUnsupportedUploadType()
input.value = ''
return
}
try { try {
const text = await file.text() const text = await file.text()
@@ -465,7 +542,7 @@ const handleFileUpload = async (event) => {
console.error('[Error] Upload failed:', e) console.error('[Error] Upload failed:', e)
} }
event.target.value = '' input.value = ''
} }
const toggleAI = async () => { const toggleAI = async () => {
@@ -491,6 +568,11 @@ const triggerImageUpload = () => {
imageInputRef.value?.click() imageInputRef.value?.click()
} }
const triggerCameraCapture = () => {
showImageDropdown.value = false
cameraInputRef.value?.click()
}
const insertImageAtCursor = (src) => { const insertImageAtCursor = (src) => {
if (!crepe || !src) return if (!crepe || !src) return
@@ -512,33 +594,20 @@ const insertImageAtCursor = (src) => {
} }
const handleImageUpload = async (event) => { const handleImageUpload = async (event) => {
const file = event.target.files?.[0] const input = event.target
const file = input.files?.[0]
if (!file) return if (!file) return
if (file.size > IMAGE_SIZE_LIMIT) { const objectUrl = await prepareImageFile(file)
alert(`图片大小不能超过 ${Math.floor(IMAGE_SIZE_LIMIT / 1024 / 1024)}MB`) if (!objectUrl) {
event.target.value = '' input.value = ''
return return
} }
const objectUrl = URL.createObjectURL(file)
objectUrls.add(objectUrl)
const arrayBuffer = await file.arrayBuffer()
const imageBytes = new Uint8Array(arrayBuffer)
const imageHash = await calculateImageHash(imageBytes)
const existingOcr = getOcrByHash(imageHash)
if (!existingOcr) {
performOCR(file, objectUrl, imageHash)
} else {
setOcrCache(objectUrl, existingOcr)
setOcrCache(file.name, existingOcr)
}
clearCurrentGhost() clearCurrentGhost()
insertImageAtCursor(objectUrl) insertImageAtCursor(objectUrl)
event.target.value = '' input.value = ''
} }
const insertImageFromUrl = () => { const insertImageFromUrl = () => {
@@ -976,3 +1045,4 @@ onUnmounted(() => {
background-color: var(--ghost-code-bg); background-color: var(--ghost-code-bg);
} }
</style> </style>
+103 -28
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, watch, computed } from 'vue' import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
import { useSettingsStore } from '../stores/settings' import { useSettingsStore } from '../stores/settings'
import { useTheme } from '../composables/useTheme' import { useTheme } from '../composables/useTheme'
@@ -7,6 +7,7 @@ const store = useSettingsStore()
const { setTheme } = useTheme() const { setTheme } = useTheme()
const isOpen = ref(false) const isOpen = ref(false)
let systemThemeMediaQuery = null
const togglePanel = () => { const togglePanel = () => {
isOpen.value = !isOpen.value isOpen.value = !isOpen.value
@@ -16,15 +17,101 @@ const closePanel = () => {
isOpen.value = false isOpen.value = false
} }
// Theme Handling const applyThemeByPreference = () => {
watch(() => store.theme, (newVal) => { if (store.theme === 'system') {
if (newVal === 'system') { if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
setTheme(isDark ? 'dark' : 'light') setTheme(isDark ? 'dark' : 'light')
} else { return
setTheme(newVal) }
setTheme('light')
return
} }
}, { immediate: true })
setTheme(store.theme)
}
watch(
() => store.theme,
() => {
applyThemeByPreference()
},
{ immediate: true }
)
const handleSystemThemeChange = (event) => {
if (store.theme !== 'system') return
setTheme(event.matches ? 'dark' : 'light')
}
onMounted(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
systemThemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
if (typeof systemThemeMediaQuery.addEventListener === 'function') {
systemThemeMediaQuery.addEventListener('change', handleSystemThemeChange)
} else if (typeof systemThemeMediaQuery.addListener === 'function') {
systemThemeMediaQuery.addListener(handleSystemThemeChange)
}
})
onUnmounted(() => {
if (!systemThemeMediaQuery) return
if (typeof systemThemeMediaQuery.removeEventListener === 'function') {
systemThemeMediaQuery.removeEventListener('change', handleSystemThemeChange)
} else if (typeof systemThemeMediaQuery.removeListener === 'function') {
systemThemeMediaQuery.removeListener(handleSystemThemeChange)
}
})
const appearanceMode = computed({
get() {
if (store.backgroundType === 'warm') return 'warm'
if (store.backgroundType === 'reading') return 'reading'
if (store.backgroundType === 'image') return 'image'
if (store.theme === 'dark') return 'dark'
if (store.theme === 'light') return 'light'
return 'system'
},
set(mode) {
if (mode === 'dark') {
store.theme = 'dark'
store.backgroundType = 'default'
return
}
if (mode === 'light') {
store.theme = 'light'
store.backgroundType = 'default'
return
}
if (mode === 'system') {
store.theme = 'system'
store.backgroundType = 'default'
return
}
if (mode === 'warm') {
store.theme = 'light'
store.backgroundType = 'warm'
return
}
if (mode === 'reading') {
store.theme = 'light'
store.backgroundType = 'reading'
return
}
if (mode === 'image') {
store.theme = 'light'
store.backgroundType = 'image'
}
}
})
// Background Image Handling // Background Image Handling
const handleImageUpload = (event) => { const handleImageUpload = (event) => {
@@ -41,12 +128,6 @@ const handleImageUpload = (event) => {
// Helper to translate // Helper to translate
const t = (key) => store.t[key] const t = (key) => store.t[key]
// Background Style for App (This will be used in App.vue, but we preview it here or just logical check)
// UI Helpers
const tabs = ['General', 'Model', 'Appearance', 'About']
const currentTab = ref('General')
</script> </script>
<template> <template>
@@ -88,25 +169,18 @@ const currentTab = ref('General')
<h3>{{ t('appearance') }}</h3> <h3>{{ t('appearance') }}</h3>
<div class="form-group"> <div class="form-group">
<label>{{ t('theme') }}</label> <label>{{ t('appearance') }}</label>
<div class="segment-control"> <select v-model="appearanceMode" class="select-input">
<button :class="{ active: store.theme === 'light' }" @click="store.theme = 'light'">{{ t('light') }}</button> <option value="dark">{{ t('dark') }}</option>
<button :class="{ active: store.theme === 'dark' }" @click="store.theme = 'dark'">{{ t('dark') }}</button> <option value="light">{{ t('light') }}</option>
<button :class="{ active: store.theme === 'system' }" @click="store.theme = 'system'">{{ t('system') }}</button> <option value="system">{{ t('system') }}</option>
</div>
</div>
<div class="form-group">
<label>{{ t('background') }}</label>
<select v-model="store.backgroundType" class="select-input">
<option value="default">{{ t('default') }}</option>
<option value="warm">{{ t('warm') }}</option> <option value="warm">{{ t('warm') }}</option>
<option value="reading">{{ t('reading') }}</option> <option value="reading">{{ t('reading') }}</option>
<option value="image">{{ t('image') }}</option> <option value="image">{{ t('image') }}</option>
</select> </select>
</div> </div>
<div v-if="store.backgroundType === 'image'" class="form-group"> <div v-if="appearanceMode === 'image'" class="form-group">
<label>{{ t('image') }}</label> <label>{{ t('image') }}</label>
<input type="file" accept="image/*" @change="handleImageUpload" class="file-input" /> <input type="file" accept="image/*" @change="handleImageUpload" class="file-input" />
@@ -488,3 +562,4 @@ const currentTab = ref('General')
opacity: 0.7; opacity: 0.7;
} }
</style> </style>
+8 -8
View File
@@ -59,14 +59,14 @@ export const copilotGhostMark = $markSchema('copilot_ghost', () => ({
} }
})) }))
function clearRuntimeRequests(runtime: CopilotRuntime, invalidateRequest = true) { function clearRuntimeRequests(runtime: CopilotRuntime, invalidateRequest = true, abortReason = 'abort') {
if (runtime.debounceTimer) { if (runtime.debounceTimer) {
clearTimeout(runtime.debounceTimer) clearTimeout(runtime.debounceTimer)
runtime.debounceTimer = null runtime.debounceTimer = null
} }
if (runtime.abortController) { if (runtime.abortController) {
runtime.abortController.abort() runtime.abortController.abort(abortReason)
runtime.abortController = null runtime.abortController = null
} }
@@ -302,7 +302,7 @@ function doFetchSuggestion(
const config = runtime.ctx.get(copilotConfigCtx.key) const config = runtime.ctx.get(copilotConfigCtx.key)
if (runtime.abortController) { if (runtime.abortController) {
runtime.abortController.abort() runtime.abortController.abort('superseded')
runtime.abortController = null runtime.abortController = null
} }
@@ -611,7 +611,7 @@ export const copilotPlugin = $prose((ctx) => new Plugin<CopilotState>({
const nextHasGhost = Boolean(nextGhost?.suggestion && nextGhost.from < nextGhost.to) const nextHasGhost = Boolean(nextGhost?.suggestion && nextGhost.from < nextGhost.to)
if (docChanged && prevHasGhost && nextHasGhost) { if (docChanged && prevHasGhost && nextHasGhost) {
clearGhostText(nextView) clearGhostText(nextView)
clearRuntimeRequests(runtime) clearRuntimeRequests(runtime, true, 'superseded')
return return
} }
@@ -630,7 +630,7 @@ export const copilotPlugin = $prose((ctx) => new Plugin<CopilotState>({
const { from, to } = nextView.state.selection const { from, to } = nextView.state.selection
if (from !== to) { if (from !== to) {
clearRuntimeRequests(runtime) clearRuntimeRequests(runtime, true, 'manual')
return return
} }
@@ -638,7 +638,7 @@ export const copilotPlugin = $prose((ctx) => new Plugin<CopilotState>({
}, },
destroy: () => { destroy: () => {
unbindDomListeners(activeDom) unbindDomListeners(activeDom)
clearRuntimeRequests(runtime) clearRuntimeRequests(runtime, true, 'destroy')
runtimeByView.delete(view) runtimeByView.delete(view)
} }
} }
@@ -657,14 +657,14 @@ export function setCopilotEnabled(view: EditorView, value: boolean): void {
runtime.enabled = value runtime.enabled = value
if (!value) { if (!value) {
clearRuntimeRequests(runtime) clearRuntimeRequests(runtime, true, 'disabled')
} }
} }
export function interruptCopilot(view: EditorView): void { export function interruptCopilot(view: EditorView): void {
const runtime = runtimeByView.get(view) const runtime = runtimeByView.get(view)
if (!runtime) return if (!runtime) return
clearRuntimeRequests(runtime) clearRuntimeRequests(runtime, true, 'manual')
} }
export function checkSizeLimit(view: EditorView): { size: number; overLimit: boolean } { export function checkSizeLimit(view: EditorView): { size: number; overLimit: boolean } {
+67
View File
@@ -0,0 +1,67 @@
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import mermaid from 'mermaid'
// ── Mermaid init ────────────────────────────────────────────────────────────
let mermaidReady = false
let diagramCounter = 0
function ensureMermaid() {
if (mermaidReady) return
const dark = window.matchMedia?.('(prefers-color-scheme: dark)').matches
mermaid.initialize({
startOnLoad: false,
theme: dark ? 'dark' : 'default',
securityLevel: 'loose',
fontFamily: 'inherit',
})
mermaidReady = true
}
// ── renderPreview ───────────────────────────────────────────────────────────
// Pass this function to codeBlockConfig.renderPreview via crepe.editor.config().
// For non-mermaid languages, return null to use the default preview renderer.
export async function mermaidRenderPreview(
language: string,
content: string,
applyPreview: (value: null | string | HTMLElement) => void,
): Promise<void> {
if (language !== 'mermaid') {
applyPreview(null)
return
}
ensureMermaid()
// Show a placeholder immediately
const wrapper = document.createElement('div')
wrapper.className = 'mermaid-block'
const inner = document.createElement('div')
inner.className = 'mermaid-inner'
inner.innerHTML = '<div class="mermaid-loading">···</div>'
wrapper.appendChild(inner)
applyPreview(wrapper)
const id = `mermaid-render-${++diagramCounter}`
const code = content.trim() || 'graph TD\nA-->B'
try {
const { svg } = await mermaid.render(id, code)
inner.innerHTML = svg
applyPreview(wrapper)
} catch (err) {
const pre = document.createElement('pre')
pre.className = 'mermaid-error'
pre.textContent = `Mermaid error:\n${err instanceof Error ? err.message : String(err)}`
inner.innerHTML = ''
inner.appendChild(pre)
applyPreview(wrapper)
}
}
// ── Milkdown plugin helper ─────────────────────────────────────────────────
// Call this inside a crepe.editor.config() callback:
// ctx.update(codeBlockConfig.key, (prev) => ({ ...prev, renderPreview: mermaidRenderPreview }))
//
// Re-export the config key so callers don't need to import @milkdown/components directly.
export { codeBlockConfig }
+63
View File
@@ -37,6 +37,8 @@
--toggle-moon: #475569; --toggle-moon: #475569;
--ghost-text: #7d8796; --ghost-text: #7d8796;
--ghost-code-bg: rgba(15, 23, 42, 0.06); --ghost-code-bg: rgba(15, 23, 42, 0.06);
--mermaid-max-width: 800px;
--mermaid-max-height: 420px;
--crepe-color-background: #ffffff; --crepe-color-background: #ffffff;
--crepe-color-on-background: #000000; --crepe-color-on-background: #000000;
@@ -189,3 +191,64 @@ body {
transition: none !important; transition: none !important;
} }
} }
/* ── Mermaid diagram blocks ─────────────────────────────────────────── */
.mermaid-block {
display: block;
margin: 1em 0;
padding: 16px;
background: var(--crepe-color-surface, #f7f7f7);
border: 1px solid var(--panel-border, #d7deea);
border-radius: 8px;
cursor: pointer;
transition: border-color 160ms ease, box-shadow 160ms ease;
user-select: none;
}
.mermaid-block:hover {
border-color: var(--focus-ring, #3b82f6);
}
.mermaid-block.mermaid-selected {
border-color: var(--focus-ring, #3b82f6);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-ring, #3b82f6) 25%, transparent);
}
.mermaid-inner {
display: block;
max-width: min(100%, var(--mermaid-max-width));
max-height: var(--mermaid-max-height);
margin: 0 auto;
overflow: auto;
}
.mermaid-inner svg {
display: block;
}
.mermaid-loading {
padding: 24px;
text-align: center;
font-size: 1.4em;
color: var(--muted-text, #6b7280);
letter-spacing: 0.2em;
animation: mermaid-pulse 1.2s ease-in-out infinite;
}
@keyframes mermaid-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.mermaid-error {
padding: 12px 16px;
margin: 0;
background: color-mix(in srgb, var(--danger-text, #dc2626) 8%, transparent);
border: 1px solid var(--danger-text, #dc2626);
border-radius: 6px;
color: var(--danger-text, #dc2626);
font-size: 12px;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
white-space: pre-wrap;
word-break: break-word;
}
+69 -6
View File
@@ -1,7 +1,51 @@
import { API_URL } from './config.js' import { API_URL } from './config.js'
import { useSettingsStore } from '../stores/settings'
const API_KEY = 'your-secret-key-here'
let cachedIP = null let cachedIP = null
function generateRequestId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function getCancelUrl(apiUrl) {
const normalized = String(apiUrl || '').replace(/\/+$/, '')
if (!normalized) return '/v1/completions/cancel'
if (normalized.endsWith('/v1/completions')) {
return `${normalized}/cancel`
}
return `${normalized}/cancel`
}
function normalizeAbortReason(reason) {
if (typeof reason === 'string' && reason.trim()) {
return reason.trim().slice(0, 64)
}
return 'abort'
}
async function sendCancelRequest(cancelUrl, requestId, reason) {
try {
await fetch(cancelUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
},
body: JSON.stringify({
request_id: requestId,
reason,
}),
})
} catch (e) {
console.debug('[Copilot] cancel request failed', e)
}
}
async function getClientIP() { async function getClientIP() {
if (cachedIP) return cachedIP if (cachedIP) return cachedIP
try { try {
@@ -16,15 +60,30 @@ async function getClientIP() {
} }
} }
import { useSettingsStore } from '../stores/settings'
export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL) { export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL) {
const requestId = generateRequestId()
const cancelUrl = getCancelUrl(apiUrl)
const onAbort = () => {
const reason = normalizeAbortReason(signal?.reason)
void sendCancelRequest(cancelUrl, requestId, reason)
}
if (signal) {
if (signal.aborted) {
onAbort()
} else {
signal.addEventListener('abort', onAbort, { once: true })
}
}
try { try {
const settings = useSettingsStore() const settings = useSettingsStore()
const clientIP = await getClientIP() const clientIP = await getClientIP()
const headers = { const headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-API-Key': 'your-secret-key-here' 'X-API-Key': API_KEY,
'X-Request-Id': requestId,
} }
// Only send IP if privacy mode is OFF // Only send IP if privacy mode is OFF
@@ -41,15 +100,15 @@ export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL)
user_preferences: { user_preferences: {
language: settings.language, language: settings.language,
currency: settings.currency, currency: settings.currency,
timezone: settings.detectedTimezone timezone: settings.detectedTimezone,
} },
} }
const res = await fetch(apiUrl, { const res = await fetch(apiUrl, {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify(body), body: JSON.stringify(body),
signal signal,
}) })
if (!res.ok) { if (!res.ok) {
@@ -95,5 +154,9 @@ export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL)
} else { } else {
throw e throw e
} }
} finally {
if (signal) {
signal.removeEventListener('abort', onAbort)
}
} }
} }