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:
@@ -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>
|
||||
|
||||
+119
-39
@@ -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 }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user