feat(fun): 添加 AI 剧本生成功能

- 在 素材录入页面添加 AI 生成剧本按钮
- 新增 AiDrama 页面用于与 AI 助手交互
- 实现与 DeepSeek API 的流式和非流式通信
- 添加 Markdown 渲染功能
- 优化聊天界面布局和交互
This commit is contained in:
fhysy 2025-03-11 16:23:59 +08:00
parent 4e6414d8c8
commit 0d06fe8fb6
4 changed files with 723 additions and 0 deletions

View File

@ -656,6 +656,12 @@
"navigationBarTitleText": "视频预览",
"navigationBarBackgroundColor": "#fff"
}
},{
"path": "AiDrama",
"style": {
"navigationBarTitleText": "AI 剧本助手",
"navigationBarBackgroundColor": "#fff"
}
}]
},
{

634
pages/fun/AiDrama.vue Normal file
View File

@ -0,0 +1,634 @@
<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" class="accept-button-container">
<u-button
type="success"
size="mini"
@click="acceptScript(message.content)"
:custom-style="{marginTop: '10px'}"
>
<u-icon name="checkmark" size="14" 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';
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
};
},
onLoad(options) {
// URL
this.orderId = options.orderId || '';
// H5
// #ifdef H5
this.isH5 = true;
// #endif
//
this.initPage();
// Markdown
this.renderAllMarkdown();
},
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 剧本助手'
});
},
// 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(apiMessages);
} else {
// H5使
await this.fetchNormalResponse(apiMessages);
}
} catch (error) {
console.error('API请求失败:', error);
uni.showToast({
title: '生成剧本失败,请稍后重试',
icon: 'none'
});
} finally {
this.loading = false;
//
this.throttledScrollToBottom();
}
},
// 使fetch APIH5
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('https://api.siliconflow.cn/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'deepseek-ai/DeepSeek-R1-Distill-Qwen-7B',
messages: apiMessages,
stream: true,
max_tokens: 8192,
stop: '',
temperature: 0.6,
top_p: 0.7,
top_k: 50,
frequency_penalty: 0
})
});
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 = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n').filter(line => line.trim() !== '');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.substring(6).trim();
if (data === '[DONE]') {
//
if (this.currentAiMessageIndex >= 0 && this.currentAiMessageIndex < this.messages.length) {
this.messages[this.currentAiMessageIndex].endStatus = true;
//
this.throttledScrollToBottom();
}
continue;
}
try {
const parsed = JSON.parse(data);
// parsedchoices
if (!parsed || !parsed.choices || !Array.isArray(parsed.choices) || parsed.choices.length === 0) {
console.warn('无效的响应格式:', data);
continue;
}
// 访delta
const delta = parsed.choices[0].delta || {};
//
const content = delta.content;
// reasoning_contentcontent
const reasoningContent = delta.reasoning_content || '';
// contentnullreasoningContent
const textToAdd = (content !== null && content !== undefined) ? content : reasoningContent;
if (textToAdd) {
result += textToAdd;
// messages[currentAiMessageIndex]
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);
}
}
}
}
return result;
} catch (error) {
console.error('调用DeepSeek流式API失败:', error);
// AI
if (this.messages.length > 0 && this.messages[this.messages.length - 1].content === '') {
this.messages.pop();
this.currentAiMessageIndex = -1;
}
throw error;
}
},
// 使H5
async fetchNormalResponse(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 uni.request({
url: 'https://api.siliconflow.cn/v1/chat/completions',
method: 'POST',
header: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
data: {
model: 'deepseek-ai/DeepSeek-R1-Distill-Qwen-7B',
messages: apiMessages,
stream: false,
max_tokens: 8192,
stop: '',
temperature: 0.6,
top_p: 0.7,
top_k: 50,
frequency_penalty: 0
},
dataType: 'json'
});
//
if (response.statusCode !== 200) {
throw new Error(`API请求失败: ${response.statusCode}`);
}
//
const result = response.data.choices[0].message.content;
//
this.messages[this.currentAiMessageIndex].content = result;
this.messages[this.currentAiMessageIndex].renderedContent = renderMarkdown(result);
this.messages[this.currentAiMessageIndex].endStatus = true;
//
this.throttledScrollToBottom();
return result;
} catch (error) {
console.error('调用DeepSeek API失败:', error);
// AI
if (this.messages.length > 0 && this.messages[this.messages.length - 1].content === '') {
this.messages.pop();
this.currentAiMessageIndex = -1;
}
throw error;
}
},
//
acceptScript(scriptContent) {
// // 使
// getApp().globalData.selectedScript = {
// orderId: this.orderId,
// content: scriptContent
// };
// const pages = getCurrentPages();
// // pageA
// const prevPage = pages[pages.length - 2];
// console.log("prevPage",prevPage)
uni.showToast({
title: '已确认使用此剧本',
icon: 'success'
});
//
// setTimeout(() => {
// uni.navigateBack();
// }, 1500);
// 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);
}
});
},
//
throttledScrollToBottom() {
// if (this.scrollTimer) return;
// this.scrollTimer = setTimeout(() => {
// this.scrollToBottom();
// this.scrollTimer = null;
// }, 1000); // 300ms
this.$u.throttle(this.scrollToBottom, 300)
},
//
scrollToBottom() {
this.$nextTick(() => {
// 1: 使scrollTop
// const query = uni.createSelectorQuery().in(this);
// query.select('.chat-content').boundingClientRect(data => {
// if (data) {
// //
// this.scrollTop = 999999;
// }
// }).exec();
// 2: 使
setTimeout(() => {
const query = uni.createSelectorQuery().in(this);
query.select('#scroll-bottom-anchor').boundingClientRect(data => {
if (data) {
//
uni.pageScrollTo({
selector: '#scroll-bottom-anchor',
duration: 100
});
}
}).exec();
}, 100);
});
},
//
loadMoreMessages() {
//
//
this.needScrollToBottom = false; //
//
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;
// padding: 20rpx;
// padding-bottom: 180rpx; /* */
}
.message-wrapper-box{
padding: 20rpx;
// padding-bottom: 180rpx; /* */
}
.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样式 */
.markdown-body {
color: #333;
}
.message-time {
font-size: 24rpx;
color: #909399;
margin-top: 10rpx;
padding: 0 10rpx;
}
.user-message-container .message-time {
color: #909399;
}
/* 固定在底部的输入区域 */
.chat-input-container {
// position: fixed;
// bottom: 0;
// left: 0;
// right: 0;
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;
// min-height: 170rpx;
margin-top: auto;
position: sticky;
bottom: 0;
}
.chat-input {
display: flex;
align-items: flex-end;
}
.bottom-placeholder {
height: 40rpx; /* 确保内容不被输入框遮挡 */
}
.accept-button-container {
// display: flex;
// justify-content: flex-start;
// margin-top: 20rpx;
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>

64
pages/fun/markdown.js Normal file
View File

@ -0,0 +1,64 @@
// 简单的Markdown渲染函数
export function renderMarkdown(content) {
if (!content) return '';
// 由于UniApp的rich-text组件支持HTML我们将Markdown转换为HTML
// 这是一个简化版的Markdown解析器仅支持基本语法
let html = content
// 转义HTML特殊字符
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// 标题
.replace(/^# (.*$)/gm, '<h1>$1</h1>')
.replace(/^## (.*$)/gm, '<h2>$1</h2>')
.replace(/^### (.*$)/gm, '<h3>$1</h3>')
.replace(/^#### (.*$)/gm, '<h4>$1</h4>')
.replace(/^##### (.*$)/gm, '<h5>$1</h5>')
.replace(/^###### (.*$)/gm, '<h6>$1</h6>')
// 粗体和斜体
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/__(.*?)__/g, '<strong>$1</strong>')
.replace(/_(.*?)_/g, '<em>$1</em>')
// 链接
.replace(/\[([^\]]+)\]$$([^)]+)$$/g, '<a href="$2">$1</a>')
// 图片
.replace(/!\[([^\]]+)\]$$([^)]+)$$/g, '<img src="$2" alt="$1">')
// 无序列表
.replace(/^\s*[-*+]\s+(.*$)/gm, '<li>$1</li>')
// 有序列表
.replace(/^\s*\d+\.\s+(.*$)/gm, '<li>$1</li>')
// 代码块
.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>')
// 行内代码
.replace(/`([^`]+)`/g, '<code>$1</code>')
// 引用
.replace(/^\> (.*$)/gm, '<blockquote>$1</blockquote>')
// 段落
.replace(/\n\s*\n/g, '</p><p>')
// 换行
.replace(/\n/g, '<br>');
// 包装在段落标签中
html = '<p>' + html + '</p>';
// 修复列表
html = html.replace(/<li>.*?<\/li>/g, function(match) {
return '<ul>' + match + '</ul>';
});
return html;
}

View File

@ -8,6 +8,16 @@
<u-icon name="arrow-down-fill" color="#999" size="28"></u-icon>
</view>
</view>
<view class="form-item">
<text class="form-label">剧本 <text class="form-hint">视频的剧本</text> </text>
<!-- 文本输入 -->
<template>
<u-input style="max-height: 500rpx;overflow-y: auto;" :disabled="getDisabledStatus()" maxlength="9999" type='textarea' class="bg-gray" v-model="scriptContent" :placeholder="`请输入剧本`" />
</template>
<u-button @click="openAiDrama" type="primary" :disabled="getDisabledStatus()">Ai生成剧本</u-button>
</view>
<view v-for="(item,index) in materialTemplateList" :key="index" class="form-item">
<text class="form-label">{{ item.attrTitle }} <text class="form-hint">{{ item.attrHint ? '(' +item.attrHint + ')' : '' }}</text> </text>
<!-- 文本输入 -->
@ -100,6 +110,7 @@
audioType: ['.wav', '.mp3', '.aac', '.flac', '.ogg'],
audioH5Type: ['wav', 'mp3', 'aac', 'flac', 'ogg'],
buttonLoading:false,
scriptContent:'',
// videoType: ['.avi', '.mp4', '.mkv', '.flv', '.mov', '.wmv', '.mpeg', '.3gp'],
}
},
@ -112,6 +123,14 @@
this.getProductDetail();
},
methods: {
getAiDramaTxt(txt){
this.scriptContent = txt;
},
openAiDrama(){
uni.navigateTo({
url:'./AiDrama'
})
},
getDisabledStatus(){
if(this.commitState !== 'add' && this.commitState !== 'draft' && this.commitState !== 'back'){
return true;