692 lines
20 KiB
Vue
692 lines
20 KiB
Vue
<template>
|
||
<view class="chat-container">
|
||
<!-- 聊天内容区域 -->
|
||
<scroll-view
|
||
class="chat-content"
|
||
scroll-y
|
||
:scroll-top="scrollTop"
|
||
:scroll-with-animation="true"
|
||
@scrolltoupper="loadMoreMessages"
|
||
:refresher-enabled="false"
|
||
ref="scrollView"
|
||
>
|
||
<view class="message-wrapper-box" >
|
||
<view v-for="(message, index) in messages" :key="index" class="message-wrapper">
|
||
<!-- 系统消息或AI消息 -->
|
||
<block v-if="message.role === 'system' || message.role === 'assistant'">
|
||
<view class="message-container ai-message-container">
|
||
<view class="message ai-message">
|
||
<!-- 使用渲染后的Markdown内容 -->
|
||
<rich-text class="message-content markdown-body" :nodes="message.renderedContent"></rich-text>
|
||
</view>
|
||
<text class="message-time">{{ message.time }}</text>
|
||
</view>
|
||
<!-- 在每条AI消息后显示确认按钮(除了系统消息) -->
|
||
<view v-if="message.role === 'assistant' && message.endStatus && index !== 0" class="accept-button-container">
|
||
<u-button
|
||
type="success"
|
||
size="mini"
|
||
@click="acceptScript(message.content)"
|
||
:custom-style="{marginTop: '10px'}"
|
||
>
|
||
<u-icon name="checkmark" size="20" class="button-icon"></u-icon>
|
||
使用此剧本
|
||
</u-button>
|
||
<u-button
|
||
v-if="index === findLastAssistantIndex()"
|
||
type="primary"
|
||
size="mini"
|
||
@click="resetScript(index)"
|
||
:custom-style="{marginTop: '10px',marginLeft: '10px'}"
|
||
>
|
||
<u-icon name="reload" size="20" class="button-icon"></u-icon>
|
||
重新生成
|
||
</u-button>
|
||
</view>
|
||
</block>
|
||
|
||
<!-- 用户消息 -->
|
||
<block v-else-if="message.role === 'user'">
|
||
<view class="message-container user-message-container">
|
||
<view class="message user-message">
|
||
<text class="message-content">{{ message.content }}</text>
|
||
</view>
|
||
<text class="message-time">{{ message.time }}</text>
|
||
</view>
|
||
</block>
|
||
</view>
|
||
|
||
<!-- 加载中提示 -->
|
||
<view v-if="loading" class="loading-container">
|
||
<u-loading mode="circle" size="24"></u-loading>
|
||
<text class="loading-text">正在生成剧本...</text>
|
||
</view>
|
||
|
||
<!-- 用于滚动到底部的空白元素 -->
|
||
<view id="scroll-bottom-anchor" style="height: 1px;"></view>
|
||
|
||
<!-- 底部占位,确保内容不被输入框遮挡 -->
|
||
<view class="bottom-placeholder"></view>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<!-- 输入区域 - 固定在底部 -->
|
||
<view class="chat-input-container">
|
||
<view class="chat-input">
|
||
<u-input
|
||
v-model="inputMessage"
|
||
placeholder="请输入剧本相关描述,AI将为您生成专业剧本..."
|
||
:disabled="loading"
|
||
type='textarea'
|
||
:auto-height="true"
|
||
:custom-style="{flex: 1, minHeight: '80rpx', maxHeight: '200rpx'}"
|
||
@confirm="sendMessage"
|
||
></u-input>
|
||
<u-button
|
||
type="primary"
|
||
:disabled="loading || !inputMessage.trim()"
|
||
@click="sendMessage"
|
||
:custom-style="{marginLeft: '10px'}"
|
||
>
|
||
发送
|
||
</u-button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import { renderMarkdown } from './markdown.js';
|
||
import config from "@/common/api/config.js"
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
// 聊天消息列表
|
||
messages: [
|
||
{
|
||
role: 'system',
|
||
content: '你好!我是你的AI剧本助手。请告诉我你想创作的剧本类型和主要情节,我会为你生成专业的剧本内容。',
|
||
renderedContent: '',
|
||
time: this.formatTime(new Date()),
|
||
endStatus: true
|
||
}
|
||
],
|
||
// 输入框内容
|
||
inputMessage: '',
|
||
// 加载状态
|
||
loading: false,
|
||
// 滚动位置
|
||
scrollTop: 0,
|
||
// URL参数
|
||
orderId: '',
|
||
// API密钥
|
||
apiKey: 'sk-uobtjwyzastkxcnrrbllxpftqxdumkafnitdqqhoermmioru',
|
||
// 当前AI消息索引
|
||
currentAiMessageIndex: -1,
|
||
// 是否是H5环境
|
||
isH5: false,
|
||
// 是否需要滚动到底部
|
||
needScrollToBottom: true,
|
||
// 滚动计时器
|
||
scrollTimer: null,
|
||
token:'',
|
||
};
|
||
},
|
||
onLoad(options) {
|
||
// 从URL获取参数
|
||
this.orderId = options.orderId || '';
|
||
this.token = uni.getStorageSync('userToken');
|
||
// 检测是否是H5环境
|
||
// #ifdef H5
|
||
this.isH5 = true;
|
||
// #endif
|
||
|
||
// 初始化
|
||
this.initPage();
|
||
|
||
// 渲染初始消息的Markdown内容
|
||
this.renderAllMarkdown();
|
||
this.getAiChatHistory();
|
||
},
|
||
onReady() {
|
||
// 页面加载完成后滚动到底部
|
||
this.throttledScrollToBottom();
|
||
},
|
||
onUnload() {
|
||
// 清除滚动计时器
|
||
if (this.scrollTimer) {
|
||
clearTimeout(this.scrollTimer);
|
||
this.scrollTimer = null;
|
||
}
|
||
},
|
||
watch: {
|
||
// 监听消息变化,自动滚动到底部
|
||
messages: {
|
||
deep: true,
|
||
handler() {
|
||
if (this.needScrollToBottom) {
|
||
this.throttledScrollToBottom();
|
||
}
|
||
}
|
||
}
|
||
},
|
||
methods: {
|
||
// 初始化页面
|
||
initPage() {
|
||
// 设置导航栏标题
|
||
uni.setNavigationBarTitle({
|
||
title: 'AI 剧本助手'
|
||
});
|
||
},
|
||
async getAiChatHistory(){
|
||
console.log("this.$api",this.$api)
|
||
let res = await this.$api.aiApi.getAiHistory(this.orderId)
|
||
console.log('当前获取历史记录', res);
|
||
if(res.code == 200){
|
||
let list = res.data || [];
|
||
this.messages = list.map((item) => {
|
||
return {
|
||
role: item.messageType,
|
||
content: item.message,
|
||
renderedContent: renderMarkdown(item.message),
|
||
time: item.createTime,
|
||
endStatus: true
|
||
}
|
||
});
|
||
if (list.length > 0) {
|
||
// 滚动到底部
|
||
this.$nextTick(()=>{
|
||
setTimeout(()=>this.throttledScrollToBottom(),1000)
|
||
})
|
||
}
|
||
}else{
|
||
uni.showToast({
|
||
title: res.msg,
|
||
icon: 'error'
|
||
});
|
||
}
|
||
|
||
},
|
||
findLastAssistantIndex() {
|
||
// 从后向前遍历数组
|
||
for (let i = this.messages.length - 1; i >= 0; i--) {
|
||
if (this.messages[i].role === 'assistant') {
|
||
return i; // 返回最后一条 assistant 的索引
|
||
}
|
||
}
|
||
return -1; // 如果没有找到,返回 -1
|
||
},
|
||
resetScript(index) {
|
||
if(this.messages[index-1] && this.messages[index-1].content && this.messages[index-1].role === 'user' ){
|
||
this.inputMessage = this.messages[index-1].content;
|
||
this.sendMessage()
|
||
}else{
|
||
uni.showToast({
|
||
title: '重新生成失败',
|
||
icon: 'error'
|
||
});
|
||
}
|
||
},
|
||
|
||
// 渲染所有消息的Markdown内容
|
||
renderAllMarkdown() {
|
||
this.messages.forEach((message, index) => {
|
||
if (message.role === 'system' || message.role === 'assistant') {
|
||
this.messages[index].renderedContent = renderMarkdown(message.content);
|
||
}
|
||
});
|
||
},
|
||
|
||
// 格式化时间为 HH:MM 格式
|
||
formatTime(date) {
|
||
const hours = date.getHours().toString().padStart(2, '0');
|
||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||
return `${hours}:${minutes}`;
|
||
},
|
||
|
||
// 发送消息
|
||
async sendMessage() {
|
||
if (!this.inputMessage.trim() || this.loading) return;
|
||
|
||
// 添加用户消息
|
||
const userMessage = {
|
||
role: 'user',
|
||
content: this.inputMessage,
|
||
time: this.formatTime(new Date())
|
||
};
|
||
this.messages.push(userMessage);
|
||
|
||
// 清空输入框
|
||
const userInput = this.inputMessage;
|
||
this.inputMessage = '';
|
||
|
||
// 设置加载状态
|
||
this.loading = true;
|
||
|
||
// 滚动到底部
|
||
this.throttledScrollToBottom()
|
||
|
||
try {
|
||
// 准备发送给API的消息
|
||
const apiMessages = [
|
||
{
|
||
role: 'system',
|
||
content: '你好!我是你的AI剧本助手。请告诉我你想创作的剧本类型和主要情节,我会为你生成专业的剧本内容。请使用Markdown格式来组织你的回答,可以使用标题、列表、粗体、斜体等Markdown语法来增强可读性。'
|
||
},
|
||
...this.messages
|
||
.filter(msg => msg.role !== 'system')
|
||
.map(msg => ({
|
||
role: msg.role,
|
||
content: msg.content
|
||
}))
|
||
];
|
||
|
||
// 在H5环境中使用fetch API进行流式请求
|
||
if (this.isH5) {
|
||
await this.fetchStreamResponse(userInput);
|
||
} else {
|
||
// 在非H5环境中使用普通请求
|
||
await this.fetchNormalResponse(apiMessages);
|
||
}
|
||
} catch (error) {
|
||
console.error('API请求失败:', error);
|
||
uni.showToast({
|
||
title: '生成剧本失败,请稍后重试',
|
||
icon: 'none'
|
||
});
|
||
} finally {
|
||
this.loading = false;
|
||
// 滚动到底部
|
||
this.throttledScrollToBottom();
|
||
}
|
||
},
|
||
|
||
// 使用fetch API进行流式请求(仅H5环境)
|
||
async fetchStreamResponse(apiMessages) {
|
||
try {
|
||
// 添加一个空的AI回复,用于流式更新
|
||
this.currentAiMessageIndex = this.messages.length;
|
||
this.messages.push({
|
||
role: 'assistant',
|
||
content: '',
|
||
renderedContent: '',
|
||
time: this.formatTime(new Date()),
|
||
endStatus: false
|
||
});
|
||
|
||
const response = await fetch(`${config.baseUrl}/ai/chatStream`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${this.token}`,
|
||
'clientid': config.clientId,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
redirect: 'follow',
|
||
body: JSON.stringify({ businessId:this.orderId, message:apiMessages })
|
||
});
|
||
// console.log("response",response)
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`API请求失败: ${response.status}`);
|
||
}
|
||
|
||
if (!response.body) {
|
||
throw new Error('响应体为空');
|
||
}
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let result = '';
|
||
let buffer = ''; // 用于存储未完整的数据
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
const chunk = decoder.decode(value, { stream: true }); // Use streaming mode
|
||
buffer += chunk;
|
||
|
||
// 将新的chunk添加到buffer中
|
||
const lines = buffer.split('\n');
|
||
// 按换行符分割,但保留buffer中可能不完整的最后一行
|
||
buffer = lines.pop() || '';
|
||
|
||
for (const line of lines) {
|
||
if (!line.trim()) continue; // Skip empty lines
|
||
|
||
let data = line;
|
||
|
||
if (line.startsWith('data:')) {
|
||
data = line.substring(5).trim();
|
||
}
|
||
|
||
if (data === '[DONE]') {
|
||
// 设置消息已完成状态
|
||
if (this.currentAiMessageIndex >= 0 && this.currentAiMessageIndex < this.messages.length) {
|
||
this.messages[this.currentAiMessageIndex].endStatus = true;
|
||
this.throttledScrollToBottom();
|
||
}
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
// For JSON data (if your API returns JSON)
|
||
if (data.startsWith('{') && data.endsWith('}')) {
|
||
const parsed = JSON.parse(data);
|
||
const content = parsed.choices?.[0]?.delta?.content || '';
|
||
if (content) {
|
||
result += content;
|
||
}
|
||
} else {
|
||
// For plain text data
|
||
result += data;
|
||
}
|
||
|
||
// Update message content
|
||
if (this.currentAiMessageIndex >= 0 && this.currentAiMessageIndex < this.messages.length) {
|
||
this.messages[this.currentAiMessageIndex].content = result;
|
||
this.messages[this.currentAiMessageIndex].renderedContent = renderMarkdown(result);
|
||
this.throttledScrollToBottom();
|
||
}
|
||
} catch (e) {
|
||
console.error('解析流数据失败:', e, line);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Process any remaining data in buffer
|
||
if (buffer.trim()) {
|
||
try {
|
||
let data = buffer;
|
||
if (buffer.startsWith('data:')) {
|
||
data = buffer.substring(5).trim();
|
||
}
|
||
if (data && data !== '[DONE]') {
|
||
// For JSON data
|
||
if (data.startsWith('{') && data.endsWith('}')) {
|
||
try {
|
||
const parsed = JSON.parse(data);
|
||
const content = parsed.choices?.[0]?.delta?.content || '';
|
||
if (content) {
|
||
result += content;
|
||
}
|
||
} catch (e) {
|
||
// If not valid JSON, treat as plain text
|
||
result += data;
|
||
}
|
||
} else {
|
||
// For plain text data
|
||
result += data;
|
||
}
|
||
|
||
this.messages[this.currentAiMessageIndex].content = result;
|
||
this.messages[this.currentAiMessageIndex].renderedContent = renderMarkdown(result);
|
||
this.throttledScrollToBottom();
|
||
}
|
||
} catch (e) {
|
||
console.error('处理剩余数据失败:', e, buffer);
|
||
}
|
||
}
|
||
|
||
// Ensure message is marked as complete
|
||
if (this.currentAiMessageIndex >= 0 && this.currentAiMessageIndex < this.messages.length) {
|
||
this.messages[this.currentAiMessageIndex].endStatus = true;
|
||
}
|
||
|
||
return result;
|
||
} catch (error) {
|
||
console.error('调用流式API失败:', error);
|
||
// If error occurs, ensure empty AI message is removed
|
||
if (this.messages.length > 0 && this.messages[this.messages.length - 1].content === '') {
|
||
this.messages.pop();
|
||
this.currentAiMessageIndex = -1;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
// Use normal request (non-H5 environment)
|
||
async fetchNormalResponse(apiMessages) {
|
||
try {
|
||
// Add an empty AI reply
|
||
this.currentAiMessageIndex = this.messages.length;
|
||
this.messages.push({
|
||
role: 'assistant',
|
||
content: '',
|
||
renderedContent: '',
|
||
time: this.formatTime(new Date()),
|
||
endStatus: false
|
||
});
|
||
|
||
const response = await uni.request({
|
||
url: `${config.baseUrl}/ai/generate`,
|
||
method: 'POST',
|
||
header: {
|
||
'Authorization': `Bearer ${this.token}`,
|
||
'clientid': config.clientId,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
data: apiMessages,
|
||
dataType: 'json'
|
||
});
|
||
|
||
// Check response status
|
||
if (response.statusCode !== 200) {
|
||
throw new Error(`API请求失败: ${response.statusCode}`);
|
||
}
|
||
|
||
// Get response content
|
||
const result = response.data.content || '';
|
||
|
||
// Update message content
|
||
this.messages[this.currentAiMessageIndex].content = result;
|
||
this.messages[this.currentAiMessageIndex].renderedContent = renderMarkdown(result);
|
||
this.messages[this.currentAiMessageIndex].endStatus = true;
|
||
|
||
// Scroll to bottom
|
||
this.throttledScrollToBottom();
|
||
|
||
return result;
|
||
} catch (error) {
|
||
console.error('调用API失败:', error);
|
||
// If error occurs, ensure empty AI message is removed
|
||
if (this.messages.length > 0 && this.messages[this.messages.length - 1].content === '') {
|
||
this.messages.pop();
|
||
this.currentAiMessageIndex = -1;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
// Accept script
|
||
acceptScript(scriptContent) {
|
||
uni.showToast({
|
||
title: '已确认使用此剧本',
|
||
icon: 'success'
|
||
});
|
||
|
||
// 当前页面(假设是 pageB)
|
||
uni.navigateBack({
|
||
delta: 1, // 返回的页面层数,1 表示返回上一个页面
|
||
success: function() {
|
||
// 获取页面栈
|
||
const pages = getCurrentPages();
|
||
// 上一个页面实例(假设是 pageA)
|
||
const prevPage = pages[pages.length - 2];
|
||
console.log("prevPage",prevPage)
|
||
// 调用上一个页面的方法
|
||
prevPage.getAiDramaTxt(scriptContent);
|
||
}
|
||
});
|
||
},
|
||
|
||
// Throttled scroll to bottom function to avoid frequent scrolling
|
||
throttledScrollToBottom() {
|
||
this.$u.throttle(this.scrollToBottom, 300);
|
||
},
|
||
|
||
// Scroll to bottom
|
||
scrollToBottom() {
|
||
this.$nextTick(() => {
|
||
setTimeout(() => {
|
||
this.$nextTick(() => {
|
||
const query = uni.createSelectorQuery()
|
||
query.select('.chat-content').boundingClientRect()
|
||
query.select('.message-wrapper-box').boundingClientRect()
|
||
query.exec(res => {
|
||
const scrollViewHeight = res[0].height
|
||
const scrollContentHeight = res[1].height
|
||
if (scrollContentHeight > scrollViewHeight) {
|
||
const scrollTop = scrollContentHeight - scrollViewHeight
|
||
this.scrollTop = scrollTop
|
||
}
|
||
})
|
||
})
|
||
|
||
// const query = uni.createSelectorQuery().in(this);
|
||
// query.select('#scroll-bottom-anchor').boundingClientRect(data => {
|
||
// if (data) {
|
||
// // Scroll to anchor element position
|
||
// uni.pageScrollTo({
|
||
// selector: '#scroll-bottom-anchor',
|
||
// duration: 100
|
||
// });
|
||
// }
|
||
// }).exec();
|
||
}, 100);
|
||
});
|
||
},
|
||
|
||
// Load more messages (pull-up loading)
|
||
loadMoreMessages() {
|
||
// Logic for loading history messages can be implemented here
|
||
// Currently not needed, keeping the interface
|
||
this.needScrollToBottom = false; // Disable auto-scrolling when pulling up
|
||
|
||
// Restore auto-scrolling after loading
|
||
setTimeout(() => {
|
||
this.needScrollToBottom = true;
|
||
}, 1000);
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss">
|
||
.chat-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: calc(100vh - var(--window-top));
|
||
position: relative;
|
||
}
|
||
|
||
.chat-content {
|
||
flex: 1;
|
||
background-color: #f5f7fa;
|
||
}
|
||
|
||
.message-wrapper-box{
|
||
padding: 20rpx;
|
||
}
|
||
|
||
.message-wrapper {
|
||
margin-bottom: 40rpx;
|
||
}
|
||
|
||
.message-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.ai-message-container {
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.user-message-container {
|
||
align-items: flex-end;
|
||
}
|
||
|
||
.message {
|
||
max-width: calc(80% - 40rpx);
|
||
padding: 24rpx 30rpx;
|
||
border-radius: 16rpx;
|
||
position: relative;
|
||
}
|
||
|
||
.ai-message {
|
||
background-color: #fff;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
.user-message {
|
||
background-color: #4e6ef2;
|
||
color: white;
|
||
box-shadow: 0 2px 8px rgba(78, 110, 242, 0.2);
|
||
}
|
||
|
||
.message-content {
|
||
line-height: 1.5;
|
||
word-break: break-word;
|
||
}
|
||
|
||
/* Markdown styles */
|
||
.markdown-body {
|
||
color: #333;
|
||
}
|
||
|
||
.message-time {
|
||
font-size: 24rpx;
|
||
color: #909399;
|
||
margin-top: 10rpx;
|
||
padding: 0 10rpx;
|
||
}
|
||
|
||
.user-message-container .message-time {
|
||
color: #909399;
|
||
}
|
||
|
||
/* Input area fixed at the bottom */
|
||
.chat-input-container {
|
||
background-color: #fff;
|
||
border-top: 1px solid #ebeef5;
|
||
padding: 20rpx 30rpx;
|
||
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
|
||
z-index: 100;
|
||
margin-top: auto;
|
||
position: sticky;
|
||
bottom: 0;
|
||
}
|
||
|
||
.chat-input {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
}
|
||
|
||
.bottom-placeholder {
|
||
height: 40rpx; /* Ensure content is not hidden by input box */
|
||
}
|
||
|
||
.accept-button-container {
|
||
margin-bottom: 40rpx;
|
||
}
|
||
|
||
.loading-container {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #909399;
|
||
font-size: 28rpx;
|
||
margin: 20rpx 0;
|
||
}
|
||
|
||
.loading-text {
|
||
margin-left: 16rpx;
|
||
}
|
||
|
||
.button-icon {
|
||
margin-right: 8rpx;
|
||
}
|
||
</style>
|
||
|