feat(api): add system prompt support for LLM completion

Separate prompt generation into system and user prompts for better LLM instruction following. Backend now builds a detailed system prompt with constraints for math formatting, code block handling, boundary newlines, and OCR safety, while user prompt contains context and completion state flags. Added corresponding tests for both modules.
This commit is contained in:
2026-02-23 15:17:36 +08:00
parent ce0731c2f2
commit e28125079c
7 changed files with 649 additions and 118 deletions
+133 -2
View File
@@ -2,6 +2,35 @@
<div class="editor-container">
<div ref="root" class="milkdown-editor"></div>
<div class="history-buttons">
<button
type="button"
class="history-btn"
:disabled="!canUndo"
:aria-label="undoLabel"
:title="undoLabel"
@click="handleUndo"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 14 4 9l5-5"/>
<path d="M4 9h11a4 4 0 1 1 0 8h-1"/>
</svg>
</button>
<button
type="button"
class="history-btn"
:disabled="!canRedo"
:aria-label="redoLabel"
:title="redoLabel"
@click="handleRedo"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m15 14 5-5-5-5"/>
<path d="M20 9H9a4 4 0 1 0 0 8h1"/>
</svg>
</button>
</div>
<div class="action-buttons">
<button
type="button"
@@ -106,7 +135,8 @@ import { replaceAll } from '@milkdown/kit/utils'
import { Crepe } from '@milkdown/crepe'
import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
import { Selection } from '@milkdown/prose/state'
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, COPILOT_PLUGIN_KEY, SIZE_LIMIT, checkSizeLimit, clearGhostSuggestion } from '../plugins/copilotPlugin'
import { undo, redo, undoDepth, redoDepth } from '@milkdown/prose/history'
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, interruptCopilot, COPILOT_PLUGIN_KEY, SIZE_LIMIT, checkSizeLimit, clearGhostSuggestion } from '../plugins/copilotPlugin'
import { fetchSuggestion } from '../utils/api.js'
import { useSettingsStore } from '../stores/settings'
import { OCR_URL } from '../utils/config.js'
@@ -124,8 +154,12 @@ const contentSize = ref(0)
const showImageDropdown = ref(false)
const showUrlDialog = ref(false)
const imageUrl = ref('')
const canUndo = ref(false)
const canRedo = ref(false)
const isOverLimit = computed(() => contentSize.value > SIZE_LIMIT)
const sizeInKB = computed(() => Math.floor(contentSize.value / 1024))
const undoLabel = computed(() => t('undo') || 'Undo')
const redoLabel = computed(() => t('redo') || 'Redo')
const aiButtonLabel = computed(() => {
if (isOverLimit.value) return t('docTooLarge')
return aiEnabled.value ? t('disableAI') : t('enableAI')
@@ -133,6 +167,7 @@ const aiButtonLabel = computed(() => {
let crepe = null
let markdownSyncTimer = null
let rootResizeObserver = null
const objectUrls = new Set()
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
@@ -220,6 +255,38 @@ const clearCurrentGhost = () => {
})
}
const updateEditorTailSpace = () => {
if (!root.value) return
const viewportHeight = root.value.clientHeight
const tailSpace = Math.max(viewportHeight - 32, 160)
root.value.style.setProperty('--editor-tail-space', `${tailSpace}px`)
}
const updateHistoryState = (view) => {
canUndo.value = undoDepth(view.state) > 0
canRedo.value = redoDepth(view.state) > 0
}
const runHistoryCommand = (command) => {
if (!crepe) return
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
interruptCopilot(view)
clearCurrentSuggestion(view)
command(view.state, (tr) => view.dispatch(tr), view)
updateHistoryState(view)
view.focus()
})
}
const handleUndo = () => {
runHistoryCommand(undo)
}
const handleRedo = () => {
runHistoryCommand(redo)
}
const performOCR = async (file, cacheKey, imageHash = '') => {
if (!aiEnabled.value) return
@@ -264,6 +331,13 @@ const performOCR = async (file, cacheKey, imageHash = '') => {
onMounted(async () => {
if (!root.value) throw new Error('root.value is null')
updateEditorTailSpace()
if (typeof ResizeObserver !== 'undefined') {
rootResizeObserver = new ResizeObserver(() => {
updateEditorTailSpace()
})
rootResizeObserver.observe(root.value)
}
crepe = new Crepe({
root: root.value,
@@ -337,8 +411,10 @@ onMounted(async () => {
crepe.on((listener) => {
listener.updated((ctx, doc) => {
const view = ctx.get(editorViewCtx)
syncObjectUrls(doc)
refreshSizeAndLimit(ctx)
updateHistoryState(view)
scheduleMarkdownSync()
})
})
@@ -347,6 +423,7 @@ onMounted(async () => {
const view = ctx.get(editorViewCtx)
setCopilotEnabled(view, aiEnabled.value)
refreshSizeAndLimit(ctx)
updateHistoryState(view)
})
scheduleMarkdownSync()
})
@@ -479,6 +556,11 @@ onUnmounted(() => {
markdownSyncTimer = null
}
if (rootResizeObserver) {
rootResizeObserver.disconnect()
rootResizeObserver = null
}
for (const url of Array.from(objectUrls)) {
revokeObjectUrl(url)
}
@@ -499,6 +581,47 @@ onUnmounted(() => {
overflow: hidden;
}
.history-buttons {
position: fixed;
top: calc(16px + env(safe-area-inset-top));
right: calc(16px + env(safe-area-inset-right));
display: flex;
gap: 6px;
z-index: 9000;
}
.history-btn {
width: 34px;
height: 34px;
padding: 8px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: var(--btn-bg);
color: var(--btn-fg);
box-shadow: var(--panel-shadow);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.72;
}
.history-btn:hover:not(:disabled) {
background-color: var(--btn-hover-bg);
color: var(--btn-hover-fg);
border-color: var(--btn-hover-bg);
opacity: 1;
}
.history-btn:disabled {
background-color: var(--btn-disabled-bg);
color: var(--btn-disabled-fg);
border-color: var(--btn-disabled-bg);
cursor: not-allowed;
opacity: 0.6;
box-shadow: none;
}
.action-buttons {
position: fixed;
bottom: 20px;
@@ -709,6 +832,7 @@ onUnmounted(() => {
}
.milkdown-editor {
--editor-tail-space: calc(100vh - 32px);
width: 100%;
height: 100%;
background-color: transparent !important;
@@ -740,7 +864,7 @@ onUnmounted(() => {
.milkdown-editor :deep(.ProseMirror) {
margin: 0 !important;
padding: 0 !important;
padding: 0 0 var(--editor-tail-space) 0 !important;
}
.milkdown-editor :deep(.ProseMirror img) {
@@ -809,6 +933,7 @@ onUnmounted(() => {
color: var(--ghost-text);
opacity: 0.72;
pointer-events: auto;
transition: color 0.12s ease, opacity 0.12s ease, background-color 0.12s ease;
}
.copilot-ghost-text.copilot-loading {
@@ -836,6 +961,7 @@ onUnmounted(() => {
.copilot-ghost-block {
color: var(--ghost-text);
opacity: 0.72;
transition: color 0.12s ease, opacity 0.12s ease, background-color 0.12s ease;
}
.copilot-ghost-block code,
@@ -844,4 +970,9 @@ onUnmounted(() => {
color: inherit;
opacity: inherit;
}
.copilot-ghost-block pre,
.copilot-ghost-block code {
background-color: var(--ghost-code-bg);
}
</style>