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:
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import ollama
|
||||
from dotenv import load_dotenv
|
||||
@@ -97,6 +98,17 @@ async def call_ollama(
|
||||
kwargs["think"] = thinking
|
||||
|
||||
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:
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
|
||||
+137
-52
@@ -1,16 +1,19 @@
|
||||
from fastapi import FastAPI, Request, HTTPException, Security
|
||||
from fastapi.security import APIKeyHeader
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse, JSONResponse
|
||||
from pydantic import BaseModel
|
||||
import json
|
||||
import asyncio
|
||||
import base64
|
||||
import uuid
|
||||
import json
|
||||
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 llm import call_ollama, call_vlm_ocr
|
||||
from prompt import build_completion_prompts, prepare_prompt_context
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -20,44 +23,54 @@ logger = logging.getLogger("api")
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
ACTIVE_COMPLETIONS: dict[str, asyncio.Task] = {}
|
||||
ACTIVE_COMPLETIONS_LOCK = asyncio.Lock()
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
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")
|
||||
|
||||
|
||||
async def get_api_key(api_key: str = Security(api_key_header)):
|
||||
if api_key != API_KEY:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Could not validate credentials"
|
||||
detail="Could not validate credentials",
|
||||
)
|
||||
return api_key
|
||||
|
||||
from typing import Optional
|
||||
|
||||
class UserPreferences(BaseModel):
|
||||
language: str = 'auto'
|
||||
currency: str = 'auto'
|
||||
timezone: str = 'auto'
|
||||
language: str = "auto"
|
||||
currency: str = "auto"
|
||||
timezone: str = "auto"
|
||||
|
||||
|
||||
class CompletionRequest(BaseModel):
|
||||
prefix: str
|
||||
suffix: str
|
||||
languageId: str = 'markdown'
|
||||
model_thinking: str = 'low'
|
||||
languageId: str = "markdown"
|
||||
model_thinking: str = "low"
|
||||
privacy_mode: bool = False
|
||||
user_preferences: Optional[UserPreferences] = None
|
||||
|
||||
|
||||
class CancelCompletionRequest(BaseModel):
|
||||
request_id: str
|
||||
reason: str = "abort"
|
||||
|
||||
|
||||
class OCRRequest(BaseModel):
|
||||
image: str
|
||||
filename: str = "image.jpg"
|
||||
language: str = 'auto'
|
||||
language: str = "auto"
|
||||
|
||||
|
||||
def _preview(text: str, limit: int = 80) -> str:
|
||||
@@ -66,73 +79,143 @@ def _preview(text: str, limit: int = 80) -> str:
|
||||
return value
|
||||
return value[:limit] + "..."
|
||||
|
||||
|
||||
def _sse_payload(payload: dict) -> str:
|
||||
return f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
|
||||
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")
|
||||
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"
|
||||
location = ""
|
||||
|
||||
|
||||
if not req.privacy_mode:
|
||||
client_ip = get_client_ip(request)
|
||||
# 查询 IP 归属地
|
||||
location = get_ip_location_text(client_ip)
|
||||
if location:
|
||||
logger.info("[%s] client_location=%s", request_id, location)
|
||||
|
||||
logger.info("[%s] client_location=%s", request_tag, location)
|
||||
|
||||
try:
|
||||
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,
|
||||
client_ip,
|
||||
len(req.prefix or ""),
|
||||
len(req.suffix or ""),
|
||||
req.languageId,
|
||||
req.model_thinking,
|
||||
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
|
||||
req.privacy_mode,
|
||||
)
|
||||
|
||||
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 ""
|
||||
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(
|
||||
"[%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,
|
||||
len(content),
|
||||
_preview(content, 120),
|
||||
)
|
||||
|
||||
async def generate():
|
||||
yield f"data: {json.dumps({'content': content})}\n\n"
|
||||
yield f"data: {json.dumps({'done': True})}\n\n"
|
||||
yield _sse_payload({"content": content})
|
||||
yield _sse_payload({"done": True})
|
||||
|
||||
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:
|
||||
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)
|
||||
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")
|
||||
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)
|
||||
return JSONResponse(content={"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||
|
||||
+94
-7
@@ -63,6 +63,14 @@ def _prepare_context(prefix: str, suffix: str) -> Tuple[str, str]:
|
||||
|
||||
|
||||
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:
|
||||
@@ -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:
|
||||
^[ \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)
|
||||
in_fence = False
|
||||
active_language = "none"
|
||||
for line in normalized.split("\n"):
|
||||
if FENCE_LINE_RE.match(line):
|
||||
in_fence = not in_fence
|
||||
return in_fence
|
||||
if 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]:
|
||||
@@ -113,22 +157,36 @@ Hard constraints you must follow:
|
||||
- Do not output code snippets as inline backticks.
|
||||
- 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.
|
||||
- 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:
|
||||
6) 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:
|
||||
7) 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."""
|
||||
- Never output OCR tag fragments such as <OCR:...>."""
|
||||
return system_prompt.strip()
|
||||
|
||||
|
||||
@@ -227,6 +285,29 @@ The area is $A = \\pi r^2$.
|
||||
|
||||
```javascript
|
||||
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_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")
|
||||
suffix_starts_with_newline = recent_suffix.startswith("\n")
|
||||
|
||||
@@ -268,6 +353,8 @@ Editor language id: {safe_language_id}
|
||||
|
||||
Completion state flags:
|
||||
- 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"}
|
||||
- SUFFIX_STARTS_WITH_NEWLINE: {"true" if suffix_starts_with_newline else "false"}
|
||||
|
||||
|
||||
@@ -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 == {}
|
||||
@@ -21,7 +21,13 @@ def test_prompt_builds_system_and_user():
|
||||
assert "$...$" in system_prompt
|
||||
assert "$$...$$" 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_FENCE_LANGUAGE" in user_prompt
|
||||
assert "MERMAID_CONTEXT" in user_prompt
|
||||
assert "PREFIX_ENDS_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
|
||||
|
||||
|
||||
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():
|
||||
_, user_prompt_a = prompt.build_completion_prompts(
|
||||
prefix="Hello",
|
||||
suffix="World",
|
||||
)
|
||||
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 "SUFFIX_STARTS_WITH_NEWLINE: false" in user_prompt_a
|
||||
|
||||
@@ -46,11 +62,30 @@ def test_newline_flags():
|
||||
prefix="Hello\n",
|
||||
suffix="\nWorld",
|
||||
)
|
||||
assert "CURSOR_FENCE_LANGUAGE: none" in user_prompt_b
|
||||
assert "PREFIX_ENDS_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():
|
||||
_, 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
|
||||
|
||||
Generated
+1192
-2
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@
|
||||
"katex": "^0.16.9",
|
||||
"markdown-it": "^13.0.0",
|
||||
"markdown-it-math": "^3.0.2",
|
||||
"mermaid": "^11.12.3",
|
||||
"pinia": "^2.3.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"vue": "^3.5.24",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="editor-container">
|
||||
<div ref="root" class="milkdown-editor"></div>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
</svg>
|
||||
<span class="btn-tooltip">{{ t('importMd') }}</span>
|
||||
</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
|
||||
type="button"
|
||||
@@ -79,11 +79,13 @@
|
||||
<span class="btn-tooltip">{{ t('uploadImg') }}</span>
|
||||
</button>
|
||||
<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="showUrlDialog = true; showImageDropdown = false">{{ t('insertUrl') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
type="button"
|
||||
@@ -137,6 +139,7 @@ import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
|
||||
import { Selection } from '@milkdown/prose/state'
|
||||
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 { mermaidRenderPreview, codeBlockConfig } from '../plugins/mermaidPlugin'
|
||||
import { fetchSuggestion } from '../utils/api.js'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { OCR_URL } from '../utils/config.js'
|
||||
@@ -149,6 +152,7 @@ const t = (key) => settings.t[key]
|
||||
const root = ref(null)
|
||||
const fileInputRef = ref(null)
|
||||
const imageInputRef = ref(null)
|
||||
const cameraInputRef = ref(null)
|
||||
const aiEnabled = ref(true)
|
||||
const contentSize = ref(0)
|
||||
const showImageDropdown = ref(false)
|
||||
@@ -160,6 +164,12 @@ 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 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(() => {
|
||||
if (isOverLimit.value) return t('docTooLarge')
|
||||
return aiEnabled.value ? t('disableAI') : t('enableAI')
|
||||
@@ -170,6 +180,8 @@ let markdownSyncTimer = null
|
||||
let rootResizeObserver = null
|
||||
const objectUrls = new Set()
|
||||
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) => {
|
||||
if (!objectUrls.has(url)) return
|
||||
@@ -287,6 +299,29 @@ const handleRedo = () => {
|
||||
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 = '') => {
|
||||
if (!aiEnabled.value) return
|
||||
|
||||
@@ -329,6 +364,34 @@ const performOCR = async (file, cacheKey, imageHash = '') => {
|
||||
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 () => {
|
||||
if (!root.value) throw new Error('root.value is null')
|
||||
updateEditorTailSpace()
|
||||
@@ -341,12 +404,11 @@ onMounted(async () => {
|
||||
|
||||
crepe = new Crepe({
|
||||
root: root.value,
|
||||
defaultValue: '# 欢迎来到LLM-IN-TEXT\n\n一个即时LLM系统\n\n在下面开始你的创作...',
|
||||
defaultValue: '# 娆㈣繋鏉ュ埌LLM-IN-TEXT\n\n涓€涓嵆鏃禠LM绯荤粺\n\n鍦ㄤ笅闈㈠紑濮嬩綘鐨勫垱浣?..',
|
||||
features: {
|
||||
[Crepe.Feature.Latex]: true,
|
||||
[Crepe.Feature.ImageBlock]: true,
|
||||
[Crepe.Feature.Table]: true,
|
||||
[Crepe.Feature.Diagram]: true,
|
||||
[Crepe.Feature.ListCheck]: true,
|
||||
},
|
||||
featureConfigs: {
|
||||
@@ -356,22 +418,8 @@ onMounted(async () => {
|
||||
},
|
||||
[Crepe.Feature.ImageBlock]: {
|
||||
onUpload: async (file) => {
|
||||
if (file.size > IMAGE_SIZE_LIMIT) {
|
||||
alert(`图片大小不能超过 ${Math.floor(IMAGE_SIZE_LIMIT / 1024 / 1024)}MB`)
|
||||
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)
|
||||
}
|
||||
const objectUrl = await prepareImageFile(file)
|
||||
if (!objectUrl) return null
|
||||
clearCurrentGhost()
|
||||
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(() => settings.debounceMs, (newVal) => {
|
||||
if (!crepe) return
|
||||
@@ -406,6 +461,7 @@ onMounted(async () => {
|
||||
crepe.editor.use(copilotConfigCtx)
|
||||
crepe.editor.use(copilotGhostMark)
|
||||
crepe.editor.use(copilotPlugin)
|
||||
|
||||
|
||||
await crepe.create()
|
||||
|
||||
@@ -440,8 +496,12 @@ const exportMarkdown = async () => {
|
||||
const blob = new Blob([markdown], { type: 'text/markdown' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
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.download = `document-${Date.now()}.md`
|
||||
a.download = `save${datePart}${timePart}.md`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
@@ -453,8 +513,25 @@ const triggerUpload = () => {
|
||||
}
|
||||
|
||||
const handleFileUpload = async (event) => {
|
||||
const file = event.target.files?.[0]
|
||||
const input = event.target
|
||||
const file = input.files?.[0]
|
||||
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 {
|
||||
const text = await file.text()
|
||||
@@ -465,7 +542,7 @@ const handleFileUpload = async (event) => {
|
||||
console.error('[Error] Upload failed:', e)
|
||||
}
|
||||
|
||||
event.target.value = ''
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
const toggleAI = async () => {
|
||||
@@ -491,6 +568,11 @@ const triggerImageUpload = () => {
|
||||
imageInputRef.value?.click()
|
||||
}
|
||||
|
||||
const triggerCameraCapture = () => {
|
||||
showImageDropdown.value = false
|
||||
cameraInputRef.value?.click()
|
||||
}
|
||||
|
||||
const insertImageAtCursor = (src) => {
|
||||
if (!crepe || !src) return
|
||||
|
||||
@@ -512,33 +594,20 @@ const insertImageAtCursor = (src) => {
|
||||
}
|
||||
|
||||
const handleImageUpload = async (event) => {
|
||||
const file = event.target.files?.[0]
|
||||
const input = event.target
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (file.size > IMAGE_SIZE_LIMIT) {
|
||||
alert(`图片大小不能超过 ${Math.floor(IMAGE_SIZE_LIMIT / 1024 / 1024)}MB`)
|
||||
event.target.value = ''
|
||||
|
||||
const objectUrl = await prepareImageFile(file)
|
||||
if (!objectUrl) {
|
||||
input.value = ''
|
||||
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()
|
||||
insertImageAtCursor(objectUrl)
|
||||
|
||||
event.target.value = ''
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
const insertImageFromUrl = () => {
|
||||
@@ -976,3 +1045,4 @@ onUnmounted(() => {
|
||||
background-color: var(--ghost-code-bg);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
|
||||
@@ -7,6 +7,7 @@ const store = useSettingsStore()
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
const isOpen = ref(false)
|
||||
let systemThemeMediaQuery = null
|
||||
|
||||
const togglePanel = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
@@ -16,15 +17,101 @@ const closePanel = () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
// Theme Handling
|
||||
watch(() => store.theme, (newVal) => {
|
||||
if (newVal === 'system') {
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
setTheme(isDark ? 'dark' : 'light')
|
||||
} else {
|
||||
setTheme(newVal)
|
||||
const applyThemeByPreference = () => {
|
||||
if (store.theme === 'system') {
|
||||
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
setTheme(isDark ? 'dark' : 'light')
|
||||
return
|
||||
}
|
||||
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
|
||||
const handleImageUpload = (event) => {
|
||||
@@ -41,12 +128,6 @@ const handleImageUpload = (event) => {
|
||||
|
||||
// Helper to translate
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -88,25 +169,18 @@ const currentTab = ref('General')
|
||||
<h3>{{ t('appearance') }}</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ t('theme') }}</label>
|
||||
<div class="segment-control">
|
||||
<button :class="{ active: store.theme === 'light' }" @click="store.theme = 'light'">{{ t('light') }}</button>
|
||||
<button :class="{ active: store.theme === 'dark' }" @click="store.theme = 'dark'">{{ t('dark') }}</button>
|
||||
<button :class="{ active: store.theme === 'system' }" @click="store.theme = 'system'">{{ t('system') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ t('background') }}</label>
|
||||
<select v-model="store.backgroundType" class="select-input">
|
||||
<option value="default">{{ t('default') }}</option>
|
||||
<label>{{ t('appearance') }}</label>
|
||||
<select v-model="appearanceMode" class="select-input">
|
||||
<option value="dark">{{ t('dark') }}</option>
|
||||
<option value="light">{{ t('light') }}</option>
|
||||
<option value="system">{{ t('system') }}</option>
|
||||
<option value="warm">{{ t('warm') }}</option>
|
||||
<option value="reading">{{ t('reading') }}</option>
|
||||
<option value="image">{{ t('image') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="store.backgroundType === 'image'" class="form-group">
|
||||
<div v-if="appearanceMode === 'image'" class="form-group">
|
||||
<label>{{ t('image') }}</label>
|
||||
<input type="file" accept="image/*" @change="handleImageUpload" class="file-input" />
|
||||
|
||||
@@ -488,3 +562,4 @@ const currentTab = ref('General')
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
clearTimeout(runtime.debounceTimer)
|
||||
runtime.debounceTimer = null
|
||||
}
|
||||
|
||||
if (runtime.abortController) {
|
||||
runtime.abortController.abort()
|
||||
runtime.abortController.abort(abortReason)
|
||||
runtime.abortController = null
|
||||
}
|
||||
|
||||
@@ -302,7 +302,7 @@ function doFetchSuggestion(
|
||||
const config = runtime.ctx.get(copilotConfigCtx.key)
|
||||
|
||||
if (runtime.abortController) {
|
||||
runtime.abortController.abort()
|
||||
runtime.abortController.abort('superseded')
|
||||
runtime.abortController = null
|
||||
}
|
||||
|
||||
@@ -611,7 +611,7 @@ export const copilotPlugin = $prose((ctx) => new Plugin<CopilotState>({
|
||||
const nextHasGhost = Boolean(nextGhost?.suggestion && nextGhost.from < nextGhost.to)
|
||||
if (docChanged && prevHasGhost && nextHasGhost) {
|
||||
clearGhostText(nextView)
|
||||
clearRuntimeRequests(runtime)
|
||||
clearRuntimeRequests(runtime, true, 'superseded')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -630,7 +630,7 @@ export const copilotPlugin = $prose((ctx) => new Plugin<CopilotState>({
|
||||
|
||||
const { from, to } = nextView.state.selection
|
||||
if (from !== to) {
|
||||
clearRuntimeRequests(runtime)
|
||||
clearRuntimeRequests(runtime, true, 'manual')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -638,7 +638,7 @@ export const copilotPlugin = $prose((ctx) => new Plugin<CopilotState>({
|
||||
},
|
||||
destroy: () => {
|
||||
unbindDomListeners(activeDom)
|
||||
clearRuntimeRequests(runtime)
|
||||
clearRuntimeRequests(runtime, true, 'destroy')
|
||||
runtimeByView.delete(view)
|
||||
}
|
||||
}
|
||||
@@ -657,14 +657,14 @@ export function setCopilotEnabled(view: EditorView, value: boolean): void {
|
||||
|
||||
runtime.enabled = value
|
||||
if (!value) {
|
||||
clearRuntimeRequests(runtime)
|
||||
clearRuntimeRequests(runtime, true, 'disabled')
|
||||
}
|
||||
}
|
||||
|
||||
export function interruptCopilot(view: EditorView): void {
|
||||
const runtime = runtimeByView.get(view)
|
||||
if (!runtime) return
|
||||
clearRuntimeRequests(runtime)
|
||||
clearRuntimeRequests(runtime, true, 'manual')
|
||||
}
|
||||
|
||||
export function checkSizeLimit(view: EditorView): { size: number; overLimit: boolean } {
|
||||
|
||||
@@ -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 }
|
||||
@@ -37,6 +37,8 @@
|
||||
--toggle-moon: #475569;
|
||||
--ghost-text: #7d8796;
|
||||
--ghost-code-bg: rgba(15, 23, 42, 0.06);
|
||||
--mermaid-max-width: 800px;
|
||||
--mermaid-max-height: 420px;
|
||||
|
||||
--crepe-color-background: #ffffff;
|
||||
--crepe-color-on-background: #000000;
|
||||
@@ -189,3 +191,64 @@ body {
|
||||
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
@@ -1,7 +1,51 @@
|
||||
import { API_URL } from './config.js'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
|
||||
const API_KEY = 'your-secret-key-here'
|
||||
|
||||
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() {
|
||||
if (cachedIP) return cachedIP
|
||||
try {
|
||||
@@ -16,15 +60,30 @@ async function getClientIP() {
|
||||
}
|
||||
}
|
||||
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
|
||||
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 {
|
||||
const settings = useSettingsStore()
|
||||
const clientIP = await getClientIP()
|
||||
const headers = {
|
||||
'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
|
||||
@@ -41,15 +100,15 @@ export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL)
|
||||
user_preferences: {
|
||||
language: settings.language,
|
||||
currency: settings.currency,
|
||||
timezone: settings.detectedTimezone
|
||||
}
|
||||
timezone: settings.detectedTimezone,
|
||||
},
|
||||
}
|
||||
|
||||
const res = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -95,5 +154,9 @@ export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
} finally {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user