refactor(editor): migrate to ProseMirror Mark-based ghost text system
- Replace overlay-based GhostTextOverlay.vue with ProseMirror Mark system - Add AI toggle button with enable/disable functionality - Implement new copilotPlugin.ts using copilotGhostMark for inline suggestions - Fix cursor position offset in prompt.py by moving first suffix char to prefix - Improve API error handling with abort signal support and debug logging - Update model configuration from gpt-oss:120b to gpt-oss:20b - Add button tooltips and improve editor styling - Remove deprecated inlineSuggestionPlugin.ts - Update README with new architecture diagram and feature documentation
This commit is contained in:
@@ -1,57 +0,0 @@
|
||||
<template>
|
||||
<div v-if="visible" class="ghost-text-overlay" :style="overlayStyle" @click="acceptSuggestion">
|
||||
{{ displayText }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
suggestion: { type: String, default: '' },
|
||||
position: {
|
||||
type: Object,
|
||||
required: true,
|
||||
validator: (value) => typeof value.left === 'number' && typeof value.top === 'number'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['accept', 'dismiss'])
|
||||
|
||||
const MAX_LENGTH = 200
|
||||
|
||||
const visible = computed(() => props.suggestion && props.suggestion.length > 0)
|
||||
|
||||
const displayText = computed(() => {
|
||||
const text = props.suggestion
|
||||
return text.length > MAX_LENGTH ? text.slice(0, MAX_LENGTH) + '...' : text
|
||||
})
|
||||
|
||||
const overlayStyle = computed(() => ({
|
||||
position: 'absolute',
|
||||
left: `${props.position.left}px`,
|
||||
top: `${props.position.top}px`,
|
||||
fontSize: `${props.position.fontSize || 16}px`,
|
||||
fontFamily: props.position.fontFamily || 'monospace',
|
||||
color: '#999',
|
||||
backgroundColor: 'transparent',
|
||||
pointerEvents: 'auto',
|
||||
cursor: 'text',
|
||||
whiteSpace: 'pre-wrap',
|
||||
zIndex: 1000,
|
||||
}))
|
||||
|
||||
const acceptSuggestion = () => emit('accept')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ghost-text-overlay {
|
||||
opacity: 0.6;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ghost-text-overlay:hover {
|
||||
opacity: 1;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
+225
-112
@@ -9,6 +9,7 @@
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
<span class="btn-tooltip">导入 Markdown</span>
|
||||
</button>
|
||||
<input type="file" ref="fileInputRef" @change="handleFileUpload" accept=".md" style="display:none">
|
||||
|
||||
@@ -18,133 +19,247 @@
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
<span class="btn-tooltip">导出 Markdown</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="action-btn ai-toggle"
|
||||
:class="{ 'ai-disabled': !aiEnabled }"
|
||||
@click="toggleAI"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2z"/>
|
||||
<path d="M12 6v6l4 2"/>
|
||||
</svg>
|
||||
<span class="btn-tooltip">{{ aiEnabled ? '禁用 AI' : '启用 AI' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { replaceAll } from '@milkdown/kit/utils'
|
||||
import { Crepe } from '@milkdown/crepe'
|
||||
import { createInlineSuggestionPlugin } from '../plugins/inlineSuggestionPlugin.js'
|
||||
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, isCopilotEnabled, setCopilotEnabled, COPILOT_PLUGIN_KEY } from '../plugins/copilotPlugin'
|
||||
import { fetchSuggestion } from '../utils/api.js'
|
||||
import { DEBUG } from '../utils/config.js'
|
||||
|
||||
const root = ref(null)
|
||||
const containerRef = ref(null)
|
||||
const fileInputRef = ref(null)
|
||||
const aiEnabled = ref(true)
|
||||
let crepe = null
|
||||
|
||||
onMounted(async () => {
|
||||
if (!root.value) return
|
||||
|
||||
const plugin = createInlineSuggestionPlugin()
|
||||
|
||||
crepe = new Crepe({
|
||||
root: root.value,
|
||||
defaultValue: '# Welcome to LLM in text\n\nStart writing your content here...',
|
||||
features: { [Crepe.Feature.Latex]: true },
|
||||
featureConfigs: {
|
||||
[Crepe.Feature.Latex]: { katexOptions: {}, inlineEditConfirm: 'Escape' }
|
||||
},
|
||||
config: { showLineNumber: false },
|
||||
markdown: {
|
||||
plugins: [plugin]
|
||||
}
|
||||
})
|
||||
|
||||
await crepe.create()
|
||||
if (DEBUG) console.log('[Debug] onMounted called')
|
||||
if (!root.value) throw new Error('root.value is null')
|
||||
|
||||
if (DEBUG) console.log('[Debug] Creating Crepe editor...')
|
||||
crepe = new Crepe({
|
||||
root: root.value,
|
||||
defaultValue: '# Welcome to LLM in text\n\nStart writing your content here...',
|
||||
features: {
|
||||
[Crepe.Feature.Latex]: true,
|
||||
},
|
||||
featureConfigs: {
|
||||
[Crepe.Feature.Latex]: {
|
||||
katexOptions: {},
|
||||
inlineEditConfirm: 'Escape'
|
||||
}
|
||||
},
|
||||
config: {
|
||||
showLineNumber: false,
|
||||
}
|
||||
})
|
||||
|
||||
crepe.editor.config((ctx) => {
|
||||
ctx.set(copilotConfigCtx.key, {
|
||||
fetchSuggestion,
|
||||
debounceMs: 500
|
||||
})
|
||||
})
|
||||
|
||||
crepe.editor.use(copilotConfigCtx)
|
||||
crepe.editor.use(copilotGhostMark)
|
||||
crepe.editor.use(copilotPlugin)
|
||||
|
||||
await crepe.create()
|
||||
|
||||
if (DEBUG) console.log('[Debug] Crepe editor created with copilot plugin')
|
||||
})
|
||||
|
||||
const exportMarkdown = async () => {
|
||||
if (!crepe) return
|
||||
const markdown = await crepe.getMarkdown()
|
||||
const blob = new Blob([markdown], { type: 'text/markdown' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `document-${Date.now()}.md`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
if (!crepe) return
|
||||
|
||||
const { editorViewCtx } = await import('@milkdown/kit/core')
|
||||
const { COPILOT_PLUGIN_KEY } = await import('../plugins/copilotPlugin')
|
||||
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const state = COPILOT_PLUGIN_KEY.getState(view.state)
|
||||
if (state?.suggestion && state.from < state.to) {
|
||||
const tr = view.state.tr
|
||||
.delete(state.from, state.to)
|
||||
.setMeta(COPILOT_PLUGIN_KEY, { from: 0, to: 0, suggestion: '' })
|
||||
view.dispatch(tr)
|
||||
}
|
||||
})
|
||||
|
||||
const markdown = await crepe.getMarkdown()
|
||||
const blob = new Blob([markdown], { type: 'text/markdown' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `document-${Date.now()}.md`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const triggerUpload = () => fileInputRef.value?.click()
|
||||
const triggerUpload = () => {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
const handleFileUpload = async (event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
if (crepe?.editor) {
|
||||
crepe.editor.action(replaceAll(text))
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
if (crepe && crepe.editor) {
|
||||
crepe.editor.action(replaceAll(text))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Error] Upload failed:', e)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Error] Upload failed:', e)
|
||||
}
|
||||
|
||||
event.target.value = ''
|
||||
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
const toggleAI = async () => {
|
||||
aiEnabled.value = !aiEnabled.value
|
||||
setCopilotEnabled(aiEnabled.value)
|
||||
|
||||
if (!aiEnabled.value && crepe) {
|
||||
const { editorViewCtx } = await import('@milkdown/kit/core')
|
||||
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const state = COPILOT_PLUGIN_KEY.getState(view.state)
|
||||
if (state?.suggestion && state.from < state.to) {
|
||||
const tr = view.state.tr
|
||||
.delete(state.from, state.to)
|
||||
.setMeta(COPILOT_PLUGIN_KEY, { from: 0, to: 0, suggestion: '' })
|
||||
view.dispatch(tr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (crepe) {
|
||||
crepe.destroy()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editor-container {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 9999;
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 10px;
|
||||
background-color: #fff;
|
||||
color: #666;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 10px;
|
||||
background-color: #fff;
|
||||
color: #666;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background-color: #4a90d9;
|
||||
color: white;
|
||||
border-color: #4a90d9;
|
||||
background-color: #4a90d9;
|
||||
color: white;
|
||||
border-color: #4a90d9;
|
||||
}
|
||||
|
||||
.action-btn.ai-disabled {
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.action-btn.ai-disabled:hover {
|
||||
background-color: #4a90d9;
|
||||
color: white;
|
||||
border-color: #4a90d9;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn-tooltip {
|
||||
position: absolute;
|
||||
top: -32px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #333;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover .btn-tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.milkdown-editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #ffffff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #ffffff;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.milkdown-editor :deep(.milkdown) {
|
||||
max-width: none;
|
||||
margin: 0 auto !important;
|
||||
padding: 20px 40px !important;
|
||||
min-height: calc(100vh - 40px);
|
||||
max-width: none;
|
||||
margin: 0 !important;
|
||||
padding: 20px 40px !important;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.milkdown-editor :deep(.milkdown__main) {
|
||||
margin-left: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
width: 100% !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.milkdown-editor :deep(.milkdown__editor) {
|
||||
margin-left: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.milkdown-editor :deep(.milkdown__aside),
|
||||
@@ -153,8 +268,23 @@ const handleFileUpload = async (event) => {
|
||||
.milkdown-editor :deep([class*="line-number"]),
|
||||
.milkdown-editor :deep([class*="gutter"]),
|
||||
.milkdown-editor :deep([class*="sidebar"]) {
|
||||
display: none !important;
|
||||
width: 0 !important;
|
||||
display: none !important;
|
||||
width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
max-width: 0 !important;
|
||||
}
|
||||
|
||||
.milkdown-editor::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.milkdown-editor::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.milkdown-editor::-webkit-scrollbar-thumb {
|
||||
background-color: #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.milkdown-editor :deep(.milkdown__toolbar),
|
||||
@@ -164,48 +294,31 @@ const handleFileUpload = async (event) => {
|
||||
.milkdown-editor :deep(.milkdown-bubble-menu),
|
||||
.milkdown-editor :deep([class*="toolbar"]),
|
||||
.milkdown-editor :deep([class*="menu"]) {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
height: 0 !important;
|
||||
width: 0 !important;
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
height: 0 !important;
|
||||
width: 0 !important;
|
||||
}
|
||||
|
||||
.milkdown-editor :deep(.milkdown__block-handle),
|
||||
.milkdown-editor :deep([class*="block-handle"]),
|
||||
.milkdown-editor :deep([class*="blockHandle"]) {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
width: 0 !important;
|
||||
}
|
||||
|
||||
.milkdown-editor::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.milkdown-editor::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.milkdown-editor::-webkit-scrollbar-thumb {
|
||||
background-color: #ddd;
|
||||
border-radius: 4px;
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.ghost-text-decoration {
|
||||
color: #999 !important;
|
||||
opacity: 0.7 !important;
|
||||
font-family: inherit !important;
|
||||
font-size: inherit !important;
|
||||
line-height: inherit !important;
|
||||
user-select: none !important;
|
||||
pointer-events: auto !important;
|
||||
cursor: text !important;
|
||||
.copilot-ghost-text {
|
||||
color: #999;
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ghost-text-decoration:hover {
|
||||
color: #666 !important;
|
||||
opacity: 1 !important;
|
||||
.copilot-ghost-text.copilot-loading {
|
||||
opacity: 0.4;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { Plugin, PluginKey, Selection } from '@milkdown/prose/state'
|
||||
import { $prose, $ctx, $markSchema } from '@milkdown/kit/utils'
|
||||
import type { Ctx } from '@milkdown/kit/core'
|
||||
import type { EditorView } from '@milkdown/prose/view'
|
||||
|
||||
const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot')
|
||||
const DEBOUNCE_MS = 500
|
||||
|
||||
let enabled = true
|
||||
|
||||
interface CopilotState {
|
||||
from: number
|
||||
to: number
|
||||
suggestion: string
|
||||
}
|
||||
|
||||
interface CopilotConfig {
|
||||
fetchSuggestion: (prefix: string, suffix: string, signal?: AbortSignal) => Promise<string>
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
const initialState: CopilotState = {
|
||||
from: 0,
|
||||
to: 0,
|
||||
suggestion: ''
|
||||
}
|
||||
|
||||
export const copilotConfigCtx = $ctx<CopilotConfig, 'copilotConfig'>({
|
||||
fetchSuggestion: async () => '',
|
||||
debounceMs: DEBOUNCE_MS
|
||||
}, 'copilotConfig')
|
||||
|
||||
export const copilotGhostMark = $markSchema('copilot_ghost', () => ({
|
||||
excludes: '_',
|
||||
inclusive: true,
|
||||
parseDOM: [{ tag: 'span[data-copilot-ghost]' }],
|
||||
toDOM: () => ['span', { 'data-copilot-ghost': '', class: 'copilot-ghost-text' }, 0]
|
||||
}))
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let abortController: AbortController | null = null
|
||||
let currentCtx: Ctx | null = null
|
||||
|
||||
function clearGhostText(view: EditorView) {
|
||||
const state = COPILOT_PLUGIN_KEY.getState(view.state)
|
||||
if (state && state.suggestion && state.from < state.to) {
|
||||
const tr = view.state.tr
|
||||
.delete(state.from, state.to)
|
||||
.setMeta(COPILOT_PLUGIN_KEY, { ...initialState })
|
||||
view.dispatch(tr)
|
||||
}
|
||||
}
|
||||
|
||||
function insertGhostText(view: EditorView, suggestion: string, from: number) {
|
||||
if (!currentCtx || !suggestion) return
|
||||
|
||||
const schema = view.state.schema
|
||||
const markType = schema.marks.copilot_ghost
|
||||
|
||||
if (!markType) {
|
||||
console.error('[Copilot] copilot_ghost mark not found in schema')
|
||||
return
|
||||
}
|
||||
|
||||
const tr = view.state.tr
|
||||
tr.insertText(suggestion, from)
|
||||
const endPos = from + suggestion.length
|
||||
tr.addMark(from, endPos, markType.create())
|
||||
tr.setMeta(COPILOT_PLUGIN_KEY, { from, to: endPos, suggestion })
|
||||
view.dispatch(tr)
|
||||
}
|
||||
|
||||
function doFetchSuggestion(view: EditorView, pos: number, prefix: string, suffix: string) {
|
||||
if (!currentCtx) return
|
||||
|
||||
const config = currentCtx.get(copilotConfigCtx.key)
|
||||
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
abortController = null
|
||||
}
|
||||
|
||||
abortController = new AbortController()
|
||||
|
||||
config.fetchSuggestion(prefix, suffix, abortController.signal)
|
||||
.then(suggestion => {
|
||||
if (view.state.selection.from !== pos) return
|
||||
|
||||
if (suggestion) {
|
||||
insertGhostText(view, suggestion, pos)
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
if (e.name !== 'AbortError') {
|
||||
console.error('[Copilot] Error:', e)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
abortController = null
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleFetch(view: EditorView, pos: number, prefix: string, suffix: string) {
|
||||
if (!enabled) return
|
||||
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null
|
||||
doFetchSuggestion(view, pos, prefix, suffix)
|
||||
}, DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
function acceptSuggestion(view: EditorView) {
|
||||
const state = COPILOT_PLUGIN_KEY.getState(view.state)
|
||||
if (!state?.suggestion || state.from >= state.to) return false
|
||||
|
||||
const tr = view.state.tr.removeMark(state.from, state.to, view.state.schema.marks.copilot_ghost)
|
||||
const endPos = Math.min(state.to, tr.doc.content.size)
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(endPos)))
|
||||
tr.setMeta(COPILOT_PLUGIN_KEY, { ...initialState })
|
||||
view.dispatch(tr)
|
||||
return true
|
||||
}
|
||||
|
||||
function rejectSuggestion(view: EditorView) {
|
||||
const state = COPILOT_PLUGIN_KEY.getState(view.state)
|
||||
if (!state?.suggestion) return false
|
||||
|
||||
clearGhostText(view)
|
||||
return true
|
||||
}
|
||||
|
||||
export const copilotPlugin = $prose((ctx) => {
|
||||
currentCtx = ctx
|
||||
|
||||
return new Plugin<CopilotState>({
|
||||
key: COPILOT_PLUGIN_KEY,
|
||||
state: {
|
||||
init: () => ({ ...initialState }),
|
||||
apply: (tr, value) => {
|
||||
const meta = tr.getMeta(COPILOT_PLUGIN_KEY)
|
||||
if (meta !== undefined) {
|
||||
return meta
|
||||
}
|
||||
|
||||
if (tr.docChanged && value.suggestion) {
|
||||
return { ...initialState }
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
},
|
||||
props: {
|
||||
handleKeyDown: (view, event) => {
|
||||
const state = COPILOT_PLUGIN_KEY.getState(view.state)
|
||||
|
||||
if (event.key === 'Tab' && state?.suggestion) {
|
||||
event.preventDefault()
|
||||
return acceptSuggestion(view)
|
||||
}
|
||||
|
||||
if (event.key === 'Escape' && state?.suggestion) {
|
||||
event.preventDefault()
|
||||
return rejectSuggestion(view)
|
||||
}
|
||||
|
||||
if (state?.suggestion && event.key !== 'Shift' && event.key !== 'Control' && event.key !== 'Alt' && event.key !== 'Meta') {
|
||||
clearGhostText(view)
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
handleClick: (view, pos) => {
|
||||
const state = COPILOT_PLUGIN_KEY.getState(view.state)
|
||||
if (!state?.suggestion) return false
|
||||
|
||||
if (pos >= state.from && pos < state.to) {
|
||||
return acceptSuggestion(view)
|
||||
}
|
||||
|
||||
clearGhostText(view)
|
||||
return false
|
||||
}
|
||||
},
|
||||
view: () => ({
|
||||
update: (view, prevState) => {
|
||||
if (view.state.doc.eq(prevState.doc) && view.state.selection.eq(prevState.selection)) {
|
||||
return
|
||||
}
|
||||
|
||||
const state = COPILOT_PLUGIN_KEY.getState(view.state)
|
||||
if (state?.suggestion) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!view.state.doc.eq(prevState.doc)) {
|
||||
const { from, to } = view.state.selection
|
||||
if (from !== to) return
|
||||
|
||||
const doc = view.state.doc
|
||||
const prefix = doc.textBetween(0, from)
|
||||
const suffix = doc.textBetween(to, doc.content.size)
|
||||
|
||||
scheduleFetch(view, from, prefix, suffix)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
export { COPILOT_PLUGIN_KEY }
|
||||
|
||||
export function isCopilotEnabled(): boolean {
|
||||
return enabled
|
||||
}
|
||||
|
||||
export function setCopilotEnabled(value: boolean): void {
|
||||
enabled = value
|
||||
|
||||
if (!value) {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
abortController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { Plugin, PluginKey, Decoration, DecorationSet } from '@milkdown/prose/state'
|
||||
import { EditorView } from '@milkdown/prose/view'
|
||||
import { fetchSuggestion } from '../utils/api.js'
|
||||
import { API_URL } from '../utils/config.js'
|
||||
|
||||
const INLINE_SUGGESTION_KEY = new PluginKey('inline-suggestion')
|
||||
const DEBOUNCE_MS = 300
|
||||
|
||||
interface InlineSuggestionState {
|
||||
suggestion: string
|
||||
suggestionPos: { from: number; to: number }
|
||||
}
|
||||
|
||||
function createGhostTextDecoration(from: number, to: number, text: string) {
|
||||
return Decoration.inline(from, to, {
|
||||
class: 'ghost-text-decoration',
|
||||
'data-suggestion': text,
|
||||
}, { side: 1 })
|
||||
}
|
||||
|
||||
export function createInlineSuggestionPlugin(options: { apiUrl?: string } = {}) {
|
||||
const apiUrl = options.apiUrl || API_URL
|
||||
|
||||
return new Plugin({
|
||||
key: INLINE_SUGGESTION_KEY,
|
||||
state: {
|
||||
init: (): InlineSuggestionState => ({
|
||||
suggestion: '',
|
||||
suggestionPos: { from: 0, to: 0 }
|
||||
}),
|
||||
apply: (tr, value): InlineSuggestionState => {
|
||||
if (!tr.docChanged) return value
|
||||
|
||||
const { from, to } = tr.selection
|
||||
if (from === value.suggestionPos.from && to === value.suggestionPos.to) {
|
||||
return value
|
||||
}
|
||||
return { suggestion: '', suggestionPos: { from: 0, to: 0 } }
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
const pluginState = INLINE_SUGGESTION_KEY.getState(state)
|
||||
if (!pluginState.suggestion) {
|
||||
return DecorationSet.empty
|
||||
}
|
||||
return DecorationSet.create(state.doc, [
|
||||
createGhostTextDecoration(
|
||||
pluginState.suggestionPos.from,
|
||||
pluginState.suggestionPos.from + pluginState.suggestion.length,
|
||||
pluginState.suggestion
|
||||
)
|
||||
])
|
||||
},
|
||||
handleKeyDown: (view: EditorView, event: KeyboardEvent) => {
|
||||
const state = INLINE_SUGGESTION_KEY.getState(view.state)
|
||||
|
||||
if (event.key === 'Tab' && state.suggestion) {
|
||||
event.preventDefault()
|
||||
const { state: currentState, dispatch } = view
|
||||
dispatch(currentState.tr.insertText(state.suggestion, currentState.selection.from))
|
||||
dispatch(currentState.tr.setMeta(INLINE_SUGGESTION_KEY, {
|
||||
suggestion: '',
|
||||
suggestionPos: { from: 0, to: 0 }
|
||||
}))
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Escape' && state.suggestion) {
|
||||
event.preventDefault()
|
||||
view.dispatch(view.state.tr.setMeta(INLINE_SUGGESTION_KEY, {
|
||||
suggestion: '',
|
||||
suggestionPos: { from: 0, to: 0 }
|
||||
}))
|
||||
return true
|
||||
}
|
||||
|
||||
if (state.suggestion) {
|
||||
view.dispatch(view.state.tr.setMeta(INLINE_SUGGESTION_KEY, {
|
||||
suggestion: '',
|
||||
suggestionPos: { from: 0, to: 0 }
|
||||
}))
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
},
|
||||
appendTransaction: (transactions, oldState, newState) => {
|
||||
const lastTr = transactions[transactions.length - 1]
|
||||
if (!lastTr || !lastTr.docChanged) return null
|
||||
|
||||
const currentState = INLINE_SUGGESTION_KEY.getState(newState)
|
||||
const { from, to } = newState.selection
|
||||
|
||||
if (currentState.suggestion && from === currentState.suggestionPos.from) {
|
||||
return null
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
const prefix = newState.doc.textBetween(0, from)
|
||||
const suffix = newState.doc.textBetween(to, newState.doc.content.size)
|
||||
|
||||
try {
|
||||
const text = await fetchSuggestion(prefix, suffix, apiUrl)
|
||||
|
||||
if (text && newState.selection.from === from) {
|
||||
const newPluginState = {
|
||||
suggestion: text,
|
||||
suggestionPos: { from, to: from + text.length }
|
||||
}
|
||||
|
||||
newState.apply(newState.tr.setMeta(INLINE_SUGGESTION_KEY, newPluginState))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Inline suggestion error:', e)
|
||||
}
|
||||
}, DEBOUNCE_MS)
|
||||
|
||||
return null
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { INLINE_SUGGESTION_KEY }
|
||||
+57
-25
@@ -1,30 +1,62 @@
|
||||
export async function fetchSuggestion(prefix, suffix, apiUrl = 'http://localhost:8000/v1/completions') {
|
||||
const res = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prefix, suffix, languageId: 'markdown' }),
|
||||
})
|
||||
import { DEBUG, API_URL } from './config.js'
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL) {
|
||||
if (DEBUG) console.log('[Debug] fetchSuggestion called with prefix length:', prefix.length, 'suffix length:', suffix.length)
|
||||
try {
|
||||
const res = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prefix, suffix, languageId: 'markdown' }),
|
||||
signal
|
||||
})
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) throw new Error('No reader available')
|
||||
if (DEBUG) console.log('[Debug] fetchSuggestion response status:', res.status)
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text()
|
||||
throw new Error(`HTTP ${res.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
let text = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
const chunk = new TextDecoder().decode(value)
|
||||
const lines = chunk.split('\n').filter(l => l.startsWith('data: '))
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
if (data.content) text += data.content
|
||||
if (data.done || data.error) break
|
||||
} catch (e) {}
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) {
|
||||
if (DEBUG) console.log('[Debug] No reader available')
|
||||
throw new Error('No reader available')
|
||||
}
|
||||
|
||||
let text = ''
|
||||
let buffer = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += new TextDecoder().decode(value)
|
||||
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
const jsonStr = line.slice(6).trim()
|
||||
if (!jsonStr) continue
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
if (data.content) {
|
||||
text += data.content
|
||||
if (DEBUG) console.log('[Debug] Added content:', data.content)
|
||||
}
|
||||
if (data.done || data.error) break
|
||||
} catch (e) {
|
||||
if (DEBUG) console.warn('[Debug] JSON parse error for:', jsonStr.substring(0, 50))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (DEBUG) console.log('[Debug] Final suggestion text:', text.substring(0, 100))
|
||||
return text
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
if (DEBUG) console.log('[Debug] Request aborted')
|
||||
} else {
|
||||
if (DEBUG) console.error('[Debug] fetchSuggestion error:', e)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user