yh-codemirror 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.workbuddy/memory/2026-09-28.md +19 -0
- package/CHANGELOG.md +40 -0
- package/CodeMirror.vue +892 -21
- package/README.md +100 -0
- package/ai-history.js +42 -0
- package/index.ts +3 -0
- package/package.json +14 -6
- package/util.ts +26 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# 2026-09-28
|
|
2
|
+
|
|
3
|
+
## yh-codemirror 1.0.7 接入 AI 提示代码编辑
|
|
4
|
+
|
|
5
|
+
- 设计取舍:该包必须保持零业务依赖(deps 仅 codemirror 系列 + js-beautify,peer 仅 vue/element-plus),
|
|
6
|
+
因此**不直接依赖 yh-ai**(会拖入 axios/yh-client/yh-pub),改为宿主通过 `aiRequest` 回调注入 AI 能力。
|
|
7
|
+
传入 `aiRequest` 即启用,未传时行为与旧版完全一致(向后兼容)。
|
|
8
|
+
- 实现要点:
|
|
9
|
+
- 作用范围:有选区→选区;无选区→按 `aiScope`(默认 document)整篇或光标插入。
|
|
10
|
+
- 写回用 `view.dispatch({changes:{from,to,insert}})`,保留撤销栈;流式时区间 to 随写入长度前移。
|
|
11
|
+
- 流式:宿主调 `onChunk(chunk)` 增量返回,组件累积后整体覆盖区间;非流式则取返回值字符串。
|
|
12
|
+
- `AbortController` + signal 支持中断(停止按钮 / Esc / 卸载)。
|
|
13
|
+
- 自动剥离模型输出的 ```lang 围栏。
|
|
14
|
+
- 快捷键用 `keymap.of`(注意:`keymap` 不从 `codemirror` 导出,必须从 `@codemirror/view` 导入)。
|
|
15
|
+
- 新增事件 `ai-apply` / `ai-error`,expose `openAi/closeAi/stopAi/getView`。
|
|
16
|
+
- 新增 README.md(接入示例:非流式 + SSE 流式)、CHANGELOG.md;version 1.0.6 → 1.0.7。
|
|
17
|
+
- 验证:临时 .smoke 工程用 vite 4 + @vitejs/plugin-vue 构建通过(1663 modules)。
|
|
18
|
+
注意:root node_modules 的 vue 是 3.2.47,与 element-plus 内部 @vueuse 不兼容(hasInjectionContext 缺失),
|
|
19
|
+
**冒烟构建要把 vue alias 指向 web-new/node_modules/vue(3.5.13)**。
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# CHANGELOG
|
|
2
|
+
|
|
3
|
+
## 1.0.7
|
|
4
|
+
|
|
5
|
+
- 2026-09-30 18:54 feature: 新增 `aiPanelSize` 尺寸档位(`'normal' | 'large'`)
|
|
6
|
+
- 放大场景(window-form 的「放大」抽屉)传 `large`:面板内边距 6/8 → 10/12、字号 12 → 14px、
|
|
7
|
+
输入框 2 行 → 4 行、历史面板 max-height 62% → 72%(代码区 170px → 300px)
|
|
8
|
+
- 抽屉里的编辑区撑满整个抽屉,AI 面板还是 12px / 2 行会显得很局促
|
|
9
|
+
- 2026-09-30 18:45 feature: 新增「历史问答」查看入口(AI 输入框右上角「历史」)
|
|
10
|
+
- 每次 AI 成功写入后记录 `{时间, 问题, 作用范围, 回答}`,列表可展开查看完整问答、一键复制回答、清空
|
|
11
|
+
- 历史存在新增的 `ai-history.js` **模块级单例**(上限 50 条,最新在前);
|
|
12
|
+
不放组件内是因为配置面板里的编辑器会随切换配置项反复卸载重建,组件 ref 一卸载历史就没了
|
|
13
|
+
- 2026-09-30 18:26 fix: 选中区被当前行整块盖住(真正的根因是**绘制层级**,不是配色)
|
|
14
|
+
- CM6 的选区画在 `.cm-selectionLayer` 上:`drawSelection` 内部 `above:false` → z-index 取 `-1-pos`(负数),
|
|
15
|
+
它和 `.cm-content` 同在 `.cm-scroller`(`position:relative; z-index:0`)这个层叠上下文里
|
|
16
|
+
- 按 CSS 绘制顺序「负 z-index 后代 → in-flow 块 → 行内内容」,`.cm-line` 作为普通 in-flow 块
|
|
17
|
+
**画在选区层之上**;只要给 `.cm-line` 设不透明背景,选区就被完全遮住
|
|
18
|
+
- baseTheme 自带的 `&light .cm-activeLine:#cceeff44` / `&dark:#99eeff33` 是半透明的才不会遮,
|
|
19
|
+
solarizedDark 的 `.cm-activeLine:#173541` 和它的 `selection:#173541` 一样是不透明的同色 —— 这才是最初「看不出来」的根源
|
|
20
|
+
- 解法:当前行高亮挪到 `.cm-line.cm-activeLine::before`(`z-index:-100`),
|
|
21
|
+
同时把 `.cm-activeLine` 自身清成 `transparent`;绘制顺序变成「当前行底(-100) → 选区(-2) → 文字」
|
|
22
|
+
- 2026-09-28 10:04 feature: 新增 AI 提示代码编辑能力(回调注入 `aiRequest`,不引入任何 AI 服务依赖)
|
|
23
|
+
- 支持选区 / 整篇 / 光标插入三种作用范围(`aiScope`)
|
|
24
|
+
- 支持流式回写(`onChunk` 实时写入编辑器)
|
|
25
|
+
- 支持中断(`AbortSignal` + 停止按钮 / Esc)
|
|
26
|
+
- 新增 `ai-apply`、`ai-error` 事件与 `openAi / closeAi / stopAi / getView` 暴露方法
|
|
27
|
+
- 新增快捷键 `Mod-i` 唤起 AI 输入框(`aiHotkey` 可配)
|
|
28
|
+
- 回写走 dispatch,保留撤销栈;自动剥离 ```lang 代码围栏
|
|
29
|
+
- 2026-09-28 10:04 docs: 新增 README.md 说明 AI 接入方式(非流式 / SSE 流式示例)
|
|
30
|
+
- 2026-09-30 18:08 fix: 拖选/双击选词仍然和当前行同色(上一版权重不够)
|
|
31
|
+
- CM 6.39 的 baseTheme 用 `&light.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground`
|
|
32
|
+
(5 个类 + 子代选择器)给了默认色 `#d7d4f0` / `#233`,权重高于组件规则,堆类名盖不住
|
|
33
|
+
- 选区色改带 `!important`(不跟它的 DOM 结构绑死),当前行仍用选择器权重覆盖
|
|
34
|
+
- 补回浅色 `.cm-selectionMatch`(双击选词后其它同名 occurrence),改用暖色 `#ffe9c2` 与蓝色选区区分
|
|
35
|
+
- solarizedDark 的 `selection` 与 `activeLine` 都是 `#173541`,浅色主题又是 `#b3d8ff` / `#e8f4ff` 同一蓝色系
|
|
36
|
+
- 改为「当前行 = 中性色,选中 = 蓝色」,并区分聚焦/失焦两档选中色
|
|
37
|
+
- 配色统一挪到组件 `<style>`(带 `.cm-editor` / `.cm-editor.cm-focused` 前缀,权重高于外部主题),
|
|
38
|
+
`lightTheme` 里不再重复维护
|
|
39
|
+
- 2026-09-30 17:54 feature: 编辑器未聚焦时隐藏右下角功能按钮栏(`.editor-operation-btn.is-hidden`)
|
|
40
|
+
- 按钮栏加 `@mousedown.prevent`,避免点按钮瞬间编辑器失焦导致按钮隐藏、click 落空
|
package/CodeMirror.vue
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div
|
|
3
3
|
class="yh-code-mirror-container"
|
|
4
|
+
:class="{'is-light': props.theme === 'light', 'is-ai-large': props.aiPanelSize === 'large'}"
|
|
4
5
|
ref="YhCodemirrorContainer"
|
|
5
6
|
:style="codeMirrorStyle">
|
|
6
7
|
<div class="code-slot">
|
|
@@ -13,7 +14,130 @@
|
|
|
13
14
|
<div class="code-slot">
|
|
14
15
|
<slot name="next-code"></slot>
|
|
15
16
|
</div>
|
|
16
|
-
<div class="
|
|
17
|
+
<div v-if="aiEnabled && aiVisible" class="ai-prompt-panel">
|
|
18
|
+
<div class="ai-panel-head">
|
|
19
|
+
<span class="ai-tag">AI</span>
|
|
20
|
+
<span class="ai-scope">将修改{{ aiScopeText }}</span>
|
|
21
|
+
<span
|
|
22
|
+
class="ai-history-entry"
|
|
23
|
+
:class="{'is-active': aiHistoryVisible}"
|
|
24
|
+
title="查看历史问答"
|
|
25
|
+
@click="aiHistoryVisible = !aiHistoryVisible"
|
|
26
|
+
>历史{{ aiHistory.length ? `(${aiHistory.length})` : '' }}</span
|
|
27
|
+
>
|
|
28
|
+
<span
|
|
29
|
+
class="ai-close"
|
|
30
|
+
title="关闭"
|
|
31
|
+
@click="closeAi"
|
|
32
|
+
>×</span
|
|
33
|
+
>
|
|
34
|
+
</div>
|
|
35
|
+
<textarea
|
|
36
|
+
ref="aiInputRef"
|
|
37
|
+
v-model="aiPrompt"
|
|
38
|
+
class="ai-input"
|
|
39
|
+
:rows="aiInputRows"
|
|
40
|
+
:disabled="aiLoading"
|
|
41
|
+
:placeholder="aiPlaceholder"
|
|
42
|
+
@keydown.enter.exact.prevent="submitAi"
|
|
43
|
+
@keydown.esc.stop.prevent="closeAi"></textarea>
|
|
44
|
+
<div class="ai-panel-foot">
|
|
45
|
+
<span class="ai-tip">{{
|
|
46
|
+
aiLoading ? (aiStreaming ? '生成中…' : 'AI 正在处理…') : 'Enter 发送 / Esc 取消'
|
|
47
|
+
}}</span>
|
|
48
|
+
<span
|
|
49
|
+
v-if="!aiLoading"
|
|
50
|
+
class="ai-btn"
|
|
51
|
+
:class="{'is-disabled': !aiPrompt.trim()}"
|
|
52
|
+
@click="submitAi"
|
|
53
|
+
>生成</span
|
|
54
|
+
>
|
|
55
|
+
<span
|
|
56
|
+
v-else
|
|
57
|
+
class="ai-btn danger"
|
|
58
|
+
@click="stopAi"
|
|
59
|
+
>停止</span
|
|
60
|
+
>
|
|
61
|
+
</div>
|
|
62
|
+
<div
|
|
63
|
+
v-if="aiError"
|
|
64
|
+
class="ai-error">
|
|
65
|
+
{{ aiError }}
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
<!-- 历史问答:和输入框互斥展示,关掉输入框时不影响单独查看 -->
|
|
69
|
+
<div
|
|
70
|
+
v-if="aiEnabled && aiHistoryVisible"
|
|
71
|
+
class="ai-history-panel">
|
|
72
|
+
<div class="ai-history-head">
|
|
73
|
+
<span class="ai-tag">历史</span>
|
|
74
|
+
<span class="ai-scope">{{ aiHistory.length ? `共 ${aiHistory.length} 条问答` : '暂无记录' }}</span>
|
|
75
|
+
<span
|
|
76
|
+
v-if="aiHistory.length"
|
|
77
|
+
class="ai-btn plain"
|
|
78
|
+
@click="clearHistory"
|
|
79
|
+
>清空</span
|
|
80
|
+
>
|
|
81
|
+
<span
|
|
82
|
+
class="ai-close"
|
|
83
|
+
title="关闭"
|
|
84
|
+
@click="aiHistoryVisible = false"
|
|
85
|
+
>×</span
|
|
86
|
+
>
|
|
87
|
+
</div>
|
|
88
|
+
<div
|
|
89
|
+
v-if="!aiHistory.length"
|
|
90
|
+
class="ai-history-empty">
|
|
91
|
+
还没有历史记录,问点什么试试
|
|
92
|
+
</div>
|
|
93
|
+
<div
|
|
94
|
+
v-else
|
|
95
|
+
class="ai-history-list">
|
|
96
|
+
<div
|
|
97
|
+
v-for="item in aiHistory"
|
|
98
|
+
:key="item.id"
|
|
99
|
+
class="ai-history-item">
|
|
100
|
+
<div
|
|
101
|
+
class="ai-history-q"
|
|
102
|
+
@click="toggleHistoryItem(item.id)">
|
|
103
|
+
<span class="ai-history-time">{{ item.time }}</span>
|
|
104
|
+
<span class="ai-history-text">{{ item.prompt }}</span>
|
|
105
|
+
<span class="ai-history-toggle">{{ expandedHistoryId === item.id ? '收起' : '展开' }}</span>
|
|
106
|
+
</div>
|
|
107
|
+
<div
|
|
108
|
+
v-if="expandedHistoryId === item.id"
|
|
109
|
+
class="ai-history-body">
|
|
110
|
+
<div class="ai-history-label">
|
|
111
|
+
问题<span class="ai-history-scope">{{ item.scope }}</span>
|
|
112
|
+
</div>
|
|
113
|
+
<pre class="ai-history-pre">{{ item.prompt }}</pre>
|
|
114
|
+
<div class="ai-history-label">
|
|
115
|
+
回答
|
|
116
|
+
<span
|
|
117
|
+
class="ai-btn plain"
|
|
118
|
+
@click="copyHistoryAnswer(item.answer)"
|
|
119
|
+
>复制</span
|
|
120
|
+
>
|
|
121
|
+
</div>
|
|
122
|
+
<pre class="ai-history-pre is-answer">{{ item.answer }}</pre>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
<!-- 未聚焦时隐藏(.is-hidden):按钮栏会挡住代码尾部,只在编辑时露出来。
|
|
128
|
+
@mousedown.prevent 阻止默认的焦点转移,否则点按钮的瞬间编辑器失焦、按钮立刻隐藏,
|
|
129
|
+
mouseup 落不到按钮上,点击会失效。 -->
|
|
130
|
+
<div
|
|
131
|
+
class="editor-operation-btn"
|
|
132
|
+
:class="{'is-hidden': !editorFocused}"
|
|
133
|
+
@mousedown.prevent>
|
|
134
|
+
<span
|
|
135
|
+
v-if="aiEnabled"
|
|
136
|
+
class="ai-entry"
|
|
137
|
+
:title="`AI 编辑(${aiHotkeyLabel})`"
|
|
138
|
+
@click="openAi()"
|
|
139
|
+
>AI</span
|
|
140
|
+
>
|
|
17
141
|
<i
|
|
18
142
|
title="全屏"
|
|
19
143
|
class="iconfont icon-fullscreen"
|
|
@@ -39,15 +163,19 @@
|
|
|
39
163
|
</div>
|
|
40
164
|
</template>
|
|
41
165
|
<script setup lang="ts">
|
|
42
|
-
import {ref, onMounted, toRaw} from 'vue';
|
|
166
|
+
import {ref, onMounted, onBeforeUnmount, nextTick, toRaw} from 'vue';
|
|
43
167
|
// @ts-ignore
|
|
44
168
|
import type {Extension} from 'codemirror';
|
|
45
169
|
import {EditorView, basicSetup} from 'codemirror';
|
|
46
|
-
import {ViewUpdate} from '@codemirror/view';
|
|
170
|
+
import {ViewUpdate, keymap} from '@codemirror/view';
|
|
47
171
|
import {EditorState, EditorSelection} from '@codemirror/state';
|
|
48
172
|
import {javascriptLanguage} from '@codemirror/lang-javascript';
|
|
49
173
|
import {solarizedDark} from 'cm6-theme-solarized-dark';
|
|
174
|
+
import {HighlightStyle, syntaxHighlighting} from '@codemirror/language';
|
|
175
|
+
import {tags} from '@lezer/highlight';
|
|
50
176
|
import {getCodeLanguagePlugin} from './util';
|
|
177
|
+
import type {AiCodeEditParams, AiCodeEditRequest} from './util';
|
|
178
|
+
import {aiHistory, pushAiHistory, clearAiHistory} from './ai-history';
|
|
51
179
|
import {watch, computed} from 'vue';
|
|
52
180
|
// @ts-ignore
|
|
53
181
|
import {js_beautify} from 'js-beautify';
|
|
@@ -63,6 +191,8 @@ function fullscreen() {
|
|
|
63
191
|
}
|
|
64
192
|
|
|
65
193
|
const codeMirrorEditorRef = ref();
|
|
194
|
+
/** 编辑器是否持有焦点:决定右下角功能按钮栏是否显示 */
|
|
195
|
+
const editorFocused = ref(false);
|
|
66
196
|
|
|
67
197
|
let view: null | EditorView = null;
|
|
68
198
|
let state: null | EditorState = null;
|
|
@@ -77,14 +207,78 @@ const props = withDefaults(
|
|
|
77
207
|
language?: string | string[];
|
|
78
208
|
autocomplete?: any;
|
|
79
209
|
height?: number;
|
|
210
|
+
/** AI 请求函数;传入即启用 AI 提示编辑。返回完整替换文本,流式输出时通过 onChunk 回调 */
|
|
211
|
+
aiRequest?: AiCodeEditRequest;
|
|
212
|
+
/** 唤起 AI 输入框的快捷键(CodeMirror 键位语法),默认 Mod-i(Win: Ctrl+I / Mac: Cmd+I) */
|
|
213
|
+
aiHotkey?: string;
|
|
214
|
+
/** AI 输入框占位提示 */
|
|
215
|
+
aiPlaceholder?: string;
|
|
216
|
+
/** 无选中时 AI 的作用范围:document 整个文档 / cursor 光标处插入 */
|
|
217
|
+
aiScope?: 'document' | 'cursor';
|
|
218
|
+
/** 编辑器主题:dark(默认)或 light */
|
|
219
|
+
theme?: 'dark' | 'light';
|
|
220
|
+
/**
|
|
221
|
+
* AI 输入框/历史面板的尺寸档位。编辑区被放大时(如 window-form 的「放大」抽屉)传 'large',
|
|
222
|
+
* 否则 12px 字号 + 2 行输入框在大面板里会显得很局促。
|
|
223
|
+
*/
|
|
224
|
+
aiPanelSize?: 'normal' | 'large';
|
|
80
225
|
}>(),
|
|
81
226
|
{
|
|
82
227
|
modelValue: '',
|
|
83
228
|
language: 'javascript',
|
|
84
229
|
autocomplete: {},
|
|
230
|
+
aiHotkey: 'Mod-i',
|
|
231
|
+
aiPlaceholder: '描述你想要的修改,如:给这个函数加上异常处理',
|
|
232
|
+
aiScope: 'document',
|
|
233
|
+
theme: 'dark',
|
|
234
|
+
aiPanelSize: 'normal',
|
|
85
235
|
}
|
|
86
236
|
);
|
|
87
237
|
|
|
238
|
+
// 浅色主题(匹配原 window-form.vue 的浅色调)
|
|
239
|
+
const lightTheme = EditorView.theme(
|
|
240
|
+
{
|
|
241
|
+
'&': {color: '#333', backgroundColor: '#fff'},
|
|
242
|
+
'.cm-content': {caretColor: '#333'},
|
|
243
|
+
'.cm-cursor, .cm-dropCursor': {borderLeftColor: '#333'},
|
|
244
|
+
// 选中区/当前行的配色不在这里写:solarizedDark 这类外部主题要靠更高权重的选择器覆盖,
|
|
245
|
+
// 统一放在组件 <style> 里的 ".cm-editor .cm-selectionBackground / .cm-activeLine" 规则中,避免两处维护。
|
|
246
|
+
'.cm-panels': {backgroundColor: '#f5f5f5', color: '#333'},
|
|
247
|
+
'.cm-gutters': {
|
|
248
|
+
backgroundColor: '#f5f5f5',
|
|
249
|
+
color: '#888',
|
|
250
|
+
borderRight: '1px solid #e0e0e0',
|
|
251
|
+
},
|
|
252
|
+
// .cm-activeLineGutter 同 .cm-activeLine,见上面注释,统一在 <style> 里维护
|
|
253
|
+
'.cm-tooltip': {border: '1px solid #ddd', backgroundColor: '#fff'},
|
|
254
|
+
'.cm-tooltip .cm-tooltip-arrow:after': {
|
|
255
|
+
borderTopColor: '#fff',
|
|
256
|
+
borderBottomColor: '#fff',
|
|
257
|
+
},
|
|
258
|
+
'.cm-tooltip-autocomplete': {
|
|
259
|
+
'& > ul > li[aria-selected]': {backgroundColor: '#e8f4ff', color: '#333'},
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
{dark: false}
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const lightHighlightStyle = HighlightStyle.define([
|
|
266
|
+
{tag: tags.keyword, color: '#0000ff'},
|
|
267
|
+
{tag: tags.string, color: '#008000'},
|
|
268
|
+
{tag: tags.comment, color: '#808080', fontStyle: 'italic'},
|
|
269
|
+
{tag: tags.function(tags.variableName), color: '#795e26'},
|
|
270
|
+
{tag: tags.number, color: '#098658'},
|
|
271
|
+
{tag: tags.operator, color: '#333'},
|
|
272
|
+
{tag: tags.className, color: '#267f99'},
|
|
273
|
+
{tag: tags.tagName, color: '#0000ff'},
|
|
274
|
+
{tag: tags.attributeName, color: '#e50000'},
|
|
275
|
+
{tag: [tags.variableName], color: '#333'},
|
|
276
|
+
{tag: tags.bool, color: '#0000ff'},
|
|
277
|
+
{tag: tags.null, color: '#0000ff'},
|
|
278
|
+
]);
|
|
279
|
+
|
|
280
|
+
const lightThemeExtension = [lightTheme, syntaxHighlighting(lightHighlightStyle)];
|
|
281
|
+
|
|
88
282
|
const codeMirrorStyle = computed(() => {
|
|
89
283
|
let style: any = {};
|
|
90
284
|
if (props.height !== undefined && !isNaN(props.height)) {
|
|
@@ -107,7 +301,7 @@ const codeMirrorEditorStyle = computed(() => {
|
|
|
107
301
|
watch(
|
|
108
302
|
() => props.modelValue,
|
|
109
303
|
(val) => {
|
|
110
|
-
if (view && state &&
|
|
304
|
+
if (view && state && val != view.state.doc.toString()) {
|
|
111
305
|
let end = view.state.doc.toString().length;
|
|
112
306
|
view.dispatch({
|
|
113
307
|
changes: [
|
|
@@ -119,14 +313,193 @@ watch(
|
|
|
119
313
|
],
|
|
120
314
|
});
|
|
121
315
|
}
|
|
122
|
-
},
|
|
123
|
-
{
|
|
124
|
-
deep: true,
|
|
125
|
-
immediate: true,
|
|
126
316
|
}
|
|
127
317
|
);
|
|
128
318
|
|
|
129
|
-
const emits = defineEmits(['update:modelValue']);
|
|
319
|
+
const emits = defineEmits(['update:modelValue', 'ai-apply', 'ai-error']);
|
|
320
|
+
|
|
321
|
+
/* ------------------------------ AI 提示编辑 ------------------------------ */
|
|
322
|
+
const aiEnabled = computed(() => typeof props.aiRequest === 'function');
|
|
323
|
+
const aiHotkeyLabel = computed(() =>
|
|
324
|
+
props.aiHotkey.replace('Mod-', navigator.platform.toLowerCase().includes('mac') ? '⌘' : 'Ctrl+')
|
|
325
|
+
);
|
|
326
|
+
const aiVisible = ref(false);
|
|
327
|
+
const aiPrompt = ref('');
|
|
328
|
+
const aiLoading = ref(false);
|
|
329
|
+
const aiStreaming = ref(false);
|
|
330
|
+
const aiError = ref('');
|
|
331
|
+
const aiScopeText = ref('');
|
|
332
|
+
const aiInputRef = ref();
|
|
333
|
+
// 放大档位下输入框多给两行,配合 .is-ai-large 的字号提升
|
|
334
|
+
const aiInputRows = computed(() => (props.aiPanelSize === 'large' ? 4 : 2));
|
|
335
|
+
// 历史问答(模块级单例,见 ./ai-history)
|
|
336
|
+
const aiHistoryVisible = ref(false);
|
|
337
|
+
const expandedHistoryId = ref<number | null>(null);
|
|
338
|
+
const toggleHistoryItem = (id: number) => {
|
|
339
|
+
expandedHistoryId.value = expandedHistoryId.value === id ? null : id;
|
|
340
|
+
};
|
|
341
|
+
const clearHistory = () => {
|
|
342
|
+
clearAiHistory();
|
|
343
|
+
expandedHistoryId.value = null;
|
|
344
|
+
};
|
|
345
|
+
const copyHistoryAnswer = async (text: string) => {
|
|
346
|
+
try {
|
|
347
|
+
await navigator.clipboard.writeText(text || '');
|
|
348
|
+
ElMessage({message: '已复制回答', type: 'success'});
|
|
349
|
+
} catch (e) {
|
|
350
|
+
ElMessage({message: '复制失败,请手动选中复制', type: 'warning'});
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
// 当前 AI 作用区间(流式过程中 to 会随写入推进)
|
|
354
|
+
let aiRange: {from: number; to: number} | null = null;
|
|
355
|
+
let aiAbort: AbortController | null = null;
|
|
356
|
+
|
|
357
|
+
/** 打开 AI 输入框:有选区则作用于选区,否则按 aiScope 处理 */
|
|
358
|
+
const openAi = (presetPrompt?: string) => {
|
|
359
|
+
if (!aiEnabled.value || !view) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const st = toRaw(view.state);
|
|
363
|
+
const sel = st.selection.main;
|
|
364
|
+
if (!sel.empty) {
|
|
365
|
+
aiRange = {from: sel.from, to: sel.to};
|
|
366
|
+
const lines = st.doc.lineAt(sel.to).number - st.doc.lineAt(sel.from).number + 1;
|
|
367
|
+
aiScopeText.value = `选中的 ${lines} 行代码`;
|
|
368
|
+
} else if (props.aiScope === 'cursor') {
|
|
369
|
+
aiRange = {from: sel.head, to: sel.head};
|
|
370
|
+
aiScopeText.value = '在光标处插入';
|
|
371
|
+
} else {
|
|
372
|
+
aiRange = {from: 0, to: st.doc.length};
|
|
373
|
+
aiScopeText.value = '整个文档';
|
|
374
|
+
}
|
|
375
|
+
aiPrompt.value = presetPrompt || '';
|
|
376
|
+
aiError.value = '';
|
|
377
|
+
aiLoading.value = false;
|
|
378
|
+
aiVisible.value = true;
|
|
379
|
+
nextTick(() => {
|
|
380
|
+
aiInputRef.value && aiInputRef.value.focus();
|
|
381
|
+
});
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const closeAi = () => {
|
|
385
|
+
if (!aiVisible.value) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
stopAi();
|
|
389
|
+
aiVisible.value = false;
|
|
390
|
+
aiRange = null;
|
|
391
|
+
view && view.focus();
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const stopAi = () => {
|
|
395
|
+
if (aiAbort) {
|
|
396
|
+
aiAbort.abort();
|
|
397
|
+
aiAbort = null;
|
|
398
|
+
}
|
|
399
|
+
aiLoading.value = false;
|
|
400
|
+
aiStreaming.value = false;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
/** 把文本写入区间;流式场景下区间终点随写入长度前移 */
|
|
404
|
+
const writeRange = (range: {from: number; to: number}, text: string) => {
|
|
405
|
+
if (!view) {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const to = Math.min(range.to, view.state.doc.length);
|
|
409
|
+
view.dispatch({
|
|
410
|
+
changes: {from: range.from, to, insert: text},
|
|
411
|
+
selection: EditorSelection.range(range.from, range.from + text.length),
|
|
412
|
+
scrollIntoView: true,
|
|
413
|
+
});
|
|
414
|
+
range.to = range.from + text.length;
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
/** 兼容模型常见的 ```lang ... ``` 包裹输出 */
|
|
418
|
+
const stripCodeFence = (text: string) => {
|
|
419
|
+
const trimmed = (text || '').replace(/\r\n/g, '\n').trim();
|
|
420
|
+
const matched = trimmed.match(/^```[a-zA-Z0-9_+\-]*[ \t]*\n([\s\S]*?)\n?```$/);
|
|
421
|
+
return matched ? matched[1] : trimmed;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const submitAi = async () => {
|
|
425
|
+
const prompt = aiPrompt.value.trim();
|
|
426
|
+
if (!prompt || !view || !aiRange || !props.aiRequest || aiLoading.value) {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const range = {...aiRange};
|
|
430
|
+
const st = toRaw(view.state);
|
|
431
|
+
range.from = Math.max(0, Math.min(range.from, st.doc.length));
|
|
432
|
+
range.to = Math.max(range.from, Math.min(range.to, st.doc.length));
|
|
433
|
+
const selectedCode = st.sliceDoc(range.from, range.to);
|
|
434
|
+
|
|
435
|
+
aiLoading.value = true;
|
|
436
|
+
aiStreaming.value = false;
|
|
437
|
+
aiError.value = '';
|
|
438
|
+
const controller = new AbortController();
|
|
439
|
+
aiAbort = controller;
|
|
440
|
+
let streamed = false;
|
|
441
|
+
let acc = '';
|
|
442
|
+
const onChunk = (chunk: string) => {
|
|
443
|
+
if (chunk === undefined || chunk === null) {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
streamed = true;
|
|
447
|
+
aiStreaming.value = true;
|
|
448
|
+
acc += chunk;
|
|
449
|
+
writeRange(range, acc);
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const params: AiCodeEditParams = {
|
|
453
|
+
prompt,
|
|
454
|
+
code: st.doc.toString(),
|
|
455
|
+
selectedCode,
|
|
456
|
+
language: props.language,
|
|
457
|
+
signal: controller.signal,
|
|
458
|
+
onChunk,
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
try {
|
|
462
|
+
const res: any = await props.aiRequest(params);
|
|
463
|
+
if (controller.signal.aborted) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (!streamed) {
|
|
467
|
+
const text = typeof res === 'string' ? res : res?.text ?? res?.code ?? res?.content ?? '';
|
|
468
|
+
writeRange(range, stripCodeFence(text));
|
|
469
|
+
} else {
|
|
470
|
+
writeRange(range, stripCodeFence(acc));
|
|
471
|
+
}
|
|
472
|
+
aiLoading.value = false;
|
|
473
|
+
aiStreaming.value = false;
|
|
474
|
+
aiVisible.value = false;
|
|
475
|
+
aiRange = null;
|
|
476
|
+
view.focus();
|
|
477
|
+
const appliedText = view.state.sliceDoc(range.from, range.to);
|
|
478
|
+
// 记录历史问答(含 AI 最终写入编辑器的完整文本,撤销了也能在这里找回原文)
|
|
479
|
+
pushAiHistory({prompt, scope: aiScopeText.value, answer: appliedText});
|
|
480
|
+
emits('ai-apply', {prompt, text: appliedText});
|
|
481
|
+
ElMessage({
|
|
482
|
+
message: 'AI 修改已应用',
|
|
483
|
+
type: 'success',
|
|
484
|
+
});
|
|
485
|
+
} catch (err: any) {
|
|
486
|
+
aiLoading.value = false;
|
|
487
|
+
aiStreaming.value = false;
|
|
488
|
+
if (err?.name === 'AbortError' || controller.signal.aborted) {
|
|
489
|
+
aiError.value = '已取消';
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const message = err?.message || 'AI 请求失败';
|
|
493
|
+
aiError.value = message;
|
|
494
|
+
emits('ai-error', err);
|
|
495
|
+
ElMessage({
|
|
496
|
+
message,
|
|
497
|
+
type: 'error',
|
|
498
|
+
});
|
|
499
|
+
} finally {
|
|
500
|
+
aiAbort = null;
|
|
501
|
+
}
|
|
502
|
+
};
|
|
130
503
|
|
|
131
504
|
// 代码编辑器操作按钮
|
|
132
505
|
const onEditorBtnClick = (type) => {
|
|
@@ -182,12 +555,15 @@ const onEditorBtnClick = (type) => {
|
|
|
182
555
|
onMounted(() => {
|
|
183
556
|
const extensions: Extension[] = [
|
|
184
557
|
basicSetup,
|
|
185
|
-
solarizedDark,
|
|
558
|
+
props.theme === 'light' ? lightThemeExtension : solarizedDark,
|
|
186
559
|
EditorView.lineWrapping,
|
|
187
560
|
EditorView.updateListener.of((v: ViewUpdate) => {
|
|
188
561
|
if (v.docChanged) {
|
|
189
562
|
emits('update:modelValue', v.state.doc.toString());
|
|
190
563
|
}
|
|
564
|
+
if (v.focusChanged) {
|
|
565
|
+
editorFocused.value = v.view.hasFocus;
|
|
566
|
+
}
|
|
191
567
|
}),
|
|
192
568
|
];
|
|
193
569
|
const language = props.language;
|
|
@@ -214,44 +590,217 @@ onMounted(() => {
|
|
|
214
590
|
}
|
|
215
591
|
}
|
|
216
592
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
593
|
+
|
|
594
|
+
if (aiEnabled.value) {
|
|
595
|
+
extensions.push(
|
|
596
|
+
keymap.of([
|
|
597
|
+
{
|
|
598
|
+
key: props.aiHotkey,
|
|
599
|
+
run: () => {
|
|
600
|
+
openAi();
|
|
601
|
+
return true;
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
{
|
|
605
|
+
key: 'Escape',
|
|
606
|
+
run: () => {
|
|
607
|
+
if (aiVisible.value) {
|
|
608
|
+
closeAi();
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
return false;
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
])
|
|
615
|
+
);
|
|
226
616
|
}
|
|
617
|
+
|
|
618
|
+
state = EditorState.create({
|
|
619
|
+
doc: props.modelValue,
|
|
620
|
+
extensions,
|
|
621
|
+
});
|
|
227
622
|
if (state) {
|
|
228
623
|
view = new EditorView({
|
|
229
624
|
state: state,
|
|
230
625
|
parent: codeMirrorEditorRef.value,
|
|
231
626
|
});
|
|
627
|
+
editorFocused.value = view.hasFocus;
|
|
232
628
|
}
|
|
233
629
|
});
|
|
630
|
+
|
|
631
|
+
onBeforeUnmount(() => {
|
|
632
|
+
stopAi();
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
defineExpose({
|
|
636
|
+
/** 唤起 AI 编辑输入框(可预填提示词) */
|
|
637
|
+
openAi,
|
|
638
|
+
/** 关闭 AI 编辑输入框 */
|
|
639
|
+
closeAi,
|
|
640
|
+
/** 中断进行中的 AI 请求 */
|
|
641
|
+
stopAi,
|
|
642
|
+
/** 获取 CodeMirror 实例 */
|
|
643
|
+
getView: () => view,
|
|
644
|
+
});
|
|
234
645
|
</script>
|
|
235
646
|
<style lang="scss">
|
|
236
647
|
.yh-code-mirror-container {
|
|
237
648
|
width: 100%;
|
|
238
649
|
position: relative;
|
|
239
650
|
background-color: #173541;
|
|
240
|
-
|
|
651
|
+
&.is-light {
|
|
652
|
+
background-color: #fff;
|
|
653
|
+
.cm-editor {
|
|
654
|
+
.cm-line.cm-activeLine::before {
|
|
655
|
+
background-color: #f0f2f5;
|
|
656
|
+
}
|
|
657
|
+
.cm-activeLineGutter {
|
|
658
|
+
background-color: #f0f2f5;
|
|
659
|
+
}
|
|
660
|
+
.cm-selectionBackground {
|
|
661
|
+
background-color: #d4e7f9 !important;
|
|
662
|
+
}
|
|
663
|
+
&.cm-focused .cm-selectionBackground {
|
|
664
|
+
background-color: #b3d8ff !important;
|
|
665
|
+
}
|
|
666
|
+
// 双击选词后其它同名 occurrence(highlightSelectionMatches):用暖色,和蓝色选区、中性当前行都区分开
|
|
667
|
+
.cm-selectionMatch {
|
|
668
|
+
background-color: #ffe9c2;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
.code-slot {
|
|
672
|
+
color: #333;
|
|
673
|
+
}
|
|
674
|
+
.codemirror-editor {
|
|
675
|
+
&::-webkit-scrollbar {
|
|
676
|
+
background-color: #f5f5f5;
|
|
677
|
+
}
|
|
678
|
+
&::-webkit-scrollbar-thumb {
|
|
679
|
+
background-color: #c1c1c1;
|
|
680
|
+
&:hover {
|
|
681
|
+
background-color: #a8a8a8;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
.ai-prompt-panel {
|
|
686
|
+
background: rgba(255, 255, 255, 0.98);
|
|
687
|
+
border-color: #409eff;
|
|
688
|
+
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
|
689
|
+
.ai-panel-head {
|
|
690
|
+
color: #333;
|
|
691
|
+
}
|
|
692
|
+
.ai-input {
|
|
693
|
+
border-color: #dcdfe6;
|
|
694
|
+
background: #fff;
|
|
695
|
+
color: #333;
|
|
696
|
+
&:focus {
|
|
697
|
+
border-color: #409eff;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
.ai-panel-foot {
|
|
701
|
+
color: #606266;
|
|
702
|
+
.ai-btn.is-disabled {
|
|
703
|
+
background: #c0c4cc;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
.ai-error {
|
|
707
|
+
color: #f56c6c;
|
|
708
|
+
}
|
|
709
|
+
.ai-history-entry {
|
|
710
|
+
border-color: #dcdfe6;
|
|
711
|
+
color: #606266;
|
|
712
|
+
&:hover,
|
|
713
|
+
&.is-active {
|
|
714
|
+
color: #fff;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
.ai-history-panel {
|
|
719
|
+
background: rgba(255, 255, 255, 0.98);
|
|
720
|
+
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
|
721
|
+
.ai-history-head {
|
|
722
|
+
color: #333;
|
|
723
|
+
}
|
|
724
|
+
.ai-btn.plain {
|
|
725
|
+
border-color: #dcdfe6;
|
|
726
|
+
color: #606266;
|
|
727
|
+
}
|
|
728
|
+
.ai-history-empty {
|
|
729
|
+
color: #606266;
|
|
730
|
+
}
|
|
731
|
+
.ai-history-item {
|
|
732
|
+
border-top-color: #ebeef5;
|
|
733
|
+
}
|
|
734
|
+
.ai-history-q {
|
|
735
|
+
color: #333;
|
|
736
|
+
}
|
|
737
|
+
.ai-history-label .ai-history-scope {
|
|
738
|
+
color: #606266;
|
|
739
|
+
}
|
|
740
|
+
.ai-history-pre {
|
|
741
|
+
background: #f5f7fa;
|
|
742
|
+
color: #333;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
241
746
|
.code-slot {
|
|
242
747
|
width: 100%;
|
|
243
748
|
font-family: 'Fira Code', 'Roboto Mono', Consolas, 'Microsoft Yahei';
|
|
244
749
|
line-height: 25px;
|
|
245
|
-
font-size:
|
|
750
|
+
font-size: 12px;
|
|
246
751
|
padding-left: 29.77px;
|
|
247
752
|
color: #ffffff;
|
|
248
753
|
}
|
|
249
754
|
.cm-editor {
|
|
250
755
|
.cm-line {
|
|
756
|
+
// 给下面的 .cm-activeLine::before 当定位父级
|
|
757
|
+
position: relative;
|
|
251
758
|
font-size: 16px;
|
|
252
759
|
line-height: 25px;
|
|
253
760
|
font-family: 'Fira Code', 'Roboto Mono', Consolas, 'Microsoft Yahei';
|
|
254
761
|
}
|
|
762
|
+
/* 当前行高亮 vs 选中区,必须一眼能区分 —— 这里真正的坑不是配色而是**绘制层级**:
|
|
763
|
+
CM6 的选区画在 .cm-selectionLayer 上(drawSelection 内部 above:false,z-index 取 -1-pos,是负数),
|
|
764
|
+
它和 .cm-content 同在 .cm-scroller 这个层叠上下文里;而 .cm-line 是普通 in-flow 块,
|
|
765
|
+
按 CSS 绘制顺序「负 z-index 后代 → in-flow 块 → 行内内容」,**in-flow 块画在选区层之上**。
|
|
766
|
+
所以给 .cm-line 设不透明背景会把选区整块盖掉 —— baseTheme 自带的
|
|
767
|
+
&light .cm-activeLine: #cceeff44 / &dark: #99eeff33 是半透明的,才不会盖住,
|
|
768
|
+
之前改成了不透明色,选区就被糊掉了。
|
|
769
|
+
解法:把当前行高亮挪到 ::before 上并压到 z-index:-100,绘制顺序变成
|
|
770
|
+
当前行底(-100) → 选区层(-2) → 文字
|
|
771
|
+
选中区永远压在当前行高亮之上,同时仍然在文字之下。 */
|
|
772
|
+
.cm-line.cm-activeLine::before {
|
|
773
|
+
content: '';
|
|
774
|
+
position: absolute;
|
|
775
|
+
top: 0;
|
|
776
|
+
right: 0;
|
|
777
|
+
bottom: 0;
|
|
778
|
+
left: 0;
|
|
779
|
+
z-index: -100;
|
|
780
|
+
pointer-events: none;
|
|
781
|
+
background-color: #173541;
|
|
782
|
+
}
|
|
783
|
+
// 当前行高亮改由上面的 ::before 承载;这里必须把主题自带的**不透明**底色清掉,
|
|
784
|
+
// 否则它作为 in-flow 块的背景仍然会盖住选区层(solarizedDark 的 .cm-activeLine 就是 #173541,
|
|
785
|
+
// 和它的 selection 同色,这正是最初「选中区看不出来」的根源)。
|
|
786
|
+
.cm-activeLine {
|
|
787
|
+
background-color: transparent;
|
|
788
|
+
}
|
|
789
|
+
.cm-activeLineGutter {
|
|
790
|
+
background-color: #173541;
|
|
791
|
+
}
|
|
792
|
+
/* 选区色必须带 !important:CM 6.39 的 baseTheme 把默认色写成了
|
|
793
|
+
"&light.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground"
|
|
794
|
+
这种「5 个类 + 子代选择器」,权重高于本组件、也高于 solarizedDark 自带的主题,
|
|
795
|
+
光堆类名盖不住;用 !important 也不用跟它的 DOM 结构绑死。 */
|
|
796
|
+
.cm-selectionBackground {
|
|
797
|
+
/* 失焦:压暗一档 */
|
|
798
|
+
background-color: #204a5a !important;
|
|
799
|
+
}
|
|
800
|
+
&.cm-focused .cm-selectionBackground {
|
|
801
|
+
/* 双击选词、拖选都走这个色,和当前行的中性色明确区分 */
|
|
802
|
+
background-color: #2c6076 !important;
|
|
803
|
+
}
|
|
255
804
|
}
|
|
256
805
|
.codemirror-editor {
|
|
257
806
|
&::-webkit-scrollbar {
|
|
@@ -266,6 +815,312 @@ onMounted(() => {
|
|
|
266
815
|
}
|
|
267
816
|
}
|
|
268
817
|
}
|
|
818
|
+
.ai-prompt-panel {
|
|
819
|
+
position: absolute;
|
|
820
|
+
left: 8px;
|
|
821
|
+
right: 8px;
|
|
822
|
+
bottom: 34px;
|
|
823
|
+
z-index: 10;
|
|
824
|
+
padding: 6px 8px;
|
|
825
|
+
border: 1px solid #409eff;
|
|
826
|
+
border-radius: 4px;
|
|
827
|
+
background: rgba(15, 43, 54, 0.96);
|
|
828
|
+
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
|
|
829
|
+
.ai-panel-head {
|
|
830
|
+
display: flex;
|
|
831
|
+
align-items: center;
|
|
832
|
+
font-size: 12px;
|
|
833
|
+
color: #fff;
|
|
834
|
+
.ai-tag {
|
|
835
|
+
margin-right: 6px;
|
|
836
|
+
padding: 0 4px;
|
|
837
|
+
border-radius: 2px;
|
|
838
|
+
background: #409eff;
|
|
839
|
+
font-size: 11px;
|
|
840
|
+
line-height: 16px;
|
|
841
|
+
}
|
|
842
|
+
.ai-scope {
|
|
843
|
+
flex: 1;
|
|
844
|
+
opacity: 0.7;
|
|
845
|
+
}
|
|
846
|
+
.ai-history-entry {
|
|
847
|
+
margin-right: 8px;
|
|
848
|
+
padding: 0 4px;
|
|
849
|
+
border: 1px solid rgba(255, 255, 255, 0.35);
|
|
850
|
+
border-radius: 2px;
|
|
851
|
+
font-size: 11px;
|
|
852
|
+
line-height: 15px;
|
|
853
|
+
cursor: pointer;
|
|
854
|
+
opacity: 0.8;
|
|
855
|
+
&:hover,
|
|
856
|
+
&.is-active {
|
|
857
|
+
border-color: #409eff;
|
|
858
|
+
background: #409eff;
|
|
859
|
+
opacity: 1;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
.ai-close {
|
|
863
|
+
cursor: pointer;
|
|
864
|
+
font-size: 16px;
|
|
865
|
+
line-height: 16px;
|
|
866
|
+
opacity: 0.7;
|
|
867
|
+
&:hover {
|
|
868
|
+
opacity: 1;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
.ai-input {
|
|
873
|
+
width: 100%;
|
|
874
|
+
max-width: 100%;
|
|
875
|
+
box-sizing: border-box;
|
|
876
|
+
margin: 5px 0;
|
|
877
|
+
padding: 4px 6px;
|
|
878
|
+
border: 1px solid #2f5d6c;
|
|
879
|
+
border-radius: 3px;
|
|
880
|
+
background: #173541;
|
|
881
|
+
color: #fff;
|
|
882
|
+
font-size: 12px;
|
|
883
|
+
line-height: 18px;
|
|
884
|
+
resize: vertical;
|
|
885
|
+
outline: none;
|
|
886
|
+
overflow-wrap: break-word;
|
|
887
|
+
word-break: break-all;
|
|
888
|
+
&:focus {
|
|
889
|
+
border-color: #409eff;
|
|
890
|
+
}
|
|
891
|
+
&:disabled {
|
|
892
|
+
opacity: 0.6;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
.ai-panel-foot {
|
|
896
|
+
display: flex;
|
|
897
|
+
align-items: center;
|
|
898
|
+
justify-content: space-between;
|
|
899
|
+
font-size: 12px;
|
|
900
|
+
color: #fff;
|
|
901
|
+
.ai-tip {
|
|
902
|
+
opacity: 0.6;
|
|
903
|
+
}
|
|
904
|
+
.ai-btn {
|
|
905
|
+
padding: 1px 10px;
|
|
906
|
+
border-radius: 3px;
|
|
907
|
+
background: #409eff;
|
|
908
|
+
cursor: pointer;
|
|
909
|
+
&.danger {
|
|
910
|
+
background: #f56c6c;
|
|
911
|
+
}
|
|
912
|
+
&.is-disabled {
|
|
913
|
+
background: #6b7c85;
|
|
914
|
+
cursor: not-allowed;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
.ai-error {
|
|
919
|
+
margin-top: 4px;
|
|
920
|
+
font-size: 12px;
|
|
921
|
+
color: #f56c6c;
|
|
922
|
+
word-break: break-all;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
.ai-history-panel {
|
|
926
|
+
position: absolute;
|
|
927
|
+
left: 8px;
|
|
928
|
+
right: 8px;
|
|
929
|
+
bottom: 34px;
|
|
930
|
+
z-index: 11;
|
|
931
|
+
display: flex;
|
|
932
|
+
flex-direction: column;
|
|
933
|
+
max-height: 62%;
|
|
934
|
+
padding: 6px 8px;
|
|
935
|
+
border: 1px solid #409eff;
|
|
936
|
+
border-radius: 4px;
|
|
937
|
+
background: rgba(15, 43, 54, 0.98);
|
|
938
|
+
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
|
|
939
|
+
.ai-history-head {
|
|
940
|
+
display: flex;
|
|
941
|
+
align-items: center;
|
|
942
|
+
font-size: 12px;
|
|
943
|
+
color: #fff;
|
|
944
|
+
.ai-tag {
|
|
945
|
+
margin-right: 6px;
|
|
946
|
+
padding: 0 4px;
|
|
947
|
+
border-radius: 2px;
|
|
948
|
+
background: #409eff;
|
|
949
|
+
font-size: 11px;
|
|
950
|
+
line-height: 16px;
|
|
951
|
+
}
|
|
952
|
+
.ai-scope {
|
|
953
|
+
flex: 1;
|
|
954
|
+
opacity: 0.7;
|
|
955
|
+
}
|
|
956
|
+
.ai-btn.plain {
|
|
957
|
+
margin-right: 8px;
|
|
958
|
+
}
|
|
959
|
+
.ai-close {
|
|
960
|
+
cursor: pointer;
|
|
961
|
+
font-size: 16px;
|
|
962
|
+
line-height: 16px;
|
|
963
|
+
opacity: 0.7;
|
|
964
|
+
&:hover {
|
|
965
|
+
opacity: 1;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
.ai-btn.plain {
|
|
970
|
+
padding: 1px 6px;
|
|
971
|
+
border: 1px solid rgba(255, 255, 255, 0.35);
|
|
972
|
+
border-radius: 3px;
|
|
973
|
+
background: transparent;
|
|
974
|
+
font-size: 11px;
|
|
975
|
+
cursor: pointer;
|
|
976
|
+
&:hover {
|
|
977
|
+
border-color: #409eff;
|
|
978
|
+
color: #409eff;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
.ai-history-empty {
|
|
982
|
+
padding: 12px 0;
|
|
983
|
+
color: #fff;
|
|
984
|
+
font-size: 12px;
|
|
985
|
+
text-align: center;
|
|
986
|
+
opacity: 0.6;
|
|
987
|
+
}
|
|
988
|
+
.ai-history-list {
|
|
989
|
+
flex: 1;
|
|
990
|
+
margin-top: 4px;
|
|
991
|
+
overflow-y: auto;
|
|
992
|
+
}
|
|
993
|
+
.ai-history-item {
|
|
994
|
+
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
|
995
|
+
&:first-child {
|
|
996
|
+
border-top: none;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
.ai-history-q {
|
|
1000
|
+
display: flex;
|
|
1001
|
+
align-items: center;
|
|
1002
|
+
gap: 6px;
|
|
1003
|
+
padding: 4px 0;
|
|
1004
|
+
color: #fff;
|
|
1005
|
+
font-size: 12px;
|
|
1006
|
+
cursor: pointer;
|
|
1007
|
+
&:hover {
|
|
1008
|
+
background: rgba(64, 158, 255, 0.15);
|
|
1009
|
+
}
|
|
1010
|
+
.ai-history-time {
|
|
1011
|
+
flex: none;
|
|
1012
|
+
font-size: 11px;
|
|
1013
|
+
opacity: 0.55;
|
|
1014
|
+
}
|
|
1015
|
+
.ai-history-text {
|
|
1016
|
+
flex: 1;
|
|
1017
|
+
overflow: hidden;
|
|
1018
|
+
white-space: nowrap;
|
|
1019
|
+
text-overflow: ellipsis;
|
|
1020
|
+
}
|
|
1021
|
+
.ai-history-toggle {
|
|
1022
|
+
flex: none;
|
|
1023
|
+
font-size: 11px;
|
|
1024
|
+
color: #409eff;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
.ai-history-body {
|
|
1028
|
+
padding-bottom: 6px;
|
|
1029
|
+
}
|
|
1030
|
+
.ai-history-label {
|
|
1031
|
+
display: flex;
|
|
1032
|
+
align-items: center;
|
|
1033
|
+
margin: 2px 0;
|
|
1034
|
+
color: #409eff;
|
|
1035
|
+
font-size: 11px;
|
|
1036
|
+
.ai-history-scope {
|
|
1037
|
+
margin-left: 6px;
|
|
1038
|
+
color: #fff;
|
|
1039
|
+
opacity: 0.6;
|
|
1040
|
+
}
|
|
1041
|
+
.ai-btn.plain {
|
|
1042
|
+
margin-left: auto;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
.ai-history-pre {
|
|
1046
|
+
max-height: 170px;
|
|
1047
|
+
margin: 0 0 6px;
|
|
1048
|
+
padding: 4px 6px;
|
|
1049
|
+
overflow: auto;
|
|
1050
|
+
border-radius: 3px;
|
|
1051
|
+
background: #173541;
|
|
1052
|
+
color: #fff;
|
|
1053
|
+
font-family: 'Fira Code', 'Roboto Mono', Consolas, 'Microsoft Yahei';
|
|
1054
|
+
font-size: 12px;
|
|
1055
|
+
line-height: 18px;
|
|
1056
|
+
white-space: pre-wrap;
|
|
1057
|
+
word-break: break-all;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
/* 放大场景(window-form 的「放大」抽屉):编辑区撑满整个抽屉,AI 面板仍是 12px / 2 行会显得很局促,
|
|
1061
|
+
这里整体提一档字号、加两行输入高度、面板内边距与历史面板高度同步放大。
|
|
1062
|
+
选择器带 .is-ai-large 是为了权重高于上面 .is-light 里的同名规则,不依赖书写顺序。 */
|
|
1063
|
+
&.is-ai-large {
|
|
1064
|
+
.ai-prompt-panel,
|
|
1065
|
+
.ai-history-panel {
|
|
1066
|
+
left: 12px;
|
|
1067
|
+
right: 12px;
|
|
1068
|
+
bottom: 46px;
|
|
1069
|
+
padding: 10px 12px;
|
|
1070
|
+
border-radius: 6px;
|
|
1071
|
+
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.28);
|
|
1072
|
+
}
|
|
1073
|
+
.ai-prompt-panel {
|
|
1074
|
+
.ai-panel-head,
|
|
1075
|
+
.ai-panel-foot {
|
|
1076
|
+
font-size: 14px;
|
|
1077
|
+
}
|
|
1078
|
+
.ai-tag {
|
|
1079
|
+
font-size: 12px;
|
|
1080
|
+
line-height: 18px;
|
|
1081
|
+
}
|
|
1082
|
+
.ai-history-entry {
|
|
1083
|
+
font-size: 12px;
|
|
1084
|
+
line-height: 18px;
|
|
1085
|
+
}
|
|
1086
|
+
.ai-input {
|
|
1087
|
+
margin: 8px 0;
|
|
1088
|
+
padding: 6px 8px;
|
|
1089
|
+
font-size: 14px;
|
|
1090
|
+
line-height: 22px;
|
|
1091
|
+
}
|
|
1092
|
+
.ai-btn {
|
|
1093
|
+
padding: 3px 14px;
|
|
1094
|
+
font-size: 14px;
|
|
1095
|
+
}
|
|
1096
|
+
.ai-error {
|
|
1097
|
+
font-size: 14px;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
.ai-history-panel {
|
|
1101
|
+
max-height: 72%;
|
|
1102
|
+
.ai-history-head,
|
|
1103
|
+
.ai-history-q {
|
|
1104
|
+
font-size: 14px;
|
|
1105
|
+
}
|
|
1106
|
+
.ai-history-toggle,
|
|
1107
|
+
.ai-history-time {
|
|
1108
|
+
font-size: 12px;
|
|
1109
|
+
}
|
|
1110
|
+
.ai-history-label {
|
|
1111
|
+
font-size: 12px;
|
|
1112
|
+
.ai-btn.plain {
|
|
1113
|
+
font-size: 12px;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
.ai-history-pre {
|
|
1117
|
+
max-height: 300px;
|
|
1118
|
+
padding: 6px 8px;
|
|
1119
|
+
font-size: 13px;
|
|
1120
|
+
line-height: 21px;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
269
1124
|
.editor-operation-btn {
|
|
270
1125
|
position: absolute;
|
|
271
1126
|
bottom: 1px;
|
|
@@ -284,10 +1139,26 @@ onMounted(() => {
|
|
|
284
1139
|
cursor: pointer;
|
|
285
1140
|
line-height: 24px;
|
|
286
1141
|
}
|
|
1142
|
+
.ai-entry {
|
|
1143
|
+
margin: 0 5px;
|
|
1144
|
+
padding: 0 4px;
|
|
1145
|
+
border-radius: 2px;
|
|
1146
|
+
background: rgba(255, 255, 255, 0.22);
|
|
1147
|
+
color: #fff;
|
|
1148
|
+
font-size: 12px;
|
|
1149
|
+
font-weight: bolder;
|
|
1150
|
+
line-height: 18px;
|
|
1151
|
+
cursor: pointer;
|
|
1152
|
+
}
|
|
287
1153
|
&:hover {
|
|
288
1154
|
opacity: 1;
|
|
289
1155
|
background: #409eff;
|
|
290
1156
|
}
|
|
1157
|
+
/* 未聚焦:淡出并停止接收事件(仅用 opacity:0 会留下一个看不见但能点到的区域) */
|
|
1158
|
+
&.is-hidden {
|
|
1159
|
+
opacity: 0;
|
|
1160
|
+
pointer-events: none;
|
|
1161
|
+
}
|
|
291
1162
|
}
|
|
292
1163
|
}
|
|
293
1164
|
</style>
|
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# yh-codemirror
|
|
2
|
+
|
|
3
|
+
Vue3 的 CodeMirror6 封装,在全屏 / 美化 / 全选 / 复制 / 清空 等基础能力之上,提供 **AI 提示编辑**(选中或整篇代码 → 自然语言描述 → AI 改写回写)。
|
|
4
|
+
|
|
5
|
+
## 基础用法
|
|
6
|
+
|
|
7
|
+
```vue
|
|
8
|
+
<yh-codemirror
|
|
9
|
+
v-model="code"
|
|
10
|
+
:height="500"
|
|
11
|
+
language="javascript"
|
|
12
|
+
:autocomplete="{javascript: myCompletionSource}">
|
|
13
|
+
</yh-codemirror>
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## AI 提示编辑
|
|
17
|
+
|
|
18
|
+
组件本身**不内置任何 AI 服务**(保持零业务依赖),由宿主通过 `aiRequest` 注入请求实现。传入 `aiRequest` 即自动启用:
|
|
19
|
+
|
|
20
|
+
- 编辑器右下角出现 `AI` 入口按钮
|
|
21
|
+
- 快捷键 `Ctrl+I`(Mac `Cmd+I`,可通过 `aiHotkey` 改)唤起输入框
|
|
22
|
+
- 有选区 → 只改写选中代码;无选区 → 按 `aiScope` 作用于整个文档或光标处插入
|
|
23
|
+
- `Enter` 发送、`Esc` 取消;请求中按钮变「停止」,可中断
|
|
24
|
+
- 支持流式:调用 `onChunk` 逐段返回,编辑器实时写入;非流式则直接返回完整替换文本
|
|
25
|
+
- 自动剥离模型常见的 ` ```lang ... ``` ` 包裹
|
|
26
|
+
- 通过 dispatch 写入,保留 CodeMirror 撤销栈,可 `Ctrl+Z` 回退
|
|
27
|
+
- **历史问答**:输入框右上角「历史」可查看本轮会话问过的问题与 AI 的完整回答(可展开、可复制、可清空)
|
|
28
|
+
|
|
29
|
+
历史记录存在 `ai-history.js` 的模块级单例里(上限 50 条,最新在前)。之所以不放组件内:
|
|
30
|
+
配置面板里的编辑器会随切换配置项反复卸载重建,组件内的 ref 一卸载就没了。
|
|
31
|
+
代价是同一页面所有编辑器共用一份(未做分组),所以每条都带时间与问题原文;需要按字段分组时再给 `pushAiHistory` 加 key 维度。
|
|
32
|
+
|
|
33
|
+
### Props
|
|
34
|
+
|
|
35
|
+
| 参数 | 类型 | 默认值 | 说明 |
|
|
36
|
+
| --------------- | --------------------------------- | ---------- | ------------------------------------------------------ |
|
|
37
|
+
| `aiRequest` | `AiCodeEditRequest` | - | AI 请求函数,传入即启用 AI 编辑 |
|
|
38
|
+
| `aiHotkey` | `string` | `'Mod-i'` | 唤起输入框的快捷键(CodeMirror 键位语法) |
|
|
39
|
+
| `aiPlaceholder` | `string` | 见源码 | 输入框占位提示 |
|
|
40
|
+
| `aiScope` | `'document' \| 'cursor'` | `'document'` | 无选区时的作用范围 |
|
|
41
|
+
| `aiPanelSize` | `'normal' \| 'large'` | `'normal'` | AI 输入框 / 历史面板的尺寸档位。编辑区被放大时(如 window-form 的「放大」抽屉)传 `large` |
|
|
42
|
+
|
|
43
|
+
### Events
|
|
44
|
+
|
|
45
|
+
| 事件 | 参数 | 说明 |
|
|
46
|
+
| ---------------- | ----------------------------- | ------------------------ |
|
|
47
|
+
| `update:modelValue` | `string` | 代码变更(原有) |
|
|
48
|
+
| `ai-apply` | `{prompt, text}` | AI 结果已写入编辑器 |
|
|
49
|
+
| `ai-error` | `Error` | AI 请求失败 |
|
|
50
|
+
|
|
51
|
+
### Expose
|
|
52
|
+
|
|
53
|
+
`openAi(prompt?)`、`closeAi()`、`stopAi()`、`getView()`。
|
|
54
|
+
|
|
55
|
+
### 接入示例(非流式)
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import {YhCodemirror} from 'yh-codemirror';
|
|
59
|
+
import type {AiCodeEditRequest} from 'yh-codemirror';
|
|
60
|
+
|
|
61
|
+
const aiRequest: AiCodeEditRequest = async ({prompt, code, selectedCode, language, signal}) => {
|
|
62
|
+
const res = await fetch('/api/ai/code-edit', {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
signal,
|
|
65
|
+
headers: {'Content-Type': 'application/json'},
|
|
66
|
+
body: JSON.stringify({prompt, code, selectedCode, language}),
|
|
67
|
+
});
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
throw new Error('AI 请求失败');
|
|
70
|
+
}
|
|
71
|
+
const data = await res.json();
|
|
72
|
+
// 返回「作用范围的完整替换文本」,组件负责写入
|
|
73
|
+
return data.code;
|
|
74
|
+
};
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### 接入示例(流式 SSE)
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
const aiRequest: AiCodeEditRequest = async ({prompt, selectedCode, signal, onChunk}) => {
|
|
81
|
+
const res = await fetch('/api/ai/code-edit/stream', {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
signal,
|
|
84
|
+
headers: {'Content-Type': 'application/json'},
|
|
85
|
+
body: JSON.stringify({prompt, code: selectedCode}),
|
|
86
|
+
});
|
|
87
|
+
const reader = res.body!.getReader();
|
|
88
|
+
const decoder = new TextDecoder();
|
|
89
|
+
while (true) {
|
|
90
|
+
const {done, value} = await reader.read();
|
|
91
|
+
if (done) {
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
onChunk?.(decoder.decode(value, {stream: true}));
|
|
95
|
+
}
|
|
96
|
+
// 走 onChunk 时返回值可为空,组件已实时写入编辑器
|
|
97
|
+
};
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
> 提示:流式场景下 `onChunk` 传入的是增量片段,组件按累积文本整体覆盖作用区间,因此服务端无需自行做 diff。
|
package/ai-history.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI 问答历史(模块级单例)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么放模块级而不是组件内:window-form 这类配置面板里,代码编辑器会随着切换配置项/关闭抽屉
|
|
5
|
+
* 反复卸载重建,组件内的 ref 一卸载就没了,历史根本留不住。放在模块级只要页面不刷新就一直在。
|
|
6
|
+
*
|
|
7
|
+
* 代价是同一页面里所有编辑器共用一份历史(未做分组),所以每条都带上时间与问题原文,
|
|
8
|
+
* 靠问题内容本身区分是哪个字段的提问。需要按字段分组时再给 pushAiHistory 加 key 维度。
|
|
9
|
+
*/
|
|
10
|
+
import {ref} from 'vue';
|
|
11
|
+
|
|
12
|
+
/** 最多保留多少条(超出丢最旧的) */
|
|
13
|
+
const MAX_HISTORY = 50;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @type {import('vue').Ref<Array<{id:number, time:string, prompt:string, scope:string, answer:string}>>}
|
|
17
|
+
* 最新的排在最前面
|
|
18
|
+
*/
|
|
19
|
+
export const aiHistory = ref([]);
|
|
20
|
+
|
|
21
|
+
let seed = 0;
|
|
22
|
+
|
|
23
|
+
const nowText = () => {
|
|
24
|
+
const d = new Date();
|
|
25
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
26
|
+
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(
|
|
27
|
+
d.getSeconds()
|
|
28
|
+
)}`;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** 追加一条:{prompt, scope, answer} */
|
|
32
|
+
export const pushAiHistory = ({prompt, scope = '', answer = ''}) => {
|
|
33
|
+
if (!prompt && !answer) return;
|
|
34
|
+
aiHistory.value = [{id: ++seed, time: nowText(), prompt, scope, answer}, ...aiHistory.value].slice(
|
|
35
|
+
0,
|
|
36
|
+
MAX_HISTORY
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const clearAiHistory = () => {
|
|
41
|
+
aiHistory.value = [];
|
|
42
|
+
};
|
package/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yh-codemirror",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "wraper codemirror with vue3",
|
|
5
|
-
"
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./index.ts",
|
|
9
|
+
"types": "./index.ts"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
6
12
|
"scripts": {
|
|
7
|
-
"pub": "npm publish --registry https://registry.npmjs.org/
|
|
13
|
+
"pub": "npm publish --registry https://registry.npmjs.org/ && npm publish --registry https://packages.aliyun.com/60765e0161a945067837bb5f/npm/npm-registry/"
|
|
8
14
|
},
|
|
9
15
|
"dependencies": {
|
|
10
16
|
"@codemirror/autocomplete": "^6.16.3",
|
|
@@ -15,13 +21,15 @@
|
|
|
15
21
|
"@codemirror/lang-html": "^6.4.9",
|
|
16
22
|
"@codemirror/language": "^6.10.2",
|
|
17
23
|
"@codemirror/view": "^6.28.2",
|
|
24
|
+
"@lezer/highlight": "^1.2.0",
|
|
25
|
+
"cm6-theme-solarized-dark": "^0.1.0",
|
|
18
26
|
"codemirror": "^6.0.1",
|
|
19
27
|
"js-beautify": "^1.15.1"
|
|
20
28
|
},
|
|
21
29
|
"peerDependencies": {
|
|
22
|
-
"vue-i18n": "9.8.0",
|
|
23
|
-
"vue": "3.
|
|
24
|
-
"element-plus": "2.
|
|
30
|
+
"vue-i18n": "^9.8.0",
|
|
31
|
+
"vue": "^3.5.0",
|
|
32
|
+
"element-plus": "^2.9.0"
|
|
25
33
|
},
|
|
26
34
|
"author": "Liubin"
|
|
27
35
|
}
|
package/util.ts
CHANGED
|
@@ -12,6 +12,32 @@ const plugins = {
|
|
|
12
12
|
html: () => html(),
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* AI 提示编辑:宿主注入的请求参数
|
|
17
|
+
*/
|
|
18
|
+
export interface AiCodeEditParams {
|
|
19
|
+
/** 用户输入的提示词 */
|
|
20
|
+
prompt: string;
|
|
21
|
+
/** 编辑器全文代码 */
|
|
22
|
+
code: string;
|
|
23
|
+
/** 本次作用范围的代码(有选区=选中内容;无选区=全文或空串) */
|
|
24
|
+
selectedCode: string;
|
|
25
|
+
/** 当前编辑器语言 */
|
|
26
|
+
language?: string | string[];
|
|
27
|
+
/** 用于中断请求(用户点“停止”/关闭面板时触发) */
|
|
28
|
+
signal: AbortSignal;
|
|
29
|
+
/** 流式输出回调:逐段返回内容,组件会实时写入编辑器 */
|
|
30
|
+
onChunk?: (chunk: string) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* AI 提示编辑:宿主注入的请求函数。
|
|
35
|
+
* 返回值需为「作用范围的完整替换文本」;若走流式(onChunk 逐段返回),返回值可为空。
|
|
36
|
+
*/
|
|
37
|
+
export type AiCodeEditRequest = (
|
|
38
|
+
params: AiCodeEditParams
|
|
39
|
+
) => Promise<string | void | {text?: string; code?: string; content?: string}>;
|
|
40
|
+
|
|
15
41
|
export const getCodeLanguagePlugin = function (language) {
|
|
16
42
|
return plugins[language]();
|
|
17
43
|
};
|