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 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
@@ -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
@@ -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"}
|
||||||
|
|
||||||
|
|||||||
@@ -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 "$$...$$" 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
|
||||||
|
|||||||
Generated
+1192
-2
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 } {
|
||||||
|
|||||||
@@ -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;
|
--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
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user