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
+186 -128
View File
@@ -331,82 +331,103 @@ 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
[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
WRONG: phase one starts with (repeats suffix)
[EX03] Continue markdown checklist
=== CATEGORY B: MARKDOWN STRUCTURES ===
[EX03] Continue checklist
<PREFIX>## TODO
- [ ] Buy milk
- [ ] </PREFIX>
@@ -414,41 +435,7 @@ careful internal testing before
Expected OUTPUT:
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>
@@ -457,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 |
@@ -480,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>
@@ -511,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(
@@ -552,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}
+313 -54
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>
@@ -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',
@@ -905,8 +943,63 @@ const insertDocBlockAtCursor = (attrs) => {
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()
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 = () => {
@@ -915,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) => {
@@ -1127,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 {
@@ -1451,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 }
}
+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: '挿入',