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:
“ydy0615”
2026-02-13 09:24:50 +08:00
parent 16e76e1e90
commit 65d4a57d33
12 changed files with 617 additions and 440 deletions

View File

@@ -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>

View File

@@ -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>