gy-app-shop/pages/fun/AiDrama.vue

692 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>