Compare commits

...

2 Commits

Author SHA1 Message Date
ydy0615 9904b9bd78 feat: 批量上传支持及prompt优化
- 支持多文件批量上传,一次最多10个
- 新增json/toml/yaml格式支持
- 优化inline补全prompt结构,增加边界决策指南
- size计算包含doc_block内容长度
- 超限时显示警告tooltip
2026-04-05 11:40:56 +08:00
ydy0615 7ed199aaf1 style: 简化文档卡片样式,优化布局间距 2026-04-05 10:16:16 +08:00
13 changed files with 801 additions and 494 deletions
+1
View File
@@ -43,4 +43,5 @@ env/
# IDE directories
.kilocode/
.kilo/
.codex/
+2 -2
View File
@@ -63,10 +63,10 @@ def _extract_message(response) -> tuple[str, str]:
async def call_ollama(
prompt: str,
*,
system_prompt: str = None,
system_prompt: str | None = None,
tag: str = "default",
temperature: float = 0.7,
thinking: str = None,
thinking: str | None = None,
) -> dict:
"""
调用 Ollama API 并返回 content 和 thinking。
+16 -28
View File
@@ -1,6 +1,5 @@
import asyncio
import base64
import json
import logging
import os
import re
@@ -12,7 +11,7 @@ from typing import Optional
from fastapi import FastAPI, HTTPException, Request, Security, File, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse, Response
from fastapi.responses import JSONResponse, Response
from fastapi.security import APIKeyHeader
from pydantic import BaseModel
@@ -125,10 +124,6 @@ def _sanitize_converted_markdown(text: str) -> str:
return value.strip()
def _sse_payload(payload: dict) -> str:
return f"data: {json.dumps(payload)}\n\n"
def get_client_ip(request: Request) -> str:
if request.client:
return request.headers.get("X-Client-IP") or request.client.host
@@ -186,11 +181,10 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
)
)
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
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 ""
@@ -204,26 +198,17 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
_preview(content, 120),
)
async def generate():
yield _sse_payload({"content": content})
yield _sse_payload({"done": True})
return StreamingResponse(generate(), media_type="text/event-stream")
return JSONResponse(content={"content": content, "request_id": request_id})
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")
return JSONResponse(content={"cancelled": True, "request_id": request_id}, status_code=499)
except Exception as 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)
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")
@@ -399,7 +384,10 @@ if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8001)
# TTS and ASR routes
from tts_asr import register_tts_asr_routes
register_tts_asr_routes(app)
# TTS and ASR routes (lazy loaded to avoid heavy import on startup)
def _register_tts_asr_routes():
from tts_asr import register_tts_asr_routes
register_tts_asr_routes(app)
_register_tts_asr_routes()
+261 -202
View File
@@ -1,6 +1,13 @@
from datetime import datetime, timedelta, timezone
import re
from typing import Tuple
from typing import Protocol, Tuple, runtime_checkable
@runtime_checkable
class UserPreferences(Protocol):
language: str
currency: str
timezone: str
def _get_current_datetime(timezone_pref: str = "auto") -> str:
@@ -214,113 +221,107 @@ def _canonical_language_id(language_id: str) -> str:
return LANGUAGE_SYNONYMS.get(safe, safe)
def _language_guidance(language_id: str) -> str:
canonical = _canonical_language_id(language_id)
if canonical == "markdown":
return ""
if canonical == "mermaid":
return """
_JS_LANGS = {"javascript", "typescript"}
_CODE_LANGS = {"python", "go", "rust", "java", "kotlin", "swift", "ruby", "php", "lua", "c", "cpp", "csharp", "r", "matlab", "dart"}
_LANG_GUIDANCE = {
"mermaid": """
Language-specific guidance (mermaid):
- Output valid Mermaid syntax only.
- Prefer concise, syntactically correct diagram statements.
- Avoid prose unless the user prompt explicitly requires it."""
if canonical == "latex":
return """
- Avoid prose unless the user prompt explicitly requires it.""",
"latex": """
Language-specific guidance (latex):
- Output LaTeX math content only when completing LaTeX.
- If CURSOR_IN_FENCED_CODE_BLOCK=true and CURSOR_FENCE_LANGUAGE is latex/tex/katex:
- Output raw LaTeX lines only.
- Do not wrap with $ or $$."""
if canonical == "json":
return """
- Do not wrap with $ or $$.""",
"json": """
Language-specific guidance (json):
- Output strict JSON only (no comments, no trailing commas).
- Ensure valid quotes and braces."""
if canonical == "yaml":
return """
- Ensure valid quotes and braces.""",
"yaml": """
Language-specific guidance (yaml):
- Output valid YAML only.
- Use consistent indentation and avoid tabs."""
if canonical == "toml":
return """
- Use consistent indentation and avoid tabs.""",
"toml": """
Language-specific guidance (toml):
- Output valid TOML only.
- Keep key types consistent."""
if canonical == "ini":
return """
- Keep key types consistent.""",
"ini": """
Language-specific guidance (ini):
- Output valid INI only.
- Keep section headers and key=value pairs consistent."""
if canonical == "sql":
return """
- Keep section headers and key=value pairs consistent.""",
"sql": """
Language-specific guidance (sql):
- Output a single, valid SQL statement unless context requires multiple.
- Prefer ANSI SQL when dialect is unclear."""
if canonical == "bash":
return """
- Prefer ANSI SQL when dialect is unclear.""",
"bash": """
Language-specific guidance (bash):
- Output POSIX-compatible shell when possible.
- Avoid interactive prompts or destructive commands unless requested."""
if canonical == "powershell":
return """
- Avoid interactive prompts or destructive commands unless requested.""",
"powershell": """
Language-specific guidance (powershell):
- Output valid PowerShell commands.
- Avoid destructive commands unless explicitly requested."""
if canonical == "html":
return """
- Avoid destructive commands unless explicitly requested.""",
"html": """
Language-specific guidance (html):
- Output valid HTML only.
- Keep markup minimal and well-formed."""
if canonical == "css":
return """
- Keep markup minimal and well-formed.""",
"css": """
Language-specific guidance (css):
- Output valid CSS only.
- Use concise, readable selectors."""
if canonical == "diff":
return """
- Use concise, readable selectors.""",
"diff": """
Language-specific guidance (diff):
- Output a unified diff only.
- Ensure @@ hunk headers and +/- lines are consistent."""
if canonical == "regex":
return """
- Ensure @@ hunk headers and +/- lines are consistent.""",
"regex": """
Language-specific guidance (regex):
- Output the regex pattern only.
- Avoid delimiters unless explicitly requested."""
if canonical in {"javascript", "typescript"}:
return f"""
Language-specific guidance ({canonical}):
- Output valid {canonical} code.
- Prefer modern syntax and avoid prose unless comments are needed."""
if canonical in {"python", "go", "rust", "java", "kotlin", "swift", "ruby", "php", "lua", "c", "cpp", "csharp", "r", "matlab", "dart"}:
return f"""
Language-specific guidance ({canonical}):
- Output valid {canonical} code.
- Avoid prose unless context clearly expects comments or docstrings."""
if canonical == "text":
return """
- Avoid delimiters unless explicitly requested.""",
"text": """
Language-specific guidance (text):
- Output plain text only.
- Avoid markdown formatting unless explicitly asked."""
if canonical == "xml":
return """
- Avoid markdown formatting unless explicitly asked.""",
"xml": """
Language-specific guidance (xml):
- Output well-formed XML only.
- Ensure matching tags and proper escaping."""
if canonical == "dockerfile":
return """
- Ensure matching tags and proper escaping.""",
"dockerfile": """
Language-specific guidance (dockerfile):
- Output valid Dockerfile instructions only.
- Keep layers minimal and ordered logically."""
if canonical == "makefile":
return """
- Keep layers minimal and ordered logically.""",
"makefile": """
Language-specific guidance (makefile):
- Output valid Makefile syntax only.
- Use tabs for recipe lines."""
return f"""
Language-specific guidance ({canonical}):
- Output valid {canonical} code.
- Use tabs for recipe lines.""",
}
_GENERIC_CODE = """
Language-specific guidance ({lang}):
- Output valid {lang} code.
- Avoid prose unless context clearly expects comments or docstrings."""
_JS_CODE = """
Language-specific guidance ({lang}):
- Output valid {lang} code.
- Prefer modern syntax and avoid prose unless comments are needed."""
def _language_guidance(language_id: str) -> str:
canonical = _canonical_language_id(language_id)
if canonical == "markdown":
return ""
guidance = _LANG_GUIDANCE.get(canonical)
if guidance:
return guidance
if canonical in _JS_LANGS:
return _JS_CODE.format(lang=canonical)
if canonical in _CODE_LANGS:
return _GENERIC_CODE.format(lang=canonical)
return _GENERIC_CODE.format(lang=canonical)
def build_inline_system_prompt(language_id: str = "markdown") -> str:
safe_language_id = _canonical_language_id(language_id)
@@ -330,124 +331,111 @@ def build_inline_system_prompt(language_id: str = "markdown") -> str:
Return only the insertion text that should be placed between PREFIX and SUFFIX.
Hard constraints you must follow:
1) Output-only contract:
- Output insertion text only.
- No explanations, no meta labels, no wrapper quotes around the whole answer.
CORE PRINCIPLE: Output insertion text only. No explanations, no meta labels, no wrapper quotes.
2) Strict math formatting (KaTeX):
- If you output any math expression, it must be strict KaTeX-compatible math.
- Every formula must be wrapped with either $...$ (inline) or $$...$$ (block).
- Never output bare formulas without $ or $$ wrappers.
- Exception: If CURSOR_IN_FENCED_CODE_BLOCK=true and CURSOR_FENCE_LANGUAGE is latex/tex/katex,
output raw LaTeX without $ or $$ wrappers.
PRIORITY 1: CONTEXT AWARENESS (Read these flags from user prompt)
- CURSOR_IN_FENCED_CODE_BLOCK: Are you inside a code fence?
- CURSOR_FENCE_LANGUAGE: What language is the current fence?
- PREFIX_ENDS_WITH_NEWLINE: Does prefix end with newline?
- SUFFIX_STARTS_WITH_NEWLINE: Does suffix start with newline?
- MERMAID_CONTEXT: Is this a Mermaid diagram context?
3) Strict code formatting:
- Read CURSOR_IN_FENCED_CODE_BLOCK from the user prompt.
- If CURSOR_IN_FENCED_CODE_BLOCK=true:
- You are already inside a fenced code block.
- Never output triple backticks.
- Output code lines only.
- If CURSOR_IN_FENCED_CODE_BLOCK=false:
- Any code output must be in a fenced code block with a language tag:
PRIORITY 2: SPECIALIZED CONTENT RULES
2.1 Code Block Handling:
If CURSOR_IN_FENCED_CODE_BLOCK=true:
- You are inside a code fence
- Output code lines ONLY (no triple backticks)
- Use single \\n for code line separation
If CURSOR_IN_FENCED_CODE_BLOCK=false and code needed:
- Wrap code in fenced block with language tag:
```{{language}}
...
code here
```
- Do not output code snippets as inline backticks.
- Choose the language tag from context (no default fallback tag instruction).
- Never use inline backticks for code snippets
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:
2.2 Math Formatting (KaTeX):
- Inline math: wrap with $...$
- Block math: wrap with $$...$$
- Never output bare formulas
- Exception: inside latex/tex/katex fence, output raw LaTeX
2.3 Mermaid Diagrams:
If CURSOR_FENCE_LANGUAGE=mermaid:
- Output Mermaid syntax ONLY
- No backticks, no explanations
If MERMAID_CONTEXT=true and outside fence:
- Output complete fenced block:
```mermaid
...
diagram syntax
```
- 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.
PRIORITY 3: MARKDOWN STRUCTURE
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).
3.1 Newline Semantics:
- Single \\n: soft break (same paragraph, renders as space or <br>)
- Double \\n\\n: hard break (new paragraph/block)
- Use \\n\\n for: new paragraphs, before headings, starting lists/tables
- Use \\n for: continuation within blocks (list items, table cells)
- Exception: inside code blocks, use \\n freely for code lines
7) OCR safety:
- PREFIX may include hidden OCR metadata tags like <OCR:...>.
- Never output any OCR tag.
- Never output OCR tag fragments such as <OCR:...>."""
3.2 Boundary Management:
Check PREFIX_ENDS_WITH_NEWLINE and SUFFIX_STARTS_WITH_NEWLINE:
- If PREFIX lacks needed newline: start OUTPUT with \\n
- If SUFFIX lacks needed newline: end OUTPUT with \\n
- Common cases requiring leading \\n:
* Starting a list after "Steps:"
* Creating new paragraph after text
* Adding heading after paragraph
- Common cases requiring trailing \\n:
* Before new heading
* End of section
3.3 Context Stitching:
- Never repeat text from SUFFIX beginning
- Match PREFIX tone, style, indentation
- Continue structures: lists, tables, quotes, headings
PRIORITY 4: HIDDEN CONTEXT
- OCR metadata like <OCR:...> is hidden context
- Never copy OCR tags to output
- Use OCR content as semantic hint only
"""
if language_guidance:
system_prompt = f"{system_prompt.rstrip()}\n{language_guidance.strip()}"
system_prompt = f"{system_prompt.rstrip()}\\n{language_guidance.strip()}"
return system_prompt.strip()
INLINE_EXAMPLES = """[EX01] Prose continuation
INLINE_EXAMPLES = """=== CATEGORY A: PROSE CONTINUATION ===
[EX01] Simple prose continuation
<PREFIX>The quick brown fox </PREFIX>
<SUFFIX>jumps over the lazy dog.</SUFFIX>
Expected OUTPUT:
moved quietly and then
moved quietly and then
[EX02] Avoid repeating suffix beginning
[EX02] Avoid repeating suffix
<PREFIX>Our launch plan starts with </PREFIX>
<SUFFIX>phase one, followed by phase two.</SUFFIX>
Expected OUTPUT:
careful internal testing before
careful internal testing before
WRONG: phase one starts with (repeats suffix)
[EX03] Continue markdown checklist
=== CATEGORY B: MARKDOWN STRUCTURES ===
[EX03] Continue checklist
<PREFIX>## TODO
- [ ] Buy milk
- [ ] </PREFIX>
<SUFFIX></SUFFIX>
Expected OUTPUT:
Write release notes and share draft with team
Write release notes and share draft with team
[EX04] Cursor outside code block, code must use fenced block
CURSOR_IN_FENCED_CODE_BLOCK=false
<PREFIX>Parse this JSON payload in Python:</PREFIX>
<SUFFIX></SUFFIX>
Expected OUTPUT:
```python
import json
data = json.loads(payload)
```
[EX05] Cursor inside fenced code block, do not output fences
CURSOR_IN_FENCED_CODE_BLOCK=true
<PREFIX>```python
def add(a, b):
return </PREFIX>
<SUFFIX>
```</SUFFIX>
Expected OUTPUT:
a + b
[EX06] Inline math must use $...$
<PREFIX>The derivative of x^2 is </PREFIX>
<SUFFIX>.</SUFFIX>
Expected OUTPUT:
$2x$
[EX07] Block math must use $$...$$
<PREFIX>We can write the Gaussian integral as:</PREFIX>
<SUFFIX></SUFFIX>
Expected OUTPUT:
$$
\\int_{-\\infty}^{\\infty} e^{-x^2}\\,dx = \\sqrt{\\pi}
$$
[EX08] Prefix misses boundary newline; add newline at output start
[EX04] Start list after header (PREFIX lacks newline)
PREFIX_ENDS_WITH_NEWLINE=false
<PREFIX>Deployment steps:</PREFIX>
<SUFFIX></SUFFIX>
@@ -456,21 +444,7 @@ Expected OUTPUT:
- Build artifact
- Deploy service
[EX09] Suffix misses boundary newline; add newline at output end
SUFFIX_STARTS_WITH_NEWLINE=false
<PREFIX>Summary paragraph complete.</PREFIX>
<SUFFIX>## Next Section</SUFFIX>
Expected OUTPUT:
[EX10] OCR metadata exists but must never be emitted
<PREFIX>![whiteboard](img.png) <OCR:equation y = mx + b>
The relationship is </PREFIX>
<SUFFIX>.</SUFFIX>
Expected OUTPUT:
$y = mx + b$
[EX11] Continue markdown table with correct row shape
[EX05] Continue table row
<PREFIX>| Name | Score |
| --- | --- |
| Alice | 92 |
@@ -479,30 +453,91 @@ $y = mx + b$
Expected OUTPUT:
88 |
[EX12] Mixed text + math + code in one insertion
CURSOR_IN_FENCED_CODE_BLOCK=false
<PREFIX>Use the area formula and provide a tiny JS helper.</PREFIX>
[EX06] Start new paragraph
<PREFIX>First paragraph ends.</PREFIX>
<SUFFIX></SUFFIX>
Expected OUTPUT:
The area is $A = \\pi r^2$.
```javascript
const area = (r) => Math.PI * r * r;
Second paragraph starts.
WRONG: Second paragraph starts. (missing leading \\n\\n)
[EX07] Add newline before heading
PREFIX_ENDS_WITH_NEWLINE=false
<PREFIX>End of previous section.</PREFIX>
<SUFFIX>## Next Heading</SUFFIX>
Expected OUTPUT:
WRONG: (would join with heading without separation)
=== CATEGORY C: CODE BLOCKS ===
[EX08] Outside fence: wrap code in fence
CURSOR_IN_FENCED_CODE_BLOCK=false
<PREFIX>Parse this JSON payload in Python:</PREFIX>
<SUFFIX></SUFFIX>
Expected OUTPUT:
```python
import json
data = json.loads(payload)
```
WRONG: import json\\ndata = json.loads(payload) (no fence)
[EX13] Cursor inside mermaid fence: no backticks, mermaid lines only
[EX09] Inside fence: output code only
CURSOR_IN_FENCED_CODE_BLOCK=true
<PREFIX>```python
def add(a, b):
return </PREFIX>
<SUFFIX>
```</SUFFIX>
Expected OUTPUT:
a + b
WRONG: ```python\\nreturn a + b\\n``` (duplicate fences)
[EX10] Code inside fence uses single newline
CURSOR_IN_FENCED_CODE_BLOCK=true
<PREFIX>```python
def hello():</PREFIX>
<SUFFIX>
```</SUFFIX>
Expected OUTPUT:
print("Hello")
return True
(Note: single \\n between code lines, no markdown rules)
=== CATEGORY D: MATH ===
[EX11] Inline math
<PREFIX>The derivative of x^2 is </PREFIX>
<SUFFIX>.</SUFFIX>
Expected OUTPUT:
$2x$
WRONG: 2x (bare formula)
[EX12] Block math
<PREFIX>We can write the Gaussian integral as:</PREFIX>
<SUFFIX></SUFFIX>
Expected OUTPUT:
$$
\\int_{-\\infty}^{\\infty} e^{-x^2}\\,dx = \\sqrt{\\pi}
$$
WRONG: \\int... (bare formula without $$)
=== CATEGORY E: MERMAID ===
[EX13] Inside mermaid fence
CURSOR_FENCE_LANGUAGE=mermaid
CURSOR_IN_FENCED_CODE_BLOCK=true
<PREFIX>```mermaid
flowchart TD
A[Start] --> </PREFIX>
A[Start] --> </PREFIX>
<SUFFIX>
```</SUFFIX>
Expected OUTPUT:
B{Valid?}
B -->|Yes| C[Done]
WRONG: ```mermaid\\nB{Valid?}... (duplicate fence)
[EX14] Mermaid context outside fence: return full mermaid block
[EX14] Outside fence with mermaid context
CURSOR_IN_FENCED_CODE_BLOCK=false
MERMAID_CONTEXT=true
<PREFIX>Please provide a simple release pipeline diagram.</PREFIX>
@@ -510,8 +545,18 @@ MERMAID_CONTEXT=true
Expected OUTPUT:
```mermaid
flowchart LR
Build --> Test --> Deploy
```"""
Build --> Test --> Deploy
```
=== CATEGORY F: OCR METADATA ===
[EX15] Use OCR as context, never output
<PREFIX>![whiteboard](img.png) <OCR:equation y = mx + b>
The relationship is </PREFIX>
<SUFFIX>.</SUFFIX>
Expected OUTPUT:
$y = mx + b$
WRONG: <OCR:equation y = mx + b> (OCR tag in output)"""
def build_completion_prompts(
@@ -520,7 +565,7 @@ def build_completion_prompts(
language_id: str = "markdown",
location: str = "",
thinking_level: str = "low",
preferences: object = None,
preferences: UserPreferences | None = None,
) -> Tuple[str, str]:
safe_language_id = _canonical_language_id(language_id)
recent_prefix, recent_suffix = _prepare_context(prefix, suffix)
@@ -551,35 +596,49 @@ def build_completion_prompts(
preferences_instruction = f"\nUser Preferences:\n{preferences_instruction}"
user_prompt = f"""Current time: {current_time}{location_info}{preferences_instruction}
Reasoning hint: {thinking_level}
Editor language id: {safe_language_id}
Reasoning level: {thinking_level}
Editor language: {safe_language_id}
Completion state flags:
=== 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"}
Task:
- Produce the best insertion text at the cursor between PREFIX and SUFFIX.
- Keep insertion meaningful and non-empty.
- Keep insertion concise unless structure requires more content.
=== TASK ===
Produce the best insertion text between PREFIX and SUFFIX.
Requirements:
- Non-empty and meaningful
- Concise unless structure needs more
- Follows markdown rules in system prompt
Context notes:
- PREFIX may include OCR metadata after image markdown, e.g. ![alt](url) <OCR:description>.
- OCR metadata is hidden context and must never be copied into output.
- Preserve local style and formatting.
=== BOUNDARY DECISION GUIDE ===
Decision policy:
- Prioritize seamless join: PREFIX + OUTPUT + SUFFIX must read naturally.
- Do not repeat SUFFIX-leading text.
- If uncertain, prefer a complete short phrase/sentence with clear meaning.
Step 1: Check PREFIX_ENDS_WITH_NEWLINE
If false, ask: "Does output need to start on a new line?"
- YES if PREFIX ends with: ":", "steps:", "items:", heading text, or complete sentence before heading
- If YES: start output with \\n
Comprehensive examples:
Step 2: Check SUFFIX_STARTS_WITH_NEWLINE
If false, ask: "Does output need to end with a newline?"
- YES if SUFFIX starts with: heading (##), new paragraph, or list marker
- If YES: end output with \\n
Step 3: Choose newline type
- Use \\n\\n for: new paragraphs, before headings, starting lists
- Use \\n for: continuing within blocks, list items, table cells
- Exception: inside code fences, use \\n freely
=== CONTEXT NOTES ===
- OCR metadata (e.g., <OCR:description>) is hidden context, never copy to output
- Match PREFIX tone, style, and indentation
- Do not repeat text from SUFFIX beginning
=== EXAMPLES BY CATEGORY ===
{INLINE_EXAMPLES}
Now produce the insertion.
=== NOW COMPLETE THE TASK ===
<PREFIX>
{recent_prefix}
@@ -601,7 +660,7 @@ def build_prompt(
language_id: str = "markdown",
location: str = "",
thinking_level: str = "low",
preferences: object = None,
preferences: UserPreferences | None = None,
) -> str:
"""
Backward-compatible helper. Returns only the user prompt body.
+2 -1
View File
@@ -197,8 +197,9 @@ class ASRResponse(BaseModel):
def get_api_key(api_key: str):
from backend.main import API_KEY
import main
API_KEY = main.API_KEY
if api_key != API_KEY:
raise HTTPException(status_code=403, detail="API Key 无效")
return api_key
+113 -131
View File
@@ -1,38 +1,13 @@
<template>
<div class="doc-block-crepe" :class="{ collapsed: collapsedState }">
<div class="doc-header">
<div class="doc-accent"></div>
<div class="doc-icon">
<svg v-if="docType === 'pdf'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M8 13h5"/>
<path d="M8 17h8"/>
</svg>
<svg v-else-if="docType === 'docx'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="m8 13 2 4 2-4 2 4 2-4"/>
</svg>
<svg v-else-if="docType === 'pptx'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="12" rx="2"/>
<path d="M8 20h8"/>
<path d="M12 16v4"/>
<path d="M9 8h3a2 2 0 0 1 0 4H9z"/>
</svg>
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M8 13h8"/>
<path d="M8 17h5"/>
</svg>
<section class="doc-card" :class="{ 'is-collapsed': collapsedState }">
<header class="doc-card__header">
<div class="doc-card__badge">{{ typeLabel }}</div>
<div class="doc-card__meta">
<div class="doc-card__name">{{ docName }}</div>
<div class="doc-card__time">{{ displayTime }}</div>
</div>
<div class="doc-meta">
<div class="doc-name">{{ docName }}</div>
<div class="doc-subline">{{ typeLabel }} · {{ displayTime }}</div>
</div>
<div class="doc-actions">
<button type="button" class="action-btn" :title="collapsedState ? '展开文件' : '折叠文件'" @click="toggleCollapse">
<div class="doc-card__actions">
<button type="button" class="doc-card__btn" :title="collapsedState ? '展开文件' : '折叠文件'" @click="toggleCollapse">
<svg v-if="collapsedState" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
@@ -40,7 +15,7 @@
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<button type="button" class="action-btn action-btn-danger" title="删除文件" @click="props.onDelete?.()">
<button type="button" class="doc-card__btn doc-card__btn--danger" title="删除文件" @click="props.onDelete?.()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"/>
<path d="M8 6V4h8v2"/>
@@ -50,11 +25,11 @@
</svg>
</button>
</div>
</header>
<div v-show="!collapsedState" class="doc-card__body">
<div ref="editorRoot" class="doc-card__editor"></div>
</div>
<div v-show="!collapsedState" class="doc-editor">
<div ref="editorRoot" class="inner-crepe"></div>
</div>
</div>
</section>
</template>
<script setup>
@@ -82,14 +57,7 @@ const collapsedState = ref(Boolean(props.collapsed))
const currentContent = ref(props.content || '')
let crepe = null
let syncTimer = null
let applyingExternalContent = false
const displayTime = computed(() => {
if (!props.uploadTime) return '刚上传'
const date = new Date(props.uploadTime)
if (Number.isNaN(date.getTime())) return '刚上传'
return date.toLocaleString('zh-CN', { hour12: false })
})
let syncingExternal = false
const typeLabel = computed(() => {
if (props.docType === 'docx') return 'DOCX'
@@ -98,6 +66,13 @@ const typeLabel = computed(() => {
return 'TXT'
})
const displayTime = computed(() => {
if (!props.uploadTime) return '刚上传'
const date = new Date(props.uploadTime)
if (Number.isNaN(date.getTime())) return '刚上传'
return date.toLocaleString('zh-CN', { hour12: false })
})
const toggleCollapse = () => {
collapsedState.value = !collapsedState.value
props.onUpdateCollapsed?.(collapsedState.value)
@@ -107,7 +82,7 @@ const syncContent = () => {
if (!crepe) return
if (syncTimer) clearTimeout(syncTimer)
syncTimer = setTimeout(async () => {
if (!crepe || applyingExternalContent) return
if (!crepe || syncingExternal) return
const markdown = await crepe.getMarkdown()
currentContent.value = markdown
props.onUpdateContent?.(markdown)
@@ -115,22 +90,23 @@ const syncContent = () => {
}
const syncExternalContent = async (nextValue) => {
const value = nextValue || ''
if (!crepe) {
currentContent.value = nextValue || ''
currentContent.value = value
return
}
if ((nextValue || '') === currentContent.value) return
applyingExternalContent = true
if (value === currentContent.value) return
syncingExternal = true
try {
crepe.editor.action(replaceAll(nextValue || ''))
currentContent.value = nextValue || ''
crepe.editor.action(replaceAll(value))
currentContent.value = value
} finally {
applyingExternalContent = false
syncingExternal = false
}
}
watch(() => props.content, (nextValue) => {
void syncExternalContent(nextValue || '')
void syncExternalContent(nextValue)
})
watch(() => props.collapsed, (nextValue) => {
@@ -169,7 +145,6 @@ onMounted(async () => {
crepe.editor.use(copilotConfigCtx)
crepe.editor.use(copilotGhostMark)
crepe.editor.use(copilotPlugin)
await crepe.create()
crepe.on((listener) => {
@@ -201,129 +176,136 @@ onUnmounted(() => {
</script>
<style scoped>
.doc-block-crepe {
position: relative;
margin: 14px 0;
border: 1px solid color-mix(in srgb, var(--panel-border) 72%, transparent);
border-radius: 18px;
.doc-card {
width: 100%;
max-width: 100%;
margin: 8px 0;
border-radius: 12px;
border: 1px solid rgba(59, 130, 246, 0.12);
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06), 0 1px 3px rgba(0, 0, 0, 0.04);
overflow: hidden;
background: linear-gradient(180deg, color-mix(in srgb, var(--panel-bg) 82%, transparent) 0%, color-mix(in srgb, var(--crepe-color-surface-low) 88%, transparent) 100%);
box-shadow: 0 18px 38px rgba(15, 23, 42, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.08);
backdrop-filter: blur(14px);
backdrop-filter: blur(10px);
position: relative;
}
.doc-header {
.doc-card__header {
display: grid;
grid-template-columns: 4px 24px minmax(0, 1fr) auto;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: linear-gradient(135deg, color-mix(in srgb, var(--btn-bg) 76%, transparent) 0%, color-mix(in srgb, var(--crepe-color-surface) 78%, transparent) 100%);
border-bottom: 1px solid color-mix(in srgb, var(--panel-border) 76%, transparent);
padding: 8px 12px;
border-bottom: 1px solid rgba(59, 130, 246, 0.1);
background: rgba(255, 255, 255, 0.6);
}
.doc-accent {
width: 4px;
height: 36px;
.doc-card__badge {
min-width: 48px;
padding: 4px 10px;
border-radius: 999px;
background: linear-gradient(180deg, #4f8cff 0%, #7dc1ff 100%);
box-shadow: 0 0 18px rgba(79, 140, 255, 0.32);
background: linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%);
color: #fff;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-align: center;
box-shadow: 0 2px 6px rgba(59, 130, 246, 0.2);
}
.doc-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--btn-fg);
}
.doc-meta {
.doc-card__meta {
min-width: 0;
}
.doc-name {
font-size: 14px;
.doc-card__name {
color: #1e293b;
font-size: 13px;
font-weight: 600;
color: var(--app-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.doc-subline {
.doc-card__time {
margin-top: 2px;
font-size: 11px;
color: var(--muted-text);
color: #64748b;
font-size: 10px;
}
.doc-actions {
.doc-card__actions {
display: flex;
align-items: center;
gap: 6px;
gap: 4px;
}
.action-btn {
width: 30px;
height: 30px;
border: 1px solid color-mix(in srgb, var(--panel-border) 72%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--btn-bg) 84%, transparent);
color: var(--btn-fg);
cursor: pointer;
.doc-card__btn {
width: 26px;
height: 26px;
border: 1px solid rgba(59, 130, 246, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.5);
color: #64748b;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.14s ease, background-color 0.14s ease, border-color 0.14s ease;
cursor: pointer;
transition: all 0.15s ease;
}
.action-btn:hover {
transform: translateY(-1px);
background: var(--btn-hover-bg);
border-color: var(--btn-hover-bg);
color: var(--btn-hover-fg);
.doc-card__btn:hover {
background: rgba(59, 130, 246, 0.1);
border-color: rgba(59, 130, 246, 0.25);
color: #3b82f6;
}
.action-btn-danger:hover {
background: rgba(220, 38, 38, 0.12);
border-color: rgba(220, 38, 38, 0.22);
color: #dc2626;
.doc-card__btn--danger:hover {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.doc-editor {
padding: 10px 12px 12px;
.doc-card__body {
padding: 8px 10px;
background: rgba(248, 250, 252, 0.5);
}
.inner-crepe {
border-radius: 14px;
.doc-card__editor {
min-height: 48px;
border-radius: 8px;
border: 1px solid rgba(59, 130, 246, 0.08);
background: rgba(255, 255, 255, 0.6);
overflow: hidden;
background: color-mix(in srgb, var(--crepe-color-background) 78%, transparent);
border: 1px solid color-mix(in srgb, var(--panel-border) 68%, transparent);
}
.inner-crepe :deep(.milkdown) {
.doc-card__editor :deep(.milkdown) {
background: transparent !important;
}
.inner-crepe :deep(.milkdown__main),
.inner-crepe :deep(.milkdown__editor) {
.doc-card__editor :deep(.milkdown__main),
.doc-card__editor :deep(.milkdown__editor) {
margin: 0 !important;
padding: 0 !important;
}
.inner-crepe :deep(.ProseMirror) {
min-height: 92px;
padding: 10px 12px 14px !important;
font-size: 14px !important;
line-height: 1.7;
.doc-card__editor :deep(.ProseMirror) {
min-height: 80px;
padding: 10px 12px 12px !important;
font-size: 13px !important;
line-height: 1.6;
}
.inner-crepe :deep(.ProseMirror h1),
.inner-crepe :deep(.ProseMirror h2),
.inner-crepe :deep(.ProseMirror h3),
.inner-crepe :deep(.ProseMirror p),
.inner-crepe :deep(.ProseMirror li),
.inner-crepe :deep(.ProseMirror blockquote),
.inner-crepe :deep(.ProseMirror code) {
font-size: inherit;
.doc-card__editor :deep(.ProseMirror > *:last-child) {
margin-bottom: 0;
}
.doc-card__editor :deep(.ProseMirror p:first-child) {
margin-top: 0;
}
.doc-card__editor :deep(.milkdown__toolbar),
.doc-card__editor :deep(.milkdown__menu),
.doc-card__editor :deep(.milkdown__statusbar),
.doc-card__editor :deep(.milkdown-slate-toolbar),
.doc-card__editor :deep(.milkdown-bubble-menu) {
display: none !important;
}
</style>
+27 -29
View File
@@ -107,78 +107,76 @@ const downloadDoc = () => {
<style scoped>
.doc-block {
margin: 12px 0;
border-radius: 8px;
margin: 8px 0;
border-radius: 10px;
overflow: hidden;
background: var(--crepe-color-surface-low);
border: 1px solid var(--panel-border);
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(12px);
border: 1px solid rgba(59, 130, 246, 0.15);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
}
.doc-block.collapsed .doc-content {
display: none;
}
/* 深色条 */
.doc-header {
display: flex;
align-items: center;
padding: 10px 12px;
background: var(--crepe-color-surface);
border-bottom: 1px solid var(--panel-border);
gap: 10px;
padding: 6px 10px;
background: rgba(255, 255, 255, 0.85);
border-bottom: 1px solid rgba(59, 130, 246, 0.12);
gap: 8px;
}
/* 文件类型icon */
.doc-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--crepe-color-primary);
color: #3b82f6;
}
/* 文件名 */
.doc-name {
flex: 1;
font-size: 14px;
font-size: 13px;
font-weight: 500;
color: var(--crepe-color-on-surface);
color: #1e293b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 操作按钮 */
.doc-actions {
display: flex;
align-items: center;
gap: 4px;
gap: 2px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
width: 24px;
height: 24px;
padding: 0;
border: none;
background: transparent;
color: var(--crepe-color-on-surface-variant);
color: #64748b;
cursor: pointer;
border-radius: 4px;
opacity: 0.7;
border-radius: 6px;
opacity: 0.75;
}
.action-btn:hover {
background: var(--crepe-color-hover);
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
opacity: 1;
}
/* 浅色块:文档内容 */
.doc-content {
padding: 12px;
background: var(--crepe-color-surface-low);
max-height: 400px;
padding: 8px 10px;
background: rgba(248, 250, 252, 0.6);
max-height: 240px;
overflow-y: auto;
}
@@ -186,9 +184,9 @@ const downloadDoc = () => {
margin: 0;
padding: 0;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
font-size: 13px;
line-height: 1.6;
color: var(--crepe-color-on-surface);
font-size: 12px;
line-height: 1.5;
color: #334155;
white-space: pre-wrap;
word-break: break-word;
}
+333 -56
View File
@@ -46,7 +46,7 @@
</svg>
<span class="btn-tooltip">{{ t('uploadFile') }}</span>
</button>
<input type="file" ref="uploadFileInputRef" @change="handleUploadFile" accept=".txt,.docx,.pptx,.pdf,text/plain,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.presentationml.presentation" style="display:none">
<input type="file" ref="uploadFileInputRef" @change="handleUploadFile" accept=".txt,.json,.toml,.yaml,.yml,.docx,.pptx,.pdf,text/plain,application/json,text/yaml,text/x-yaml,application/x-yaml,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.presentationml.presentation" multiple style="display:none">
<button
type="button"
@@ -132,9 +132,34 @@
<span class="btn-tooltip">{{ aiButtonLabel }}</span>
</button>
<div class="size-indicator" :class="{ 'over-limit': isOverLimit }" aria-live="polite">
{{ sizeInKB }} KB
</div>
<div
class="size-indicator"
:class="{ 'over-limit': isOverLimit }"
@mouseenter="showSizeTooltip = true"
@mouseleave="showSizeTooltip = false"
>
<svg
class="warning-icon"
:class="{ 'warning-icon--visible': isOverLimit }"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
{{ sizeInKB }} KB
<Transition name="tooltip-fade">
<div v-if="showSizeTooltip && isOverLimit" class="size-tooltip">
<strong>文档超过32KB限制</strong>
<span>AI补全功能已暂停建议精简内容或分段处理</span>
</div>
</Transition>
</div>
</div>
<div v-if="showUrlDialog" class="url-dialog-overlay" @click.self="showUrlDialog = false">
@@ -153,6 +178,17 @@
</div>
</div>
</div>
<div v-if="uploadProgress" class="upload-progress-overlay">
<div class="upload-progress-dialog">
<div class="spinner"></div>
<p>{{ t('uploading') || '正在上传文件' }}</p>
<p class="progress-text">
{{ uploadProgress.current }} / {{ uploadProgress.total }}
</p>
<p class="filename">{{ uploadProgress.filename }}</p>
</div>
</div>
</template>
<script setup>
@@ -170,7 +206,7 @@ import { useSettingsStore } from '../stores/settings'
import { OCR_URL, EXPORT_PDF_URL } from '../utils/config.js'
import { convertFileToMarkdown } from '../utils/convert.js'
import { setOcrCache, clearOcrCache, clearAllOcrCache, IMAGE_SIZE_LIMIT, calculateImageHash, getOcrByHash, setOcrByHash } from '../utils/ocrCache.js'
import { DOC_BLOCK_NODE_TYPE, buildLegacyDocBlock, getDocTypeFromFilename, isSupportedDocFile, transformDocBlockMarkdownForClipboard, transformLegacyDocBlocksForExport, transformSpecialDocBlocksToLegacy } from '../utils/docBlock.js'
import { DOC_BLOCK_NODE_TYPE, getDocTypeFromFilename, isSupportedDocFile, transformDocBlockMarkdownForClipboard, transformLegacyDocBlocksForExport, transformSpecialDocBlocksToLegacy } from '../utils/docBlock.js'
const emit = defineEmits(['update:markdown'])
const settings = useSettingsStore()
@@ -187,10 +223,12 @@ const contentSize = ref(0)
const showImageDropdown = ref(false)
const showExportDropdown = ref(false)
const showUrlDialog = ref(false)
const showSizeTooltip = ref(false)
const imageUrl = ref('')
const canUndo = ref(false)
const canRedo = ref(false)
const isDocUploadDisabled = ref(false)
const uploadProgress = ref(null)
const isOverLimit = computed(() => contentSize.value > SIZE_LIMIT)
const sizeInKB = computed(() => Math.floor(contentSize.value / 1024))
const undoLabel = computed(() => t('undo') || 'Undo')
@@ -220,8 +258,8 @@ 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 CONVERT_EXT_RE = /\.(docx|pptx|pdf)$/i
const TEXT_EXT_RE = /\.txt$/i
const TEXT_MIME_TYPES = new Set(['text/plain'])
const TEXT_EXT_RE = /\.(txt|json|toml|ya?ml)$/i
const TEXT_MIME_TYPES = new Set(['text/plain', 'application/json', 'text/yaml', 'text/x-yaml', 'application/x-yaml'])
const CONVERT_MIME_TYPES = new Set([
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
@@ -887,8 +925,81 @@ const insertMarkdownAtCursor = (markdown) => {
const insertDocBlockAtCursor = (attrs) => {
if (!crepe) return
const markdown = buildLegacyDocBlock(attrs)
insertMarkdownAtCursor(`\n${markdown}\n`)
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const { state } = view
const { from, to } = state.selection
const docBlockType = state.schema.nodes[DOC_BLOCK_NODE_TYPE]
if (!docBlockType) return
const blockNode = docBlockType.create({
docType: attrs.docType,
docName: attrs.docName,
uploadTime: attrs.uploadTime,
content: attrs.content,
collapsed: Boolean(attrs.collapsed),
})
const tr = state.tr.replaceRangeWith(from, to, blockNode)
const nextPos = Math.min(from + blockNode.nodeSize, tr.doc.content.size)
tr.setSelection(Selection.near(tr.doc.resolve(nextPos), 1))
view.dispatch(tr.scrollIntoView())
view.focus()
})
}
const insertEmptyParagraph = () => {
if (!crepe) return
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const { state } = view
const { from, to } = state.selection
const tr = state.tr.insertText('\n\n', from, to)
const nextPos = from + 2
tr.setSelection(Selection.near(tr.doc.resolve(nextPos), 1))
view.dispatch(tr)
})
}
const insertMultipleDocBlocks = (blocks) => {
if (!crepe || blocks.length === 0) return
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
let tr = view.state.tr
const docBlockType = view.state.schema.nodes[DOC_BLOCK_NODE_TYPE]
if (!docBlockType) return
let currentPos = tr.selection.from
blocks.forEach((block, index) => {
const maxPos = tr.doc.content.size
if (index > 0) {
const insertPos = Math.min(currentPos, maxPos)
tr = tr.insertText('\n', insertPos, insertPos)
currentPos = insertPos + 1
}
const blockNode = docBlockType.create({
docType: block.docType,
docName: block.docName,
uploadTime: block.uploadTime,
content: block.content,
collapsed: Boolean(block.collapsed),
})
const insertBlockPos = Math.min(currentPos, tr.doc.content.size)
tr = tr.replaceRangeWith(insertBlockPos, insertBlockPos, blockNode)
currentPos = insertBlockPos + blockNode.nodeSize
})
const finalPos = Math.min(currentPos, tr.doc.content.size)
if (finalPos >= 0 && finalPos <= tr.doc.content.size) {
tr.setSelection(Selection.near(tr.doc.resolve(finalPos), 1))
}
view.dispatch(tr.scrollIntoView())
view.focus()
})
}
const triggerFileUpload = () => {
@@ -897,51 +1008,101 @@ const triggerFileUpload = () => {
}
const handleUploadFile = async (event) => {
const input = event.target
const file = input.files?.[0]
if (!file) return
const input = event.target
const files = Array.from(input.files || [])
if (files.length === 0) return
try {
if (!isSupportedDocFile(file)) {
alert(t('uploadDocTypeWarning') || '仅支持 txt、docx、pptx、pdf 格式的文档')
return
}
const BATCH_LIMIT = 10
const MAX_FILE_SIZE = 50 * 1024 * 1024
if (isDocUploadDisabled.value || !crepe) {
alert(t('uploadDocInBlockWarning') || '当前光标位置不能插入文件')
return
}
if (files.length > BATCH_LIMIT) {
alert(t('uploadBatchLimit') || `一次最多上传${BATCH_LIMIT}个文件`)
input.value = ''
return
}
const docType = getDocTypeFromFilename(file.name)
let content = ''
if (isTextFile(file)) {
content = await file.text()
} else if (isConvertibleFile(file)) {
content = await convertFileToMarkdown(file)
} else {
alert(t('uploadDocTypeWarning') || '仅支持 txt、docx、pptx、pdf 格式的文档')
return
}
if (!content) {
throw new Error('文档解析结果为空')
}
clearCurrentGhost()
insertDocBlockAtCursor({
docType,
docName: file.name || `document.${docType}`,
uploadTime: new Date().toISOString(),
collapsed: false,
content,
})
} catch (e) {
const message = e instanceof Error ? e.message : ''
warnConvertError(message)
} finally {
input.value = ''
for (const file of files) {
if (file.size > MAX_FILE_SIZE) {
alert(t('uploadSizeLimit') || `${file.name} 超过${MAX_FILE_SIZE / 1024 / 1024}MB限制`)
input.value = ''
return
}
}
for (const file of files) {
if (!isSupportedDocFile(file)) {
alert(t('uploadDocTypeWarning') || '仅支持 txt、docx、pptx、pdf 格式的文档')
input.value = ''
return
}
}
if (isDocUploadDisabled.value || !crepe) {
alert(t('uploadDocInBlockWarning') || '当前光标位置不能插入文件')
input.value = ''
return
}
const total = files.length
uploadProgress.value = { current: 0, total, filename: '' }
const results = []
const errors = []
for (let index = 0; index < files.length; index++) {
const file = files[index]
uploadProgress.value = { current: index + 1, total, filename: file.name }
try {
const docType = getDocTypeFromFilename(file.name)
let content = ''
if (isTextFile(file)) {
content = await file.text()
} else if (isConvertibleFile(file)) {
content = await convertFileToMarkdown(file)
} else {
throw new Error('不支持的文件类型')
}
if (!content) {
throw new Error('文档解析结果为空')
}
results.push({
docType,
docName: file.name || `document.${docType}`,
content,
index,
})
} catch (e) {
const message = e instanceof Error ? e.message : ''
errors.push({ filename: file.name, message })
}
}
uploadProgress.value = null
clearCurrentGhost()
results.sort((a, b) => a.index - b.index)
const blocksToInsert = results.map(({ docType, docName, content }) => ({
docType,
docName,
content,
uploadTime: new Date().toISOString(),
collapsed: false,
}))
insertMultipleDocBlocks(blocksToInsert)
if (errors.length > 0) {
const failCount = errors.length
const errorMsgs = errors.map(e => `${e.filename}: ${e.message}`).join('\n')
alert(`上传失败 ${failCount} 个文件:\n\n${errorMsgs}`)
}
input.value = ''
}
const handleImageUpload = async (event) => {
@@ -1109,14 +1270,81 @@ onUnmounted(() => {
}
.size-indicator {
font-size: 10px;
color: var(--muted-text);
text-align: center;
margin-top: 4px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 4px 10px;
font-size: 10px;
color: var(--muted-text);
border-radius: 12px;
transition: all 0.3s ease;
cursor: default;
}
.size-indicator.over-limit {
color: var(--danger-text);
color: var(--danger-text);
background: rgba(220, 38, 38, 0.08);
animation: pulse-warning 2s ease-in-out infinite;
}
.warning-icon {
flex-shrink: 0;
opacity: 0;
transition: opacity 0.2s ease;
}
.warning-icon--visible {
opacity: 1;
}
@keyframes pulse-warning {
0%, 100% {
opacity: 1;
background: rgba(220, 38, 38, 0.08);
}
50% {
opacity: 0.75;
background: rgba(220, 38, 38, 0.12);
}
}
.size-tooltip {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
padding: 8px 12px;
background: var(--tooltip-bg);
color: var(--tooltip-fg);
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
box-shadow: var(--panel-shadow);
display: flex;
flex-direction: column;
gap: 2px;
}
.size-tooltip strong {
font-weight: 600;
}
.size-tooltip span {
opacity: 0.85;
font-size: 11px;
}
.tooltip-fade-enter-active,
.tooltip-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.tooltip-fade-enter-from,
.tooltip-fade-leave-to {
opacity: 0;
transform: translateY(4px);
}
.action-btn {
@@ -1433,7 +1661,56 @@ onUnmounted(() => {
.copilot-ghost-block pre,
.copilot-ghost-block code {
background-color: var(--ghost-code-bg);
background-color: var(--ghost-code-bg);
}
.upload-progress-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.upload-progress-dialog {
background: var(--editor-bg, white);
padding: 24px 32px;
border-radius: 8px;
text-align: center;
max-width: 400px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.spinner {
width: 40px;
height: 40px;
margin: 0 auto 16px;
border: 3px solid #f3f3f3;
border-top: 3px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.progress-text {
font-size: 18px;
font-weight: 600;
margin: 8px 0;
}
.filename {
font-size: 14px;
color: #666;
word-break: break-all;
}
</style>
+6 -1
View File
@@ -743,7 +743,12 @@ export function interruptCopilot(view: EditorView): void {
}
export function checkSizeLimit(view: EditorView): { size: number; overLimit: boolean } {
const size = view.state.doc.content.size
let size = view.state.doc.content.size
view.state.doc.descendants((node) => {
if (node.type.name === 'doc_block' && node.attrs.content) {
size += String(node.attrs.content).length
}
})
return { size, overLimit: size > SIZE_LIMIT }
}
+5 -33
View File
@@ -1,4 +1,4 @@
import { API_URL } from './config.js'
import { API_URL, API_KEY } from './config.js'
import { useSettingsStore } from '../stores/settings'
function generateRequestId() {
@@ -30,6 +30,7 @@ async function sendCancelRequest(cancelUrl, requestId, reason) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
},
body: JSON.stringify({
request_id: requestId,
@@ -73,6 +74,7 @@ export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl
const headers = {
'Content-Type': 'application/json',
'X-Request-Id': requestId,
'X-API-Key': API_KEY,
}
const body = {
@@ -100,38 +102,8 @@ export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl
throw new Error(`HTTP ${res.status}: ${errorText}`)
}
const reader = res.body?.getReader()
if (!reader) {
throw new Error('No reader available')
}
let text = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += new TextDecoder().decode(value)
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const jsonStr = line.slice(6).trim()
if (!jsonStr) continue
try {
const data = JSON.parse(jsonStr)
if (data.content) {
text += data.content
}
if (data.done || data.error) break
} catch (e) {
// skip invalid lines
}
}
}
return text
const data = await res.json()
return data.content || ''
} catch (e) {
if (e.name === 'AbortError') {
// ignore abort
+1
View File
@@ -6,3 +6,4 @@ export const API_URL = import.meta.env.VITE_API_URL || `${API_BASE_URL}/v1/compl
export const OCR_URL = import.meta.env.VITE_OCR_URL || `${API_BASE_URL}/v1/ocr`
export const CONVERT_URL = import.meta.env.VITE_CONVERT_URL || `${API_BASE_URL}/v1/convert`
export const EXPORT_PDF_URL = import.meta.env.VITE_EXPORT_PDF_URL || '/v1/export/pdf'
export const API_KEY = import.meta.env.VITE_API_KEY || 'your-secret-key-here'
+14
View File
@@ -9,6 +9,9 @@ const HEADER_SEPARATOR = '\n---\n'
export function normalizeDocType(value = '') {
const lower = String(value || '').trim().toLowerCase()
if (lower === 'txt' || lower === 'text' || lower === 'plain') return 'txt'
if (lower === 'json') return 'json'
if (lower === 'toml') return 'toml'
if (lower === 'yaml' || lower === 'yml') return 'yaml'
if (lower === 'doc' || lower === 'docx' || lower === 'word') return 'docx'
if (lower === 'ppt' || lower === 'pptx' || lower === 'powerpoint') return 'pptx'
if (lower === 'pdf') return 'pdf'
@@ -20,6 +23,9 @@ export function getDocTypeFromFilename(name = '') {
if (lower.endsWith('.docx')) return 'docx'
if (lower.endsWith('.pptx')) return 'pptx'
if (lower.endsWith('.pdf')) return 'pdf'
if (lower.endsWith('.json')) return 'json'
if (lower.endsWith('.toml')) return 'toml'
if (lower.endsWith('.yaml') || lower.endsWith('.yml')) return 'yaml'
return 'txt'
}
@@ -29,10 +35,18 @@ export function isSupportedDocFile(file) {
const type = String(file.type || '').toLowerCase()
return (
name.endsWith('.txt') ||
name.endsWith('.json') ||
name.endsWith('.toml') ||
name.endsWith('.yaml') ||
name.endsWith('.yml') ||
name.endsWith('.docx') ||
name.endsWith('.pptx') ||
name.endsWith('.pdf') ||
type === 'text/plain' ||
type === 'application/json' ||
type === 'text/yaml' ||
type === 'text/x-yaml' ||
type === 'application/x-yaml' ||
type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
type === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
type === 'application/pdf'
+20 -11
View File
@@ -36,15 +36,18 @@ export const translations = {
uploadImg: 'Upload Image',
uploadFile: 'Upload File',
uploadDoc: 'Upload Document',
uploadDocTypeWarning: 'Only txt, docx, pptx, pdf formats are supported.',
uploadDocTypeWarning: 'Only txt, json, toml, yaml, docx, pptx, pdf formats are supported.',
uploadDocSizeWarning: 'File size cannot exceed 10MB.',
uploadDocInBlockWarning: 'Cannot insert document inside an existing document block. Please move cursor outside.',
uploadDocError: 'Document conversion failed:',
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
uploadFileError: 'File upload failed.',
uploadConvertError: 'File conversion failed.',
enableAI: 'Enable AI',
uploadFileError: 'File upload failed.',
uploadConvertError: 'File conversion failed.',
uploadBatchLimit: 'Maximum 10 files at once',
uploadSizeLimit: 'File exceeds 50MB limit',
uploading: 'Uploading files...',
enableAI: 'Enable AI',
disableAI: 'Disable AI',
insertUrl: 'Insert Image from URL',
insert: 'Insert',
@@ -90,15 +93,18 @@ export const translations = {
uploadImg: '上传图片',
uploadFile: '上传文件',
uploadDoc: '上传文档',
uploadDocTypeWarning: '仅支持 txt、docx、pptx、pdf 格式的文档',
uploadDocTypeWarning: '仅支持 txt、json、toml、yaml、docx、pptx、pdf 格式的文档',
uploadDocSizeWarning: '文件大小不能超过 10MB',
uploadDocInBlockWarning: '无法在现有文档块内插入新文档,请将光标移到文档外部',
uploadDocError: '文档转换失败:',
uploadFileTypeWarning: '不支持的文件类型。仅支持 doc/docx/ppt/pptx/pdf/zip、图片、txt/json。',
uploadMdTypeWarning: '仅支持 Markdown.md)和图片文件。',
uploadFileError: '文件上传失败',
uploadConvertError: '文件转换失败',
enableAI: '启用 AI',
uploadFileError: '文件上传失败',
uploadConvertError: '文件转换失败',
uploadBatchLimit: '一次最多上传10个文件',
uploadSizeLimit: '文件超过50MB限制',
uploading: '正在上传文件...',
enableAI: '启用 AI',
disableAI: '禁用 AI',
insertUrl: '通过 URL 插入图片',
insert: '插入',
@@ -145,9 +151,12 @@ export const translations = {
uploadFile: 'Upload File',
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
uploadFileError: 'File upload failed.',
uploadConvertError: 'File conversion failed.',
enableAI: 'AIを有効化',
uploadFileError: 'File upload failed.',
uploadConvertError: 'File conversion failed.',
uploadBatchLimit: 'Maximum 10 files at once',
uploadSizeLimit: 'File exceeds 50MB limit',
uploading: 'Uploading files...',
enableAI: 'AIを有効化',
disableAI: 'AIを無効化',
insertUrl: 'URLから画像を挿入',
insert: '挿入',