feat: 批量上传支持及prompt优化

- 支持多文件批量上传,一次最多10个
- 新增json/toml/yaml格式支持
- 优化inline补全prompt结构,增加边界决策指南
- size计算包含doc_block内容长度
- 超限时显示警告tooltip
This commit is contained in:
2026-04-05 11:40:56 +08:00
parent 7ed199aaf1
commit 9904b9bd78
5 changed files with 542 additions and 197 deletions
+189 -131
View File
@@ -331,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. Return only the insertion text that should be placed between PREFIX and SUFFIX.
Hard constraints you must follow: CORE PRINCIPLE: Output insertion text only. No explanations, no meta labels, no wrapper quotes.
1) Output-only contract:
- Output insertion text only.
- No explanations, no meta labels, no wrapper quotes around the whole answer.
2) Strict math formatting (KaTeX): PRIORITY 1: CONTEXT AWARENESS (Read these flags from user prompt)
- If you output any math expression, it must be strict KaTeX-compatible math. - CURSOR_IN_FENCED_CODE_BLOCK: Are you inside a code fence?
- Every formula must be wrapped with either $...$ (inline) or $$...$$ (block). - CURSOR_FENCE_LANGUAGE: What language is the current fence?
- Never output bare formulas without $ or $$ wrappers. - PREFIX_ENDS_WITH_NEWLINE: Does prefix end with newline?
- Exception: If CURSOR_IN_FENCED_CODE_BLOCK=true and CURSOR_FENCE_LANGUAGE is latex/tex/katex, - SUFFIX_STARTS_WITH_NEWLINE: Does suffix start with newline?
output raw LaTeX without $ or $$ wrappers. - MERMAID_CONTEXT: Is this a Mermaid diagram context?
3) Strict code formatting: PRIORITY 2: SPECIALIZED CONTENT RULES
- Read CURSOR_IN_FENCED_CODE_BLOCK from the user prompt.
- If CURSOR_IN_FENCED_CODE_BLOCK=true: 2.1 Code Block Handling:
- You are already inside a fenced code block. If CURSOR_IN_FENCED_CODE_BLOCK=true:
- Never output triple backticks. - You are inside a code fence
- Output code lines only. - Output code lines ONLY (no triple backticks)
- If CURSOR_IN_FENCED_CODE_BLOCK=false: - Use single \\n for code line separation
- Any code output must be in a fenced code block with a language tag:
If CURSOR_IN_FENCED_CODE_BLOCK=false and code needed:
- Wrap code in fenced block with language tag:
```{{language}} ```{{language}}
... code here
``` ```
- Do not output code snippets as inline backticks. - Never use inline backticks for code snippets
- Choose the language tag from context (no default fallback tag instruction).
4) Mermaid-specific completion rules: 2.2 Math Formatting (KaTeX):
- Read CURSOR_FENCE_LANGUAGE and MERMAID_CONTEXT from the user prompt. - Inline math: wrap with $...$
- If CURSOR_FENCE_LANGUAGE=mermaid: - Block math: wrap with $$...$$
- Output Mermaid statements only. - Never output bare formulas
- Never output triple backticks. - Exception: inside latex/tex/katex fence, output raw LaTeX
- Never output prose explanations.
- If CURSOR_IN_FENCED_CODE_BLOCK=false and MERMAID_CONTEXT=true: 2.3 Mermaid Diagrams:
- Output a complete Mermaid fenced block: 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 ```mermaid
... diagram syntax
``` ```
- Keep Mermaid syntax valid and concise.
- Never mix Mermaid code and explanatory narration in one output.
5) Boundary newline repair: PRIORITY 3: MARKDOWN STRUCTURE
- 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.
6) Context stitching: 3.1 Newline Semantics:
- Do not repeat text that already appears at the start of SUFFIX. - Single \\n: soft break (same paragraph, renders as space or <br>)
- Preserve nearby language, tone, punctuation, indentation, and markdown structure. - Double \\n\\n: hard break (new paragraph/block)
- Continue existing structures naturally (lists, tables, block quotes, headings). - 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: 3.2 Boundary Management:
- PREFIX may include hidden OCR metadata tags like <OCR:...>. Check PREFIX_ENDS_WITH_NEWLINE and SUFFIX_STARTS_WITH_NEWLINE:
- Never output any OCR tag. - If PREFIX lacks needed newline: start OUTPUT with \\n
- Never output OCR tag fragments such as <OCR:...>.""" - 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: 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() 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> <PREFIX>The quick brown fox </PREFIX>
<SUFFIX>jumps over the lazy dog.</SUFFIX> <SUFFIX>jumps over the lazy dog.</SUFFIX>
Expected OUTPUT: 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> <PREFIX>Our launch plan starts with </PREFIX>
<SUFFIX>phase one, followed by phase two.</SUFFIX> <SUFFIX>phase one, followed by phase two.</SUFFIX>
Expected OUTPUT: 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 <PREFIX>## TODO
- [ ] Buy milk - [ ] Buy milk
- [ ] </PREFIX> - [ ] </PREFIX>
<SUFFIX></SUFFIX> <SUFFIX></SUFFIX>
Expected OUTPUT: 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 [EX04] Start list after header (PREFIX lacks newline)
CURSOR_IN_FENCED_CODE_BLOCK=false
<PREFIX>Parse this JSON payload in Python:</PREFIX>
<SUFFIX></SUFFIX>
Expected OUTPUT:
```python
import json
data = json.loads(payload)
```
[EX05] Cursor inside fenced code block, do not output fences
CURSOR_IN_FENCED_CODE_BLOCK=true
<PREFIX>```python
def add(a, b):
return </PREFIX>
<SUFFIX>
```</SUFFIX>
Expected OUTPUT:
a + b
[EX06] Inline math must use $...$
<PREFIX>The derivative of x^2 is </PREFIX>
<SUFFIX>.</SUFFIX>
Expected OUTPUT:
$2x$
[EX07] Block math must use $$...$$
<PREFIX>We can write the Gaussian integral as:</PREFIX>
<SUFFIX></SUFFIX>
Expected OUTPUT:
$$
\\int_{-\\infty}^{\\infty} e^{-x^2}\\,dx = \\sqrt{\\pi}
$$
[EX08] Prefix misses boundary newline; add newline at output start
PREFIX_ENDS_WITH_NEWLINE=false PREFIX_ENDS_WITH_NEWLINE=false
<PREFIX>Deployment steps:</PREFIX> <PREFIX>Deployment steps:</PREFIX>
<SUFFIX></SUFFIX> <SUFFIX></SUFFIX>
@@ -457,21 +444,7 @@ Expected OUTPUT:
- Build artifact - Build artifact
- Deploy service - Deploy service
[EX09] Suffix misses boundary newline; add newline at output end [EX05] Continue table row
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
<PREFIX>| Name | Score | <PREFIX>| Name | Score |
| --- | --- | | --- | --- |
| Alice | 92 | | Alice | 92 |
@@ -480,30 +453,91 @@ $y = mx + b$
Expected OUTPUT: Expected OUTPUT:
88 | 88 |
[EX12] Mixed text + math + code in one insertion [EX06] Start new paragraph
CURSOR_IN_FENCED_CODE_BLOCK=false <PREFIX>First paragraph ends.</PREFIX>
<PREFIX>Use the area formula and provide a tiny JS helper.</PREFIX>
<SUFFIX></SUFFIX> <SUFFIX></SUFFIX>
Expected OUTPUT: Expected OUTPUT:
The area is $A = \\pi r^2$.
```javascript Second paragraph starts.
const area = (r) => Math.PI * r * r; 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 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_FENCE_LANGUAGE=mermaid
CURSOR_IN_FENCED_CODE_BLOCK=true
<PREFIX>```mermaid <PREFIX>```mermaid
flowchart TD flowchart TD
A[Start] --> </PREFIX> A[Start] --> </PREFIX>
<SUFFIX> <SUFFIX>
```</SUFFIX> ```</SUFFIX>
Expected OUTPUT: Expected OUTPUT:
B{Valid?} B{Valid?}
B -->|Yes| C[Done] 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 CURSOR_IN_FENCED_CODE_BLOCK=false
MERMAID_CONTEXT=true MERMAID_CONTEXT=true
<PREFIX>Please provide a simple release pipeline diagram.</PREFIX> <PREFIX>Please provide a simple release pipeline diagram.</PREFIX>
@@ -511,8 +545,18 @@ MERMAID_CONTEXT=true
Expected OUTPUT: Expected OUTPUT:
```mermaid ```mermaid
flowchart LR 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( def build_completion_prompts(
@@ -552,35 +596,49 @@ def build_completion_prompts(
preferences_instruction = f"\nUser Preferences:\n{preferences_instruction}" preferences_instruction = f"\nUser Preferences:\n{preferences_instruction}"
user_prompt = f"""Current time: {current_time}{location_info}{preferences_instruction} user_prompt = f"""Current time: {current_time}{location_info}{preferences_instruction}
Reasoning hint: {thinking_level} Reasoning level: {thinking_level}
Editor language id: {safe_language_id} 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_IN_FENCED_CODE_BLOCK: {"true" if cursor_in_fenced_code_block else "false"}
- CURSOR_FENCE_LANGUAGE: {cursor_fence_language} - CURSOR_FENCE_LANGUAGE: {cursor_fence_language}
- MERMAID_CONTEXT: {"true" if mermaid_context else "false"} - 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"}
Task: === TASK ===
- Produce the best insertion text at the cursor between PREFIX and SUFFIX. Produce the best insertion text between PREFIX and SUFFIX.
- Keep insertion meaningful and non-empty. Requirements:
- Keep insertion concise unless structure requires more content. - Non-empty and meaningful
- Concise unless structure needs more
- Follows markdown rules in system prompt
Context notes: === BOUNDARY DECISION GUIDE ===
- 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.
Decision policy: Step 1: Check PREFIX_ENDS_WITH_NEWLINE
- Prioritize seamless join: PREFIX + OUTPUT + SUFFIX must read naturally. If false, ask: "Does output need to start on a new line?"
- Do not repeat SUFFIX-leading text. - YES if PREFIX ends with: ":", "steps:", "items:", heading text, or complete sentence before heading
- If uncertain, prefer a complete short phrase/sentence with clear meaning. - 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} {INLINE_EXAMPLES}
Now produce the insertion. === NOW COMPLETE THE TASK ===
<PREFIX> <PREFIX>
{recent_prefix} {recent_prefix}
+313 -54
View File
@@ -46,7 +46,7 @@
</svg> </svg>
<span class="btn-tooltip">{{ t('uploadFile') }}</span> <span class="btn-tooltip">{{ t('uploadFile') }}</span>
</button> </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 <button
type="button" type="button"
@@ -132,9 +132,34 @@
<span class="btn-tooltip">{{ aiButtonLabel }}</span> <span class="btn-tooltip">{{ aiButtonLabel }}</span>
</button> </button>
<div class="size-indicator" :class="{ 'over-limit': isOverLimit }" aria-live="polite"> <div
{{ sizeInKB }} KB class="size-indicator"
</div> :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>
<div v-if="showUrlDialog" class="url-dialog-overlay" @click.self="showUrlDialog = false"> <div v-if="showUrlDialog" class="url-dialog-overlay" @click.self="showUrlDialog = false">
@@ -153,6 +178,17 @@
</div> </div>
</div> </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> </template>
<script setup> <script setup>
@@ -187,10 +223,12 @@ const contentSize = ref(0)
const showImageDropdown = ref(false) const showImageDropdown = ref(false)
const showExportDropdown = ref(false) const showExportDropdown = ref(false)
const showUrlDialog = ref(false) const showUrlDialog = ref(false)
const showSizeTooltip = ref(false)
const imageUrl = ref('') const imageUrl = ref('')
const canUndo = ref(false) const canUndo = ref(false)
const canRedo = ref(false) const canRedo = ref(false)
const isDocUploadDisabled = ref(false) const isDocUploadDisabled = ref(false)
const uploadProgress = ref(null)
const isOverLimit = computed(() => contentSize.value > SIZE_LIMIT) 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')
@@ -220,8 +258,8 @@ const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
const MARKDOWN_EXT_RE = /\.md$/i const MARKDOWN_EXT_RE = /\.md$/i
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif|avif)$/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 CONVERT_EXT_RE = /\.(docx|pptx|pdf)$/i
const TEXT_EXT_RE = /\.txt$/i const TEXT_EXT_RE = /\.(txt|json|toml|ya?ml)$/i
const TEXT_MIME_TYPES = new Set(['text/plain']) const TEXT_MIME_TYPES = new Set(['text/plain', 'application/json', 'text/yaml', 'text/x-yaml', 'application/x-yaml'])
const CONVERT_MIME_TYPES = new Set([ const CONVERT_MIME_TYPES = new Set([
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
@@ -905,8 +943,63 @@ const insertDocBlockAtCursor = (attrs) => {
const nextPos = Math.min(from + blockNode.nodeSize, tr.doc.content.size) const nextPos = Math.min(from + blockNode.nodeSize, tr.doc.content.size)
tr.setSelection(Selection.near(tr.doc.resolve(nextPos), 1)) tr.setSelection(Selection.near(tr.doc.resolve(nextPos), 1))
view.dispatch(tr.scrollIntoView()) view.dispatch(tr.scrollIntoView())
view.focus() 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 = () => { const triggerFileUpload = () => {
@@ -915,51 +1008,101 @@ const triggerFileUpload = () => {
} }
const handleUploadFile = async (event) => { const handleUploadFile = async (event) => {
const input = event.target const input = event.target
const file = input.files?.[0] const files = Array.from(input.files || [])
if (!file) return if (files.length === 0) return
try { const BATCH_LIMIT = 10
if (!isSupportedDocFile(file)) { const MAX_FILE_SIZE = 50 * 1024 * 1024
alert(t('uploadDocTypeWarning') || '仅支持 txt、docx、pptx、pdf 格式的文档')
return
}
if (isDocUploadDisabled.value || !crepe) { if (files.length > BATCH_LIMIT) {
alert(t('uploadDocInBlockWarning') || '当前光标位置不能插入文件') alert(t('uploadBatchLimit') || `一次最多上传${BATCH_LIMIT}个文件`)
return input.value = ''
} return
}
const docType = getDocTypeFromFilename(file.name) for (const file of files) {
let content = '' if (file.size > MAX_FILE_SIZE) {
alert(t('uploadSizeLimit') || `${file.name} 超过${MAX_FILE_SIZE / 1024 / 1024}MB限制`)
if (isTextFile(file)) { input.value = ''
content = await file.text() return
} 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 (!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) => { const handleImageUpload = async (event) => {
@@ -1127,14 +1270,81 @@ onUnmounted(() => {
} }
.size-indicator { .size-indicator {
font-size: 10px; position: relative;
color: var(--muted-text); display: flex;
text-align: center; align-items: center;
margin-top: 4px; 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 { .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 { .action-btn {
@@ -1451,7 +1661,56 @@ onUnmounted(() => {
.copilot-ghost-block pre, .copilot-ghost-block pre,
.copilot-ghost-block code { .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> </style>
+6 -1
View File
@@ -743,7 +743,12 @@ export function interruptCopilot(view: EditorView): void {
} }
export function checkSizeLimit(view: EditorView): { size: number; overLimit: boolean } { 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 } return { size, overLimit: size > SIZE_LIMIT }
} }
+14
View File
@@ -9,6 +9,9 @@ const HEADER_SEPARATOR = '\n---\n'
export function normalizeDocType(value = '') { export function normalizeDocType(value = '') {
const lower = String(value || '').trim().toLowerCase() const lower = String(value || '').trim().toLowerCase()
if (lower === 'txt' || lower === 'text' || lower === 'plain') return 'txt' 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 === 'doc' || lower === 'docx' || lower === 'word') return 'docx'
if (lower === 'ppt' || lower === 'pptx' || lower === 'powerpoint') return 'pptx' if (lower === 'ppt' || lower === 'pptx' || lower === 'powerpoint') return 'pptx'
if (lower === 'pdf') return 'pdf' if (lower === 'pdf') return 'pdf'
@@ -20,6 +23,9 @@ export function getDocTypeFromFilename(name = '') {
if (lower.endsWith('.docx')) return 'docx' if (lower.endsWith('.docx')) return 'docx'
if (lower.endsWith('.pptx')) return 'pptx' if (lower.endsWith('.pptx')) return 'pptx'
if (lower.endsWith('.pdf')) return 'pdf' 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' return 'txt'
} }
@@ -29,10 +35,18 @@ export function isSupportedDocFile(file) {
const type = String(file.type || '').toLowerCase() const type = String(file.type || '').toLowerCase()
return ( return (
name.endsWith('.txt') || name.endsWith('.txt') ||
name.endsWith('.json') ||
name.endsWith('.toml') ||
name.endsWith('.yaml') ||
name.endsWith('.yml') ||
name.endsWith('.docx') || name.endsWith('.docx') ||
name.endsWith('.pptx') || name.endsWith('.pptx') ||
name.endsWith('.pdf') || name.endsWith('.pdf') ||
type === 'text/plain' || 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.wordprocessingml.document' ||
type === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' || type === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
type === 'application/pdf' type === 'application/pdf'
+20 -11
View File
@@ -36,15 +36,18 @@ export const translations = {
uploadImg: 'Upload Image', uploadImg: 'Upload Image',
uploadFile: 'Upload File', uploadFile: 'Upload File',
uploadDoc: 'Upload Document', 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.', uploadDocSizeWarning: 'File size cannot exceed 10MB.',
uploadDocInBlockWarning: 'Cannot insert document inside an existing document block. Please move cursor outside.', uploadDocInBlockWarning: 'Cannot insert document inside an existing document block. Please move cursor outside.',
uploadDocError: 'Document conversion failed:', uploadDocError: 'Document conversion failed:',
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.', uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.', uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
uploadFileError: 'File upload failed.', uploadFileError: 'File upload failed.',
uploadConvertError: 'File conversion failed.', uploadConvertError: 'File conversion failed.',
enableAI: 'Enable AI', uploadBatchLimit: 'Maximum 10 files at once',
uploadSizeLimit: 'File exceeds 50MB limit',
uploading: 'Uploading files...',
enableAI: 'Enable AI',
disableAI: 'Disable AI', disableAI: 'Disable AI',
insertUrl: 'Insert Image from URL', insertUrl: 'Insert Image from URL',
insert: 'Insert', insert: 'Insert',
@@ -90,15 +93,18 @@ export const translations = {
uploadImg: '上传图片', uploadImg: '上传图片',
uploadFile: '上传文件', uploadFile: '上传文件',
uploadDoc: '上传文档', uploadDoc: '上传文档',
uploadDocTypeWarning: '仅支持 txt、docx、pptx、pdf 格式的文档', uploadDocTypeWarning: '仅支持 txt、json、toml、yaml、docx、pptx、pdf 格式的文档',
uploadDocSizeWarning: '文件大小不能超过 10MB', uploadDocSizeWarning: '文件大小不能超过 10MB',
uploadDocInBlockWarning: '无法在现有文档块内插入新文档,请将光标移到文档外部', uploadDocInBlockWarning: '无法在现有文档块内插入新文档,请将光标移到文档外部',
uploadDocError: '文档转换失败:', uploadDocError: '文档转换失败:',
uploadFileTypeWarning: '不支持的文件类型。仅支持 doc/docx/ppt/pptx/pdf/zip、图片、txt/json。', uploadFileTypeWarning: '不支持的文件类型。仅支持 doc/docx/ppt/pptx/pdf/zip、图片、txt/json。',
uploadMdTypeWarning: '仅支持 Markdown.md)和图片文件。', uploadMdTypeWarning: '仅支持 Markdown.md)和图片文件。',
uploadFileError: '文件上传失败', uploadFileError: '文件上传失败',
uploadConvertError: '文件转换失败', uploadConvertError: '文件转换失败',
enableAI: '启用 AI', uploadBatchLimit: '一次最多上传10个文件',
uploadSizeLimit: '文件超过50MB限制',
uploading: '正在上传文件...',
enableAI: '启用 AI',
disableAI: '禁用 AI', disableAI: '禁用 AI',
insertUrl: '通过 URL 插入图片', insertUrl: '通过 URL 插入图片',
insert: '插入', insert: '插入',
@@ -145,9 +151,12 @@ export const translations = {
uploadFile: 'Upload File', uploadFile: 'Upload File',
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.', uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.', uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
uploadFileError: 'File upload failed.', uploadFileError: 'File upload failed.',
uploadConvertError: 'File conversion failed.', uploadConvertError: 'File conversion failed.',
enableAI: 'AIを有効化', uploadBatchLimit: 'Maximum 10 files at once',
uploadSizeLimit: 'File exceeds 50MB limit',
uploading: 'Uploading files...',
enableAI: 'AIを有効化',
disableAI: 'AIを無効化', disableAI: 'AIを無効化',
insertUrl: 'URLから画像を挿入', insertUrl: 'URLから画像を挿入',
insert: '挿入', insert: '挿入',