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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user