feat: 新增聊天组件和打字机效果实现

This commit is contained in:
huangzhe
2025-08-04 11:32:15 +08:00
parent 218609d50b
commit e390edf77e
6 changed files with 260 additions and 57 deletions

View File

@@ -15,7 +15,7 @@ export default {
const content = JSON.parse(markup) const content = JSON.parse(markup)
return content return content
} catch (e) { } catch (e) {
console.log(e) // console.log(e)
return {} return {}
} }
} }

View File

@@ -47,6 +47,7 @@
<script> <script>
import SvgIcon from '@/components/svg-icon/index.vue' import SvgIcon from '@/components/svg-icon/index.vue'
import { audioToText, gwcsChat } from '@/api/generatedApi' import { audioToText, gwcsChat } from '@/api/generatedApi'
import { md } from './js/markdown-it'
export default { export default {
components: { components: {
@@ -95,7 +96,6 @@ export default {
requestIndex: 1, requestIndex: 1,
requestSingle: undefined, requestSingle: undefined,
// 管控单次请求,当 abort 之后, while 后面的会进行重复请求 // 管控单次请求,当 abort 之后, while 后面的会进行重复请求
single: false,
isThink: false, isThink: false,
newMessage: '', newMessage: '',
isRecording: false, isRecording: false,
@@ -208,14 +208,14 @@ export default {
axiosGetAiChat() { axiosGetAiChat() {
const abortController = new AbortController() const abortController = new AbortController()
this.requestSingle = abortController this.requestSingle = abortController
this.messageInfo.information = this.single ? this.messageInfo.information : this.newMessage this.messageInfo.information = this.genMessage.information || this.newMessage
// 重置 answerMap // 重置 answerMap
this.answerMap = '' this.answerMap = ''
// this.typingQueue = [] // this.typingQueue = []
this.currentMessage = JSON.parse(JSON.stringify(this.messageInfo)) this.currentMessage = JSON.parse(JSON.stringify(this.messageInfo))
let params = { const params = {
appType: "gwcsHelper", appType: "gwcsHelper2",
conversationId: this.conversationId, conversationId: this.conversationId,
message: JSON.stringify(this.messageInfo), message: JSON.stringify(this.messageInfo),
user: "gwcs-test", user: "gwcs-test",
@@ -231,8 +231,7 @@ export default {
isLike: false, isLike: false,
isDisLike: false, isDisLike: false,
} }
if (this.single) { if (this.genMessage) {
// this.$set(this.messages, this.messages.length - 1, { ...this.currentMessage })
this.$set(this.messages, this.messages.length - 1, this.genMessage = { ...this.currentMessage }) this.$set(this.messages, this.messages.length - 1, this.genMessage = { ...this.currentMessage })
} else { } else {
this.messages.push(this.currentMessage) this.messages.push(this.currentMessage)
@@ -253,7 +252,6 @@ export default {
}) })
.then(async (res) => { .then(async (res) => {
await this.processStreamResponse(res, this.requestIndex) await this.processStreamResponse(res, this.requestIndex)
this.single = false
}) })
.catch((err) => { .catch((err) => {
// debugger // debugger
@@ -270,15 +268,15 @@ export default {
let buffer = '' let buffer = ''
while (true) { while (true) {
// try { // try {
const { done, value } = await reader.read() const { done, value } = await reader.read()
if (done) break if (done) break
buffer += new TextDecoder().decode(value) buffer += new TextDecoder().decode(value)
const lines = buffer.split('\n') const lines = buffer.split('\n')
lines.slice(0, -1).forEach((line) => { lines.slice(0, -1).forEach((line) => {
const parsed = this.parseStreamLine(line) const parsed = this.parseStreamLine(line)
if (parsed) this.updateMessageContent(parsed, requestIndex) if (parsed) this.updateMessageContent(parsed, requestIndex)
}) })
buffer = lines[lines.length - 1] || '' buffer = lines[lines.length - 1] || ''
// } catch (error) { // } catch (error) {
// console.error('读取流数据时发生错误:', error) // console.error('读取流数据时发生错误:', error)
// break // break
@@ -290,25 +288,45 @@ export default {
const cleanLine = line.replace(/^data:\s*/, '') const cleanLine = line.replace(/^data:\s*/, '')
if (!cleanLine) return null if (!cleanLine) return null
const data = JSON.parse(cleanLine) const data = JSON.parse(cleanLine)
// debugger/
// this.conversationId = data.conversation_id
this.$emit('update:conversationId', data.conversation_id) this.$emit('update:conversationId', data.conversation_id)
if (data.answer) { if (data.answer) {
this.answerMap += data.answer this.answerMap += data.answer
const is_complete = /<is_complete>([^<]*)(?:<\/is_complete>)?/.exec(this.answerMap) let completeInformation = {}
const information = /<information>([^<]*)(?:<\/information>)?/.exec(this.answerMap) let textContent = ''
const text = /<text>([^<]*)(?:<\/text>)?/.exec(this.answerMap)
// console.log(this.answerMap);
this.messageInfo.information = information ? information[1].trim() : this.newMessage try {
this.messageInfo.is_complete = is_complete ? is_complete[1].trim() : 'false' // 解析 complete_information 容器
if (is_complete && is_complete[1] === 'true' && text && text[1].trim() === '') { const json = this.answerMap.match(/:::\s*complete_information\s*([\s\S]*?)\s*:::/)
this.requestSingle.abort() if (json) {
this.single = true completeInformation = JSON.parse(json[1])
}
// 解析 text 容器
const text = this.answerMap.match(/:::\s*text\s*([\s\S]*?)\s*:::/)
if (text) {
textContent = text[1].trim()
}
console.log(`completeInformation: ${JSON.stringify(completeInformation)}, textContent: ${textContent}`);
} catch (e) {
console.log(e);
}
if (!completeInformation && !textContent) {
return null
}
const { information, is_complete } = completeInformation
this.messageInfo.information = information ? information : this.newMessage
this.messageInfo.is_complete = is_complete && is_complete.toString()
if (is_complete) {
this.axiosGetAiChat() this.axiosGetAiChat()
this.requestIndex++ this.requestIndex++
// this.typingQueue = []
return null return null
} }
} }
@@ -368,13 +386,6 @@ export default {
// 取出一个完整文本块 // 取出一个完整文本块
const chunk = this.typingQueue.shift() const chunk = this.typingQueue.shift()
// if (chunk.message_id !== this.currentMessageID) {
// console.log('message_id !== this.currentMessageID');
// typeNextChar()
// return
// }
// console.log(this.messages);
const chars = Array.from(chunk.answer) const chars = Array.from(chunk.answer)
const isThink = chunk.isThink const isThink = chunk.isThink
@@ -386,14 +397,9 @@ export default {
return return
} }
const char = chars.shift() || '' const char = chars.shift() || ''
// this.$set(this.currentMessage, isThink ? 'think' : 'text', this.currentMessage[isThink ? 'think' : 'text'] + char)
if (requestIndex === 2) { if (requestIndex === 2) {
// console.log('requestIndex === 2', char);
this.$set(this.genMessage, 'text', this.genMessage.text + char) this.$set(this.genMessage, 'text', this.genMessage.text + char)
// this.messages.splice(this.messages.length - 1, 1, this.genMessage)
} if (requestIndex === 1) { } if (requestIndex === 1) {
// console.log('requestIndex === 1', char);
this.$set(this.currentMessage, 'text', this.currentMessage.text + char) this.$set(this.currentMessage, 'text', this.currentMessage.text + char)
} }
const delay = this.getTypingDelay(char) const delay = this.getTypingDelay(char)

View File

@@ -3,12 +3,12 @@ import Vue from "vue";
import KnowledgeCard from "../card/index.vue" import KnowledgeCard from "../card/index.vue"
export function markdownContainer(markdownIt) { export function markdownContainer(markdownIt) {
console.log(markdownIt); // console.log(markdownIt);
console.log(container); // console.log(container);
container(markdownIt, "card", { container(markdownIt, "card", {
validate: function (params) { validate: function (params) {
console.log(params.trim(), "title line"); // console.log(params.trim(), "title line");
return "card" return "card"
// return params.trim().match(/^card\b/); // return params.trim().match(/^card\b/);
}, },
@@ -19,28 +19,29 @@ export function markdownContainer(markdownIt) {
content: function (tokens, idx, options, env, self) { content: function (tokens, idx, options, env, self) {
console.log(`args`, tokens, idx, options, env, self); // console.log(`args`, tokens, idx, options, env, self);
// return [...args] // return [...args]
console.log(`tokens[idx]`, tokens[idx]); // console.log(`tokens[idx]`, tokens[idx]);
const { markup } = tokens[idx] // const { markup } = tokens[idx]
console.log(`markup`, markup); // console.log(`markup`, markup);
setTimeout(() => { setTimeout(() => {
const dom = document.getElementById("cardTest"); const dom = document.getElementById("cardTest");
// if (dom) return // if (dom) return
const vm = new Vue({ // const vm = new Vue({
el: "#cardTest", // el: "#cardTest",
render: h => h(KnowledgeCard, { // render: h => h(KnowledgeCard, {
props: { // props: {
content: tokens[idx] // content: tokens[idx]
} // }
}) // })
}) // })
// console.log(`vue instance`, vm); // console.log(`vue instance`, vm);
}) })
// return tokens[idx] // return tokens[idx]
return "<div id='cardTest'></div>" // return "<div id='cardTest'></div>"
return tokens[idx].markup
} }
}) })
} }

View File

@@ -0,0 +1,125 @@
/**
* 打字机工具类测试文件
* 用于在Node.js环境中验证打字机功能
*/
// 导入打字机模块
const { createTyping } = require('../typing.js');
// 测试函数
async function testTyping() {
console.log('=== 打字机工具类测试开始 ===\n');
// 创建打字机实例
const typing = createTyping({
speed: 100, // 100ms每个字符
showCursor: true,
cursor: '█'
});
console.log('1. 测试基本打字功能');
console.log('-------------------');
// 测试1: 基本打字
await new Promise(resolve => {
typing.startTyping('你好,这是一个打字机测试!', (text) => {
console.log(`✓ 打字完成,最终文本: "${text}"\n`);
resolve();
});
});
// 等待一秒
await sleep(1000);
console.log('2. 测试队列功能');
console.log('---------------');
// 测试2: 队列功能
typing.resetTyping();
typing.pushText('第一段文本:欢迎使用打字机!');
typing.pushText('第二段文本:支持队列播放。');
typing.pushText('第三段文本:功能完整强大。');
console.log(`队列长度: ${typing.getState().queueLength}`);
await new Promise(resolve => {
typing.startTyping(null, (text) => {
console.log(`✓ 队列文本完成: "${text}"\n`);
resolve();
});
});
// 等待一秒
await sleep(1000);
console.log('3. 测试暂停和恢复功能');
console.log('---------------------');
// 测试3: 暂停和恢复
typing.resetTyping();
typing.startTyping('这是一个暂停测试,会在中途暂停然后恢复。');
// 2秒后暂停
setTimeout(() => {
console.log('>>> 暂停打字');
typing.pauseTyping();
// 2秒后恢复
setTimeout(() => {
console.log('>>> 恢复打字');
typing.resumeTyping();
}, 2000);
}, 2000);
// 等待完成
await sleep(8000);
console.log('\n4. 测试状态获取');
console.log('---------------');
// 测试4: 状态获取
typing.resetTyping();
typing.pushText('状态测试文本');
typing.startTyping();
// 获取状态
setTimeout(() => {
const state = typing.getState();
console.log('当前状态:', {
isTyping: state.isTyping,
isPaused: state.isPaused,
progress: Math.round(state.progress * 100) + '%',
currentText: `"${state.currentText}"`,
queueLength: state.queueLength
});
}, 1000);
await sleep(3000);
console.log('\n5. 测试速度调整');
console.log('---------------');
// 测试5: 速度调整
typing.resetTyping();
typing.setSpeed(200); // 设置为200ms
typing.startTyping('这是慢速打字测试...');
await sleep(5000);
console.log('\n=== 所有测试完成 ===');
// 最终清理
typing.stopTyping();
}
// 辅助函数:延时
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// 运行测试
if (require.main === module) {
testTyping().catch(console.error);
}
module.exports = { testTyping };

View File

@@ -0,0 +1,15 @@
import { createTyping } from "../typing.js";
const { startTyping, stopTyping, pauseTyping, pushStr } = createTyping()
pushStr("hello")
pushStr("world")
async function init() {
for await (const str of startTyping()) {
console.log(str)
}
}
init()

View File

@@ -0,0 +1,56 @@
export function createTyping(options) {
let text = ''
let typingIndex = 0;
const typingQueue = []
const typingOptions = {
typingSpeed: 100,
showCursor: false,
cursor: '█',
...options
}
function startTyping() {
if (!hasNext()) return
const _text = getStr()
typingIndex += 1
}
function stopTyping() {
}
function pauseTyping() {
}
function resetIndex() {
typingIndex = 0
}
function pushStr(str) {
typingQueue.push(str)
}
function getStr() {
return typingQueue[typingIndex]
}
function getInputText() {
}
function hasNext() {
return typingIndex <= typingQueue.length
}
return {
startTyping,
pauseTyping,
stopTyping,
pushStr,
getStr,
resetIndex
}
}