-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Expand file tree
/
Copy pathGeneratePromptDialog.vue
More file actions
562 lines (500 loc) · 16.8 KB
/
GeneratePromptDialog.vue
File metadata and controls
562 lines (500 loc) · 16.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
<template>
<el-dialog
align-center
:title="$t('views.application.generateDialog.generatePrompt')"
v-model="dialogVisible"
style="width: 600px"
append-to-body
:close-on-click-modal="true"
:close-on-press-escape="true"
:before-close="handleDialogClose"
>
<div class="generate-prompt-dialog-bg border-r-8">
<div class="scrollbar-height">
<!-- 生成内容 -->
<div class="p-16 pb-0 lighter">
<el-scrollbar ref="scrollDiv">
<div
ref="dialogScrollbar"
v-if="answer"
class="pre-wrap lighter"
style="max-height: calc(100vh - 400px)"
>
{{ answer }}
</div>
<p v-else-if="loading" shadow="always" style="margin: 0.5rem 0">
<el-icon class="is-loading color-primary mr-4"><Loading /></el-icon>
{{ $t('views.application.generateDialog.loading') }}
<span class="dotting"></span>
</p>
<p v-else class="flex align-center">
<AppIcon iconName="app-generate-star" class="color-primary mr-4"></AppIcon>
{{ $t('views.application.generateDialog.title') }}
</p>
</el-scrollbar>
<div v-if="answer && !loading && !isStreaming && !showContinueButton">
<el-button type="primary" @click="() => emit('replace', answer)">
{{ $t('views.application.generateDialog.replace') }}
</el-button>
<el-button @click="reAnswerClick" :disabled="!answer || loading" :loading="loading">
{{ $t('views.application.generateDialog.remake') }}
</el-button>
</div>
<div class="mt-8" v-else>
<el-button type="primary" v-if="showContinueButton" @click="continueStreaming" link>
{{ $t('views.application.generateDialog.continue') }}
</el-button>
</div>
</div>
<!-- 文本输入框 -->
<div class="generate-prompt-operate p-16">
<div v-if="showStopButton" class="text-center mb-8">
<el-button class="border-primary video-stop-button" @click="pauseStreaming">
<app-icon iconName="app-video-stop" class="mr-8"></app-icon>
{{ $t('views.application.generateDialog.stop') }}
</el-button>
</div>
<div class="operate-textarea">
<el-input
ref="quickInputRef"
v-model="inputValue"
:autosize="{ minRows: 1, maxRows: 10 }"
type="textarea"
:placeholder="$t('views.application.generateDialog.placeholder')"
:maxlength="100000"
class="chat-operate-textarea"
@keydown.enter="handleSubmit($event)"
/>
<div class="operate">
<div class="text-right">
<el-button
text
class="sent-button"
:disabled="!inputValue.trim() || loading || isStreaming"
@click="handleSubmit"
>
<img
v-show="!inputValue.trim() || loading || isStreaming"
src="@/assets/icon_send.svg"
alt=""
/>
<SendIcon v-show="inputValue.trim() && !loading && !isStreaming" />
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, onUnmounted, reactive, ref, nextTick, watch } from 'vue'
import { useRoute } from 'vue-router'
import { MsgConfirm } from '@/utils/message'
import { t } from '@/locales'
import systemGeneratePromptAPI from '@/api/system-resource-management/application'
import generatePromptAPI from '@/api/application/application'
import useStore from '@/stores'
const emit = defineEmits(['replace'])
const { user } = useStore()
const route = useRoute()
const chatMessages = ref<Array<any>>([])
const apiType = computed(() => {
if (route.path.includes('resource-management')) {
return 'systemManage'
} else {
return 'workspace'
}
})
// 原始输入
const originalUserInput = ref<string>('')
const modelID = ref('')
const applicationID = ref('')
const dialogVisible = ref(false)
const inputValue = ref<string>('')
const loading = ref<boolean>(false)
const promptTemplates = {
INIT_TEMPLATE: `
请根据用户描述生成一个完整的AI角色人设模板:
用户需求:{userInput}
请按以下格式生成:
必须严格遵循以下规则:
1. **严格禁止输出解释、前言、额外说明**,只输出最终结果。
2. **严格使用以下格式**,不能缺少标题、不能多出其他段落。
3. **如果用户需求不明确,就忽略用户需求**。
# 角色:
角色概述和主要职责的一句话描述
## 目标:
角色的工作目标,如果有多目标可以分点列出,但建议更聚焦1-2个目标
## 核心技能:
### 技能 1: [技能名称,如作品推荐/信息查询/专业分析等]
1. [执行步骤1 - 描述该技能的第一个具体操作步骤,包括条件判断和处理方式]
2. [执行步骤2 - 描述该技能的第二个具体操作步骤,包括如何获取或处理信息]
3. [执行步骤3 - 描述该技能的最终输出步骤,说明如何呈现结果]
===回复示例===
- 📋 [标识符]: <具体内容格式说明>
- 🎯 [标识符]: <具体内容格式说明>
- 💡 [标识符]: <具体内容格式说明>
===示例结束===
### 技能 2: [技能名称]
1. [执行步骤1 - 描述触发条件和初始处理方式]
2. [执行步骤2 - 描述信息获取和深化处理的具体方法]
3. [执行步骤3 - 描述最终输出的具体要求和格式]
### 技能 3: [技能名称]
- [核心能力描述 - 说明该技能的主要作用和知识基础]
- [应用方法 - 描述如何运用该技能为用户提供服务,包括具体的实施方式]
## 工作流:
1. 描述角色工作流程的第一步
2. 描述角色工作流程的第二步
3. 描述角色工作流程的第三步
## 输出格式:
如果对角色的输出格式有特定要求,可以在这里强调并举例说明想要的输出格式
## 限制:
1. **严格限制回答范围**:仅回答与角色设定相关的问题。
- 如果用户提问与角色无关,必须使用以下固定格式回复:
“对不起,我只能回答与【角色设定】相关的问题,您的问题不在服务范围内。”
- 不得提供任何与角色设定无关的回答。
2. 描述角色在互动过程中需要遵循的限制条件2
3. 描述角色在互动过程中需要遵循的限制条件3
输出时不得包含任何解释或附加说明,只能返回符合以上格式的内容。
`,
}
const isStreaming = ref<boolean>(false) // 是否正在流式输出
const isPaused = ref<boolean>(false) // 是否暂停
const fullContent = ref<string>('') // 完整内容缓存
const currentDisplayIndex = ref<number>(0) // 当前显示到的字符位置
let streamTimer: number | null = null // 定时器引用
const isOutputComplete = ref<boolean>(false)
// 模拟流式输出的定时器函数
const startStreamingOutput = () => {
if (streamTimer) {
clearInterval(streamTimer)
}
isStreaming.value = true
isPaused.value = false
streamTimer = setInterval(() => {
if (isApiComplete.value && !isPaused.value) {
// 更新显示内容
const currentAnswer = chatMessages.value[chatMessages.value.length - 1]
if (currentAnswer && currentAnswer.role === 'ai') {
currentAnswer.content = fullContent .value
}
stopStreaming()
return
}
if (!isPaused.value && currentDisplayIndex.value < fullContent.value.length) {
// 每次输出1-3个字符,模拟真实的流式输出
const step = Math.min(3, fullContent.value.length - currentDisplayIndex.value)
currentDisplayIndex.value += step
// 更新显示内容
const currentAnswer = chatMessages.value[chatMessages.value.length - 1]
if (currentAnswer && currentAnswer.role === 'ai') {
currentAnswer.content = fullContent.value.substring(0, currentDisplayIndex.value)
}
} else if (loading.value === false && currentDisplayIndex.value >= fullContent.value.length) {
stopStreaming()
}
}, 50) as any
}
// 停止流式输出
const stopStreaming = () => {
if (streamTimer) {
clearInterval(streamTimer)
streamTimer = null
}
isStreaming.value = false
isPaused.value = false
loading.value = false
isOutputComplete.value = true
}
const showStopButton = computed(() => {
return isStreaming.value
})
// 暂停流式输出
const pauseStreaming = () => {
isPaused.value = true
isStreaming.value = false
}
// 继续流式输出
const continueStreaming = () => {
if (currentDisplayIndex.value < fullContent.value.length) {
startStreamingOutput()
}
}
/**
* 获取一个递归函数,处理流式数据
* @param chat 每一条对话记录
* @param reader 流数据
* @param stream 是否是流式数据
*/
const getWrite = (reader: any) => {
let tempResult = ''
const middleAnswer = reactive({ content: '', role: 'ai' })
chatMessages.value.push(middleAnswer)
// 初始化状态并
fullContent.value = ''
currentDisplayIndex.value = 0
isOutputComplete.value = false
let streamingStarted = false
/**
*
* @param done 是否结束
* @param value 值
*/
const write_stream = ({ done, value }: { done: boolean; value: any }) => {
try {
if (done) {
// 流数据接收完成,但定时器继续运行直到显示完所有内容
loading.value = false
isApiComplete.value = true
return
}
const decoder = new TextDecoder('utf-8')
let str = decoder.decode(value, { stream: true })
// 这里解释一下 start 因为数据流返回流并不是按照后端chunk返回 我们希望得到的chunk是data:{xxx}\n\n 但是它获取到的可能是 data:{ -> xxx}\n\n 总而言之就是 fetch不能保证每个chunk都说以data:开始 \n\n结束
tempResult += str
const split = tempResult.match(/data:.*}\n\n/g)
if (split) {
str = split.join('')
tempResult = tempResult.replace(str, '')
} else {
return reader.read().then(write_stream)
}
// 这里解释一下 end
if (str && str.startsWith('data:')) {
if (split) {
for (const index in split) {
const chunk = JSON?.parse(split[index].replace('data:', ''))
if (!chunk.is_end) {
// 实时将新接收的内容添加到完整内容中
fullContent.value += chunk.content
if (!streamingStarted) {
streamingStarted = true
startStreamingOutput()
}
}
if (chunk.is_end) {
return Promise.resolve()
}
}
}
}
} catch (e) {
loading.value = false
stopStreaming()
return Promise.reject(e)
}
return reader.read().then(write_stream)
}
return write_stream
}
const isApiComplete = ref<boolean>(false)
const answer = computed(() => {
const result = chatMessages.value[chatMessages.value.length - 1]
if (result && result.role == 'ai') {
return result.content
}
return ''
})
// 按钮状态计算
const showContinueButton = computed(() => {
return (
!isStreaming.value && isPaused.value && currentDisplayIndex.value < fullContent.value.length
)
})
function generatePrompt(inputValue: any) {
isApiComplete.value=false
loading.value = true
const workspaceId = user.getWorkspaceId() || 'default'
chatMessages.value.push({ content: inputValue, role: 'user' })
const requestData = {
messages: chatMessages.value,
prompt: promptTemplates.INIT_TEMPLATE,
}
if (apiType.value === 'workspace') {
generatePromptAPI
.generate_prompt(workspaceId, modelID.value, applicationID.value, requestData)
.then((response) => {
nextTick(() => {
if (dialogScrollbar.value) {
// 将滚动条滚动到最下面
scrollDiv.value.setScrollTop(getMaxHeight())
}
})
const reader = response.body.getReader()
reader.read().then(getWrite(reader))
})
} else if (apiType.value === 'systemManage') {
systemGeneratePromptAPI
.generate_prompt(applicationID.value, modelID.value, requestData)
.then((response) => {
nextTick(() => {
if (dialogScrollbar.value) {
// 将滚动条滚动到最下面
scrollDiv.value.setScrollTop(getMaxHeight())
}
})
const reader = response.body.getReader()
reader.read().then(getWrite(reader))
})
}
}
// 重新生成点击
const reAnswerClick = () => {
if (originalUserInput.value) {
generatePrompt('结果不满意,请按照格式,重新生成')
}
}
const quickInputRef = ref()
const handleSubmit = (event?: any) => {
if (!event?.ctrlKey && !event?.shiftKey && !event?.altKey && !event?.metaKey) {
// 如果没有按下组合键,则会阻止默认事件
event?.preventDefault()
if (!originalUserInput.value) {
originalUserInput.value = inputValue.value
}
if (isPaused.value || isStreaming.value) {
return
}
if (inputValue.value) {
generatePrompt(inputValue.value)
inputValue.value = ''
}
} else {
// 如果同时按下ctrl/shift/cmd/opt +enter,则会换行
insertNewlineAtCursor(event)
}
}
const insertNewlineAtCursor = (event?: any) => {
const textarea = quickInputRef.value.$el.querySelector(
'.el-textarea__inner',
) as HTMLTextAreaElement
const startPos = textarea.selectionStart
const endPos = textarea.selectionEnd
// 阻止默认行为(避免额外的换行符)
event.preventDefault()
// 在光标处插入换行符
inputValue.value = inputValue.value.slice(0, startPos) + '\n' + inputValue.value.slice(endPos)
nextTick(() => {
textarea.setSelectionRange(startPos + 1, startPos + 1) // 光标定位到换行后位置
})
}
const open = (modelId: string, applicationId: string) => {
modelID.value = modelId
applicationID.value = applicationId
dialogVisible.value = true
originalUserInput.value = ''
chatMessages.value = []
}
const scrollDiv = ref()
const dialogScrollbar = ref()
const getMaxHeight = () => {
return dialogScrollbar.value!.scrollHeight
}
/**
* 处理跟随滚动条
*/
const handleScroll = () => {
if (scrollDiv.value) {
// 内部高度小于外部高度 就需要出滚动条
if (scrollDiv.value.wrapRef.offsetHeight < dialogScrollbar.value?.scrollHeight) {
// 如果当前滚动条距离最下面的距离在 规定距离 滚动条就跟随
scrollDiv.value.setScrollTop(getMaxHeight())
}
}
}
const handleDialogClose = (done: () => void) => {
if (answer.value) {
// 弹出 消息
MsgConfirm(t('common.tip'), t('views.application.generateDialog.exit'), {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
distinguishCancelAndClose: true,
})
.then(() => {
// 点击确认,清除状态
stopStreaming()
chatMessages.value = []
fullContent.value = ''
currentDisplayIndex.value = 0
isOutputComplete.value = false
done() // 真正关闭
})
.catch(() => {
// 点击取消
})
}
}
// 组件卸载时清理定时器
onUnmounted(() => {
stopStreaming()
})
watch(
answer,
() => {
handleScroll()
},
{ deep: true, immediate: true },
)
defineExpose({
open,
})
</script>
<style lang="scss" scoped>
.generate-prompt-dialog-bg {
background: var(--dialog-bg-gradient-color);
overflow: hidden;
box-sizing: border-box;
}
.generate-prompt-operate {
position: relative;
width: 100%;
box-sizing: border-box;
z-index: 10;
:deep(.operate-textarea) {
box-shadow: 0px 6px 24px 0px rgba(31, 35, 41, 0.08);
background-color: #ffffff;
border-radius: var(--app-border-radius-large);
border: 1px solid #ffffff;
box-sizing: border-box;
&:has(.el-textarea__inner:focus) {
border: 1px solid var(--el-color-primary);
}
.el-textarea__inner {
border-radius: var(--app-border-radius-large) !important;
box-shadow: none;
resize: none;
padding: 13px 16px;
box-sizing: border-box;
min-height: 47px !important;
height: 0;
}
.operate {
padding: 6px 10px;
.el-icon {
font-size: 20px;
}
.sent-button {
max-height: none;
.el-icon {
font-size: 24px;
}
}
.el-loading-spinner {
margin-top: -15px;
.circular {
width: 31px;
height: 31px;
}
}
}
}
.video-stop-button {
box-shadow: 0px 6px 24px 0px rgba(31, 35, 41, 0.08);
&:hover {
background: #ffffff;
}
}
}
</style>