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
+119 -39
View File
@@ -1,9 +1,9 @@
import { Plugin, PluginKey, Selection } from '@milkdown/prose/state'
import { $prose, $ctx, $markSchema } from '@milkdown/kit/utils'
import { parserCtx, serializerCtx } from '@milkdown/kit/core'
import { Node as ProseNode, DOMParser, DOMSerializer } from '@milkdown/prose/model'
import { Node as ProseNode, Slice } from '@milkdown/prose/model'
import type { Ctx } from '@milkdown/kit/core'
import type { EditorView } from '@milkdown/prose/view'
import { Decoration, DecorationSet, type EditorView } from '@milkdown/prose/view'
import { getOcrCache, OCR_SIZE_LIMIT, extractTextFromOCR } from '../utils/ocrCache'
const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot')
@@ -75,31 +75,16 @@ function clearRuntimeRequests(runtime: CopilotRuntime, invalidateRequest = true)
}
}
function findGhostRangeByMarks(view: EditorView): { from: number; to: number } | null {
const markType = view.state.schema.marks.copilot_ghost
if (!markType) return null
let from = Number.POSITIVE_INFINITY
let to = -1
view.state.doc.descendants((node, pos) => {
if (node.isText && node.marks.some((m: any) => m.type === markType)) {
from = Math.min(from, pos)
to = Math.max(to, pos + node.nodeSize)
}
return true
})
if (!Number.isFinite(from) || to <= from) return null
return { from, to }
function getGhostState(view: EditorView): CopilotState | null {
const state = COPILOT_PLUGIN_KEY.getState(view.state) as CopilotState | undefined
if (!state || !state.suggestion || state.from >= state.to) return null
return state
}
function getGhostRange(view: EditorView): { from: number; to: number } | null {
const state = COPILOT_PLUGIN_KEY.getState(view.state)
if (state && state.from < state.to) {
return { from: state.from, to: state.to }
}
return findGhostRangeByMarks(view)
const state = getGhostState(view)
if (!state) return null
return { from: state.from, to: state.to }
}
function hasGhostText(view: EditorView): boolean {
@@ -110,8 +95,13 @@ function clearGhostText(view: EditorView): boolean {
const range = getGhostRange(view)
if (!range) return false
const maxPos = view.state.doc.content.size
const from = Math.max(0, Math.min(range.from, maxPos))
const to = Math.max(from, Math.min(range.to, maxPos))
if (to <= from) return false
const tr = view.state.tr
.delete(range.from, range.to)
.delete(from, to)
.setMeta(COPILOT_PLUGIN_KEY, { ...initialState })
view.dispatch(tr)
return true
@@ -124,21 +114,40 @@ function getCursorBeforeGhostInsert(tr: any, from: number): number {
function insertParsedMarkdownSlice(
tr: any,
schema: any,
from: number,
parsedDoc: ProseNode
): { from: number; to: number } | null {
if (parsedDoc.content.size <= 0) return null
const insertPos = tr.mapping.map(from, -1)
const dom = DOMSerializer.fromSchema(schema).serializeFragment(parsedDoc.content)
const parsedSlice = DOMParser.fromSchema(schema).parseSlice(dom)
const parsedSlice = Slice.maxOpen(parsedDoc.content)
if (!parsedSlice || parsedSlice.size <= 0) return null
tr.replaceRange(insertPos, insertPos, parsedSlice)
const endPos = Math.min(insertPos + parsedSlice.size, tr.doc.content.size)
if (endPos <= insertPos) return null
return { from: insertPos, to: endPos }
const startPos = tr.mapping.map(insertPos, -1)
const endPos = tr.mapping.map(insertPos, 1)
if (endPos <= startPos) return null
return { from: startPos, to: endPos }
}
function createGhostDecorations(doc: ProseNode, from: number, to: number) {
if (to <= from) return DecorationSet.empty
const decorations = [
Decoration.inline(from, to, { class: 'copilot-ghost-text', 'data-copilot-ghost': '' })
]
doc.nodesBetween(from, to, (node, pos) => {
if (!node.isBlock) return true
const nodeFrom = pos
const nodeTo = pos + node.nodeSize
if (nodeFrom >= from && nodeTo <= to) {
decorations.push(Decoration.node(nodeFrom, nodeTo, { class: 'copilot-ghost-block' }))
}
return true
})
return DecorationSet.create(doc, decorations)
}
function addGhostMarksToTextNodes(tr: any, from: number, to: number, markType: any) {
@@ -184,8 +193,7 @@ function normalizeSuggestionText(raw: string): string {
async function insertGhostText(view: EditorView, suggestion: string, from: number, ctx: Ctx) {
if (!suggestion) return
const schema = view.state.schema
const markType = schema.marks.copilot_ghost
const markType = view.state.schema.marks.copilot_ghost
if (!markType) {
console.error('[Copilot] copilot_ghost mark not found in schema')
@@ -202,7 +210,7 @@ async function insertGhostText(view: EditorView, suggestion: string, from: numbe
}
const tr = view.state.tr
const insertedRange = insertParsedMarkdownSlice(tr, schema, from, parsedDoc)
const insertedRange = insertParsedMarkdownSlice(tr, from, parsedDoc)
if (!insertedRange) {
console.warn('[Copilot] parsed markdown insertion failed, falling back to plain text')
@@ -386,8 +394,10 @@ function acceptSuggestion(view: EditorView) {
const tr = view.state.tr
const doc = tr.doc
const from = range.from
const to = range.to
const maxPos = doc.content.size
const from = Math.max(0, Math.min(range.from, maxPos))
const to = Math.max(from, Math.min(range.to, maxPos))
if (to <= from) return false
const markType = view.state.schema.marks.copilot_ghost
if (!markType) return false
@@ -423,14 +433,31 @@ export const copilotPlugin = $prose((ctx) => new Plugin<CopilotState>({
return meta
}
if (tr.docChanged && value.suggestion) {
return { ...initialState }
if (tr.docChanged && value.suggestion && value.from < value.to) {
const fromResult = tr.mapping.mapResult(value.from, -1)
const toResult = tr.mapping.mapResult(value.to, 1)
const mappedFrom = Math.max(0, fromResult.pos)
const mappedTo = Math.max(mappedFrom, toResult.pos)
if (mappedTo <= mappedFrom) {
return { ...initialState }
}
return { ...value, from: mappedFrom, to: mappedTo }
}
return value
}
},
props: {
decorations: (state) => {
const ghost = COPILOT_PLUGIN_KEY.getState(state) as CopilotState | undefined
if (!ghost || !ghost.suggestion || ghost.from >= ghost.to) return null
const maxPos = state.doc.content.size
const from = Math.max(0, Math.min(ghost.from, maxPos))
const to = Math.max(from, Math.min(ghost.to, maxPos))
if (to <= from) return null
return createGhostDecorations(state.doc, from, to)
},
handleKeyDown: (view, event) => {
const hasGhost = hasGhostText(view)
@@ -450,6 +477,24 @@ export const copilotPlugin = $prose((ctx) => new Plugin<CopilotState>({
return false
},
handleTextInput: (view) => {
if (hasGhostText(view)) {
clearGhostText(view)
}
return false
},
handlePaste: (view) => {
if (hasGhostText(view)) {
clearGhostText(view)
}
return false
},
handleDrop: (view) => {
if (hasGhostText(view)) {
clearGhostText(view)
}
return false
},
handleClick: (view, pos) => {
const range = getGhostRange(view)
if (!range) return false
@@ -460,6 +505,26 @@ export const copilotPlugin = $prose((ctx) => new Plugin<CopilotState>({
clearGhostText(view)
return false
},
handleDOMEvents: {
compositionstart: (view) => {
if (hasGhostText(view)) {
clearGhostText(view)
}
return false
},
beforeinput: (view, event) => {
if (!hasGhostText(view)) return false
const inputType = (event as InputEvent).inputType || ''
if (
inputType.startsWith('insert') ||
inputType.startsWith('delete') ||
inputType.startsWith('format')
) {
clearGhostText(view)
}
return false
}
}
},
view: (view) => {
@@ -540,6 +605,16 @@ export const copilotPlugin = $prose((ctx) => new Plugin<CopilotState>({
return
}
const prevGhost = COPILOT_PLUGIN_KEY.getState(prevState) as CopilotState | undefined
const nextGhost = COPILOT_PLUGIN_KEY.getState(nextView.state) as CopilotState | undefined
const prevHasGhost = Boolean(prevGhost?.suggestion && prevGhost.from < prevGhost.to)
const nextHasGhost = Boolean(nextGhost?.suggestion && nextGhost.from < nextGhost.to)
if (docChanged && prevHasGhost && nextHasGhost) {
clearGhostText(nextView)
clearRuntimeRequests(runtime)
return
}
const ghostRange = getGhostRange(nextView)
if (ghostRange) {
const { from, to } = nextView.state.selection
@@ -586,10 +661,15 @@ export function setCopilotEnabled(view: EditorView, value: boolean): void {
}
}
export function interruptCopilot(view: EditorView): void {
const runtime = runtimeByView.get(view)
if (!runtime) return
clearRuntimeRequests(runtime)
}
export function checkSizeLimit(view: EditorView): { size: number; overLimit: boolean } {
const size = view.state.doc.content.size
return { size, overLimit: size > SIZE_LIMIT }
}
export { SIZE_LIMIT }