Files
tuanwei-uniapp/pages/assistant/index.vue
2026-07-20 11:56:02 +08:00

1726 lines
42 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="page">
<!-- Fixed custom navbar -->
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="hero-bg hero-bg-left"></view>
<view class="hero-bg hero-bg-right"></view>
<view class="navbar">
<view class="navbar-inner">
<view class="navbar-left">
<view class="nav-icon-btn" @tap="openHistoryPanel">
<view class="icon-list">
<view class="icon-list-line"></view>
<view class="icon-list-line"></view>
<view class="icon-list-line icon-list-line--sm"></view>
</view>
</view>
<view class="nav-icon-btn" @tap="startNewChat">
<view class="icon-compose">
<view class="icon-compose-h"></view>
<view class="icon-compose-v"></view>
</view>
</view>
</view>
<view class="navbar-center">
<view class="navbar-title">AI 助手</view>
<view class="navbar-sub">
{{ activeChatTitle }}<text v-if="userName"> · {{ userName }}</text>
</view>
</view>
<view class="navbar-placeholder"></view>
</view>
</view>
</view>
<!-- Spacer matching navbar height -->
<view class="navbar-spacer" :style="{ height: navbarTotalHeight + 'px' }"></view>
<view class="assistant-shell">
<scroll-view
scroll-y
scroll-with-animation
class="message-scroll"
:scroll-into-view="scrollIntoView"
>
<view v-if="!currentUser.userId" class="notice-card">
<view class="notice-title">请先登录后使用 AI 助手</view>
<view class="notice-desc">当前页面已接入真实会话逻辑发送消息和历史会话依赖登录态</view>
</view>
<view v-else-if="!messageList.length" class="empty-card">
<view class="empty-title">你好我是 AI 团委助手</view>
<view class="empty-desc">
你可以直接提问团务流程问题我会通过实时消息回复
</view>
</view>
<view
v-for="item in messageList"
:key="item.id"
:id="item.anchorId"
class="message-row"
:class="{ 'message-row--user': isUserMessage(item) }"
>
<view class="avatar" :class="{ 'avatar--user': isUserMessage(item) }">
{{ isUserMessage(item) ? '我' : '助' }}
</view>
<view class="message-main">
<view class="bubble" :class="{ 'bubble--user': isUserMessage(item), 'bubble--pending': item.loading }">
<view
v-if="!isUserMessage(item) && shouldShowAssistantThinking(item)"
class="thinking-row"
>
<view class="thinking-dot"></view>
<text>{{ ASSISTANT_PENDING_TEXT }}</text>
</view>
<view
v-if="isUserMessage(item)"
class="bubble-text"
>{{ item.content }}</view>
<zero-markdown-view
v-else-if="hasAssistantDisplayContent(item.content)"
class="bubble-text bubble-markdown"
:markdown="getAssistantDisplayContent(item.content)"
/>
</view>
<view class="bubble-actions">
<view class="bubble-action" @tap="copyMessage(item)">复制</view>
<view
v-if="isUserMessage(item)"
class="bubble-action"
@tap="fillContent(item.content || '')"
>
重用
</view>
</view>
</view>
</view>
<view :id="bottomAnchorId" class="scroll-anchor"></view>
</scroll-view>
<view class="composer">
<view class="mode-tags">
<view
v-for="item in chatModeOptions"
:key="item.value"
class="mode-tag"
:class="{ 'mode-tag--active': activeChatMode === item.value, 'mode-tag--disabled': loading }"
@tap="switchChatMode(item.value)"
>
{{ item.label }}
</view>
</view>
<view class="composer-row">
<textarea
v-model="draft"
class="composer-input"
placeholder-style="color:#a59a94;"
:placeholder="inputPlaceholder"
:disabled="loading || !currentUser.userId"
confirm-type="send"
auto-height
maxlength="-1"
show-confirm-bar="false"
@confirm="onSend"
/>
<view
class="composer-send"
:class="{ 'composer-send--disabled': !canSend }"
@tap="onSend"
>{{ loading ? '···' : '发送' }}</view>
</view>
</view>
</view>
<view v-if="showHistoryPanel" class="history-mask" @tap="closeHistoryPanel">
<view class="history-panel" @tap.stop>
<view class="history-head">
<view>
<view class="history-title">历史会话</view>
<view class="history-subtitle">按最近更新时间展示</view>
</view>
<view class="history-close" @tap="closeHistoryPanel">关闭</view>
</view>
<scroll-view scroll-y class="history-scroll">
<view v-if="historySections.length">
<view v-for="section in historySections" :key="section.label" class="history-section">
<view class="history-label">{{ section.label }}</view>
<view
v-for="item in section.items"
:key="getHistoryKey(item)"
class="history-item"
:class="{ 'history-item--active': activeHistoryKey === getHistoryKey(item) }"
>
<view class="history-item-main" @tap="selectChat(item)">
<view class="history-item-title">{{ getHistoryTitle(item) }}</view>
<view class="history-item-time">
{{ formatHistoryItemTime(item.createTime) }}
</view>
</view>
<view
class="history-delete"
@tap.stop="removeChat(item)"
>
{{ deletingChatId === item.id ? '删除中' : '删除' }}
</view>
</view>
</view>
</view>
<view v-else class="history-empty">暂无历史对话</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script>
import dayjs from '../../uni_modules/uv-ui-tools/libs/util/dayjs.js'
import {
activityOutlineGen,
buildWebSocketUrl,
creativeAssistant,
getCurrentUser,
listAiChatHistory,
listAiChatList,
manuscriptGen,
removeAiChatList,
sendMessage as sendChatMessage
} from './chat-service'
const ASSISTANT_PENDING_TEXT = '正在思考中...'
const THINK_OPEN_TAG = '<think>'
const THINK_CLOSE_TAG = '</think>'
const CHAT_MODE_OPTIONS = [
{ label: '聊天', value: 'chat' },
{ label: '稿件', value: 'manuscript' },
{ label: '创意', value: 'creative' },
{ label: '策划', value: 'outline' }
]
export default {
data() {
return {
ASSISTANT_PENDING_TEXT,
chatModeOptions: CHAT_MODE_OPTIONS,
statusBarHeight: 0,
draft: '',
activeChatMode: 'chat',
loading: false,
wsConnected: false,
scrollIntoView: '',
bottomAnchorId: `chat-bottom-${Date.now()}`,
promptList: [
'帮我生成团日活动策划大纲',
'写一篇活动新闻稿开头',
'优化竞赛申报书摘要',
'整理五四评优申报材料清单'
],
messageList: [],
historyList: [],
activeChatId: null,
activeConversationId: '',
streamingMessageId: '',
showHistoryPanel: false,
deletingChatId: null,
currentUser: {
userId: null,
nickname: '',
realName: '',
token: ''
},
socketTask: null,
socketConnectPromise: null,
initialized: false
}
},
computed: {
navbarTotalHeight() {
return this.statusBarHeight + 44
},
canSend() {
return !!this.currentUser.userId && !!this.draft.trim() && !this.loading
},
inputPlaceholder() {
if (!this.currentUser.userId) {
return '请先登录后使用 AI 助手'
}
if (this.activeChatMode === 'manuscript') {
return '请输入稿件标题、活动时间地点、参与人员和亮点内容,可直接用自然语言描述'
}
if (this.activeChatMode === 'creative') {
return '请输入创意生成指令,例如短视频脚本、海报排版、推文封面等需求'
}
if (this.activeChatMode === 'outline') {
return '请输入活动策划需求,建议包含主题、受众、时间、经费、地点等信息'
}
return '说说你想问的问题'
},
userName() {
return (
this.currentUser.nickname ||
this.currentUser.realName ||
(this.currentUser.userId ? `用户${this.currentUser.userId}` : '')
)
},
chatHeaderText() {
if (this.loading) {
return this.wsConnected ? 'AI 助手在线答疑' : 'AI 助手连接中...'
}
return 'AI 助手待命中'
},
activeHistoryKey() {
if (this.activeChatId) {
return `chat-${this.activeChatId}`
}
if (this.activeConversationId) {
return `conversation-${this.activeConversationId}`
}
return ''
},
activeChatTitle() {
if (!this.activeChatId && !this.activeConversationId) {
return '新对话'
}
const target = this.historyList.find(
(item) =>
item.id === this.activeChatId ||
(item.conversationId && item.conversationId === this.activeConversationId)
)
return target ? this.getHistoryTitle(target) : '当前会话'
},
historySections() {
const groupMap = {}
;[...this.historyList]
.sort((left, right) => this.getTimestamp(right.updateTime || right.createTime) - this.getTimestamp(left.updateTime || left.createTime))
.forEach((item) => {
const label = this.getHistoryDateLabel(item.createTime || item.updateTime)
if (!groupMap[label]) {
groupMap[label] = []
}
groupMap[label].push(item)
})
console.log(Object.keys(groupMap).map((label) => ({
label,
items: groupMap[label]
})))
return Object.keys(groupMap).map((label) => ({
label,
items: groupMap[label]
}))
}
},
onLoad() {
const { statusBarHeight } = uni.getSystemInfoSync()
this.statusBarHeight = statusBarHeight || 0
},
onShow() {
this.refreshUser()
if (!this.initialized) {
this.initAssistant()
return
}
if (this.currentUser.userId) {
this.loadHistoryList(false).catch((error) => {
this.showError(error, '刷新历史会话失败')
})
}
},
onUnload() {
this.disconnectWebSocket()
},
methods: {
refreshUser() {
const storedUser = uni.getStorageSync('user_info') || {}
this.currentUser = {
userId: Number(storedUser.userId || 0) || null,
nickname: storedUser.nickname || storedUser.nickName || '',
realName: storedUser.realName || storedUser.name || '',
token: uni.getStorageSync('token') || null
}
return this.currentUser
},
async initAssistant() {
try {
this.refreshUser()
if (!this.currentUser.userId) {
return
}
await this.loadHistoryList(true)
} catch (error) {
this.showError(error, '初始化聊天页失败')
} finally {
this.initialized = true
}
},
showToast(title) {
uni.showToast({
title,
icon: 'none'
})
},
showError(error, fallback) {
this.showToast((error && error.message) || fallback)
},
ensureLogin(showToast = true) {
this.refreshUser()
if (this.currentUser.userId) {
return true
}
if (showToast) {
this.showToast('请先登录后使用 AI 助手')
}
return false
},
createMessageId(prefix) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
},
isUserMessage(item) {
return item.role === 'user'
},
getTimestamp(value) {
if (!value) {
return 0
}
const normalized = String(value).replace(/-/g, '/')
const result = new Date(normalized).getTime()
return Number.isNaN(result) ? 0 : result
},
getTimeLabel(value) {
const source = value ? new Date(String(value).replace(/-/g, '/')) : new Date()
if (Number.isNaN(source.getTime())) {
return ''
}
const hours = `${source.getHours()}`.padStart(2, '0')
const minutes = `${source.getMinutes()}`.padStart(2, '0')
return `${hours}:${minutes}`
},
formatHistoryItemTime(value) {
if (!value) {
return ''
}
const date = dayjs(String(value).replace(/-/g, '/'))
if (!date.isValid()) {
return String(value || '')
}
return date.format('YYYY-MM-DD HH:mm')
},
isSameDate(left, right) {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
)
},
getHistoryDateLabel(value) {
if (!value) {
return '更早'
}
const date = new Date(String(value).replace(/-/g, '/'))
if (Number.isNaN(date.getTime())) {
return '更早'
}
const now = new Date()
if (this.isSameDate(date, now)) {
return '今天'
}
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
if (this.isSameDate(date, yesterday)) {
return '昨天'
}
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`
},
getHistoryKey(item) {
if (item.id) {
return `chat-${item.id}`
}
return `conversation-${item.conversationId || item.createTime || this.createMessageId('history')}`
},
getHistoryTitle(item) {
const title = (item.title || '').trim()
if (title) {
return title
}
if (item.conversationId) {
return `会话 ${item.conversationId.slice(0, 8)}`
}
return '未命名对话'
},
parseAssistantContent(value) {
if (!value || value === ASSISTANT_PENDING_TEXT) {
return {
content: '',
thinking: value === ASSISTANT_PENDING_TEXT
}
}
let cursor = 0
let content = ''
let thinking = false
while (cursor < value.length) {
if (!thinking) {
const openIndex = value.indexOf(THINK_OPEN_TAG, cursor)
const closeIndex = value.indexOf(THINK_CLOSE_TAG, cursor)
if (closeIndex !== -1 && (openIndex === -1 || closeIndex < openIndex)) {
cursor = closeIndex + THINK_CLOSE_TAG.length
continue
}
if (openIndex === -1) {
content += value.slice(cursor)
break
}
content += value.slice(cursor, openIndex)
cursor = openIndex + THINK_OPEN_TAG.length
thinking = true
continue
}
const closeIndex = value.indexOf(THINK_CLOSE_TAG, cursor)
if (closeIndex === -1) {
break
}
cursor = closeIndex + THINK_CLOSE_TAG.length
thinking = false
}
return {
content,
thinking
}
},
getAssistantDisplayContent(value) {
return this.parseAssistantContent(value).content
},
hasAssistantDisplayContent(value) {
return !!this.getAssistantDisplayContent(value).trim()
},
shouldShowAssistantThinking(item) {
if (item.content === ASSISTANT_PENDING_TEXT) {
return true
}
if (!item.loading) {
return false
}
return this.parseAssistantContent(item.content).thinking
},
getMessageCopyContent(item) {
return this.isUserMessage(item)
? item.content || ''
: this.getAssistantDisplayContent(item.content).trim()
},
copyMessage(item) {
const text = this.getMessageCopyContent(item)
if (!text) {
this.showToast('暂无可复制内容')
return
}
uni.setClipboardData({
data: text,
success: () => {
this.showToast('已复制')
}
})
},
fillContent(value) {
if (this.loading) {
return
}
this.draft = value
},
switchChatMode(mode) {
if (this.loading || this.activeChatMode === mode) {
return
}
this.activeChatMode = mode
},
scrollToBottom() {
this.bottomAnchorId = `chat-bottom-${Date.now()}`
this.$nextTick(() => {
this.scrollIntoView = this.bottomAnchorId
})
},
formatHistoryMessages(list) {
const result = []
;[...(list || [])]
.sort((left, right) => this.getTimestamp(left.createTime) - this.getTimestamp(right.createTime))
.forEach((item) => {
const time = this.getTimeLabel(item.createTime)
if (item.content) {
result.push({
id: this.createMessageId('user'),
anchorId: this.createMessageId('anchor'),
role: 'user',
content: item.content,
time
})
}
if (item.reply) {
result.push({
id: this.createMessageId('assistant'),
anchorId: this.createMessageId('anchor'),
role: 'assistant',
content: item.reply,
time
})
}
})
return result
},
syncActiveChat() {
if (!this.activeConversationId) {
return
}
const current = this.historyList.find((item) => item.conversationId === this.activeConversationId)
if (current && current.id) {
this.activeChatId = current.id
}
},
appendAssistantChunk(answer, conversationId) {
if (conversationId) {
this.activeConversationId = conversationId
}
if (!this.streamingMessageId) {
const messageId = this.createMessageId('assistant')
this.streamingMessageId = messageId
this.messageList.push({
id: messageId,
anchorId: this.createMessageId('anchor'),
role: 'assistant',
content: answer,
loading: true,
time: this.getTimeLabel()
})
this.scrollToBottom()
return
}
const target = this.messageList.find((item) => item.id === this.streamingMessageId)
if (!target) {
return
}
target.content =
target.content === ASSISTANT_PENDING_TEXT ? answer : `${target.content}${answer}`
this.scrollToBottom()
},
async finishAssistantMessage(conversationId) {
if (conversationId) {
this.activeConversationId = conversationId
}
const target = this.messageList.find((item) => item.id === this.streamingMessageId)
if (target) {
target.loading = false
if (target.content === ASSISTANT_PENDING_TEXT) {
target.content = ''
}
}
this.streamingMessageId = ''
this.loading = false
await this.loadHistoryList(false)
this.syncActiveChat()
},
disconnectWebSocket() {
this.socketConnectPromise = null
this.wsConnected = false
const socketTask = this.socketTask
this.socketTask = null
if (socketTask && typeof socketTask.close === 'function') {
try {
socketTask.close({})
} catch (error) {
console.warn('socket close failed', error)
}
}
},
async handleSocketMessage(raw) {
if (!raw) {
return
}
try {
const data = JSON.parse(raw)
if (data && data.answer === '__END__') {
await this.finishAssistantMessage(data.conversationId)
this.disconnectWebSocket()
return
}
if (data && (data.loading === 'true' || data.loading === true)) {
return
}
const notice = (data && (data.notice || data.answer)) || ''
if (notice) {
this.appendAssistantChunk(String(notice), data.conversationId)
}
} catch (error) {
console.warn('websocket message parse failed', error)
this.appendAssistantChunk(String(raw))
}
},
connectWebSocket() {
const { userId } = this.refreshUser()
if (!userId) {
return Promise.reject(new Error('未获取到当前登录用户'))
}
const currentSocket = this.socketTask
if (currentSocket && this.wsConnected) {
return Promise.resolve()
}
if (currentSocket && this.socketConnectPromise) {
return this.socketConnectPromise
}
if (currentSocket) {
this.disconnectWebSocket()
}
let cleanupPending = () => undefined
const connectPromise = new Promise((resolve, reject) => {
let settled = false
const socketTask = uni.connectSocket({
url: buildWebSocketUrl(userId),
complete: () => {}
})
cleanupPending = () => {
if (this.socketConnectPromise === connectPromise) {
this.socketConnectPromise = null
}
}
socketTask.onOpen(() => {
this.wsConnected = false
})
socketTask.onMessage((event) => {
const raw = String((event && event.data) || '')
if (raw === '连接成功') {
this.wsConnected = true
if (!settled) {
settled = true
cleanupPending()
resolve()
}
return
}
this.handleSocketMessage(raw)
})
socketTask.onClose(() => {
this.wsConnected = false
if (this.socketTask === socketTask) {
this.socketTask = null
}
if (!settled) {
settled = true
cleanupPending()
reject(new Error('聊天连接已关闭'))
return
}
if (this.loading && this.streamingMessageId) {
const target = this.messageList.find((item) => item.id === this.streamingMessageId)
if (target) {
target.loading = false
if (target.content === ASSISTANT_PENDING_TEXT) {
target.content = '连接已断开,请稍后重试。'
}
}
this.streamingMessageId = ''
this.loading = false
}
cleanupPending()
})
socketTask.onError(() => {
this.wsConnected = false
if (!settled) {
settled = true
cleanupPending()
reject(new Error('聊天连接失败'))
}
})
this.socketTask = socketTask
})
this.socketConnectPromise = connectPromise
return connectPromise
},
async loadHistoryList(autoSelect = true) {
const { userId } = this.refreshUser()
if (!userId) {
this.historyList = []
return
}
const response = await listAiChatList({ userId })
this.historyList = Array.isArray(response) ? response : []
this.syncActiveChat()
if (
autoSelect &&
!this.activeChatId &&
!this.activeConversationId &&
this.historyList.length
) {
await this.selectChat(this.historyList[0], true)
}
},
async loadChatHistory(item) {
const { userId } = this.refreshUser()
const response = await listAiChatHistory({
chatId: item.id,
conversationId: item.conversationId,
userId
})
this.messageList = this.formatHistoryMessages(Array.isArray(response) ? response : [])
this.scrollToBottom()
},
async selectChat(item, silent = false) {
if (this.loading && !silent) {
this.showToast('当前正在生成回复,请稍后再切换会话')
return
}
this.activeChatId = item.id || null
this.activeConversationId = item.conversationId || ''
this.showHistoryPanel = false
await this.loadChatHistory(item)
},
startNewChat() {
if (this.loading) {
this.showToast('当前正在生成回复,请稍后再开启新对话')
return
}
this.disconnectWebSocket()
this.activeChatId = null
this.activeConversationId = ''
this.streamingMessageId = ''
this.messageList = []
this.draft = ''
this.showHistoryPanel = false
},
stringifyUnknown(value) {
try {
return JSON.stringify(value, null, 2)
} catch (error) {
return String(value ?? '')
}
},
pickString(...values) {
for (let index = 0; index < values.length; index += 1) {
const value = values[index]
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
}
return ''
},
parseKeyValueInput(value) {
const result = {}
String(value || '')
.split(/\n+/)
.map((item) => item.trim())
.filter(Boolean)
.forEach((line) => {
const match = line.match(/^([^:]+)[:]\s*(.*)$/)
if (match) {
result[match[1].trim()] = match[2].trim()
}
})
return result
},
buildManuscriptPayload(query) {
const fields = this.parseKeyValueInput(query)
return {
docType: fields['稿件类型'] || fields['类型'] || '新闻稿',
eventType: fields['活动类型'] || '校园活动',
theme: fields['稿件标题'] || fields['标题'] || fields['主题'] || query,
time: fields['活动时间'] || fields['时间'] || '',
location: fields['活动地点'] || fields['地点'] || '',
participants: fields['参与人员'] || fields['人员'] || '',
highlights: fields['亮点内容'] || fields['亮点'] || fields['内容'] || query,
number: Number(fields['建议篇幅'] || fields['字数'] || 800),
files: []
}
},
formatManuscriptResult(payload, query) {
if (typeof payload === 'string') {
return payload
}
const data = payload || {}
const title = this.pickString(
data.title,
data.theme,
data.name,
data.subject,
data.data && data.data.title
)
const body = this.pickString(
data.content,
data.text,
data.result,
data.output,
data.answer,
data.manuscript,
data.article,
data.message,
data.data && data.data.content,
data.data && data.data.text,
data.data && data.data.result,
data.data && data.data.output,
data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content
)
if (title || body) {
return `${title || this.buildManuscriptPayload(query).theme}\n\n${body}`
}
return this.stringifyUnknown(payload)
},
buildOutlinePayload(query) {
const fields = this.parseKeyValueInput(query)
return {
event_theme: fields['活动主题'] || fields['主题'] || query,
people: fields['面向受众'] || fields['受众'] || fields['参与人员'] || '团员青年',
event_time: fields['活动时间'] || fields['时间'] || '待定',
event_funding: fields['活动资金'] || fields['资金'] || fields['经费'] || '待定',
event_type: fields['活动形式'] || fields['形式'] || '',
event_location: fields['活动地点'] || fields['地点'] || '',
event_meme: fields['备注'] || fields['补充'] || query
}
},
formatOutlineResult(payload) {
const data = payload || {}
return (
this.pickString(
data.answer,
data.raw && data.raw.answer,
data.raw && data.raw.data && data.raw.data.answer,
data.raw && data.raw.output,
data.raw && data.raw.result,
data.raw && data.raw.content,
data.raw && data.raw.message
) || this.stringifyUnknown(payload)
)
},
getCreativeOutputs(payload) {
if (payload && payload.outputs && typeof payload.outputs === 'object') {
return payload.outputs
}
if (payload && payload.data && payload.data.outputs && typeof payload.data.outputs === 'object') {
return payload.data.outputs
}
return payload || {}
},
getCreativeFileUrl(item) {
return String(
(item && (item.url || item.remote_url || item.downloadUrl || item.download_url || item.path)) || ''
).trim()
},
getCreativeFileName(item, fallback) {
return String((item && (item.name || item.filename)) || fallback).trim()
},
isCreativeImageFile(item) {
const url = this.getCreativeFileUrl(item).toLowerCase()
const contentType = String(
(item && (item.contentType || item.content_type || item.mime_type)) || ''
).toLowerCase()
const fileType = String((item && item.type) || '').toLowerCase()
const extension = String((item && item.extension) || '').toLowerCase()
return (
contentType.indexOf('image/') === 0 ||
fileType === 'image' ||
/\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/.test(url) ||
['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'].indexOf(extension) > -1
)
},
formatFileList(title, list = []) {
if (!Array.isArray(list) || !list.length) {
return ''
}
return `\n\n### ${title}\n${list
.map((item, index) => {
const url = this.getCreativeFileUrl(item)
const name = this.getCreativeFileName(item, `结果文件${index + 1}`)
if (!url) {
return ''
}
return `${index + 1}. [${name}](${url})`
})
.filter(Boolean)
.join('\n')}`
},
formatImageList(title, list = []) {
if (!Array.isArray(list) || !list.length) {
return ''
}
const lines = list
.map((item) => {
const url = this.getCreativeFileUrl(item)
if (!url) {
return ''
}
return `![](${url})`
})
.filter(Boolean)
if (!lines.length) {
return ''
}
return `\n\n### ${title}\n${lines.join('\n\n')}`
},
formatCreativeResult(payload) {
const outputs = this.getCreativeOutputs(payload)
const script = this.pickString(outputs.Script_result, outputs.script, outputs.text)
const videoMessage = this.pickString(outputs.video_message)
const photoMessage = this.pickString(outputs.photo_message)
const photoFiles = Array.isArray(outputs.photo_files)
? outputs.photo_files
: Array.isArray(outputs.photo_file)
? outputs.photo_file
: []
const imageFiles = photoFiles.filter((item) => this.isCreativeImageFile(item))
const otherPhotoFiles = photoFiles.filter((item) => imageFiles.indexOf(item) === -1)
const files = [
this.formatFileList('视频生成结果', outputs.video_result || []),
this.formatImageList('图像 / 排版结果', imageFiles),
this.formatFileList('图像 / 排版结果文件', otherPhotoFiles)
].join('')
const text = [
videoMessage,
photoMessage,
script ? `### 脚本创作结果\n${script}` : '',
files
]
.filter(Boolean)
.join('\n\n')
return text || this.stringifyUnknown(payload)
},
async sendModeMessage(query, assistantId) {
const target = this.messageList.find((item) => item.id === assistantId)
const setResult = (value) => {
if (target) {
target.content = value || '接口已返回,但未解析到内容。'
target.loading = false
}
}
if (this.activeChatMode === 'manuscript') {
const result = await manuscriptGen(this.buildManuscriptPayload(query))
setResult(this.formatManuscriptResult(result, query))
return
}
if (this.activeChatMode === 'creative') {
const result = await creativeAssistant({
user_content: query,
user_contentString: query,
user_files: []
})
setResult(this.formatCreativeResult(result))
return
}
if (this.activeChatMode === 'outline') {
const result = await activityOutlineGen(this.buildOutlinePayload(query))
setResult(this.formatOutlineResult(result))
}
},
openHistoryPanel() {
this.showHistoryPanel = true
if (this.currentUser.userId) {
this.loadHistoryList(false).catch((error) => {
this.showError(error, '加载历史会话失败')
})
}
},
closeHistoryPanel() {
this.showHistoryPanel = false
},
async removeChat(item) {
if (this.loading) {
this.showToast('当前正在生成回复,请稍后再删除会话')
return
}
if (!item.id) {
this.showToast('当前会话缺少标识,无法删除')
return
}
const result = await new Promise((resolve) => {
uni.showModal({
title: '删除会话',
content: `确定删除“${this.getHistoryTitle(item)}”吗?`,
success: resolve,
fail: () => resolve({ confirm: false })
})
})
if (!result.confirm) {
return
}
this.deletingChatId = item.id
try {
const isActive =
this.activeChatId === item.id ||
(item.conversationId && this.activeConversationId === item.conversationId)
await removeAiChatList(item.id)
await this.loadHistoryList(false)
if (isActive) {
const nextChat = this.historyList[0]
if (nextChat) {
this.activeChatId = nextChat.id || null
this.activeConversationId = nextChat.conversationId || ''
await this.loadChatHistory(nextChat)
} else {
this.startNewChat()
}
}
this.showToast('删除成功')
} catch (error) {
this.showError(error, '删除失败')
} finally {
this.deletingChatId = null
}
},
usePrompt(text) {
this.fillContent(text)
},
async onSend() {
if (!this.canSend) {
if (!this.currentUser.userId) {
this.ensureLogin(true)
}
return
}
const query = this.draft.trim()
this.loading = true
try {
const assistantId = this.createMessageId('assistant')
const userTime = this.getTimeLabel()
this.messageList.push({
id: this.createMessageId('user'),
anchorId: this.createMessageId('anchor'),
role: 'user',
content: query,
time: userTime
})
this.messageList.push({
id: assistantId,
anchorId: this.createMessageId('anchor'),
role: 'assistant',
content: ASSISTANT_PENDING_TEXT,
loading: true,
time: this.getTimeLabel()
})
this.streamingMessageId = assistantId
this.draft = ''
this.scrollToBottom()
if (this.activeChatMode === 'chat') {
await this.connectWebSocket()
await sendChatMessage({
query,
conversationId: this.activeConversationId || undefined
})
return
}
await this.sendModeMessage(query, assistantId)
this.streamingMessageId = ''
this.loading = false
this.scrollToBottom()
} catch (error) {
const target = this.streamingMessageId
? this.messageList.find((item) => item.id === this.streamingMessageId)
: null
if (target) {
target.loading = false
target.content = '出错了,请稍后再试。'
}
this.streamingMessageId = ''
this.loading = false
this.disconnectWebSocket()
this.showError(error, '发送失败')
}
}
}
}
</script>
<style lang="scss" scoped>
.page {
height: 100vh;
padding: 0 24rpx 24rpx;
box-sizing: border-box;
display: flex;
flex-direction: column;
background:
radial-gradient(circle at top left, rgba(20, 150, 242, 0.1), transparent 26%),
//linear-gradient(180deg, #f9f4ef 0%, #f4ede7 100%);
rgba(20, 150, 242, 0.1)
}
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
overflow: hidden;
background: linear-gradient(180deg, #f7fbff 0%, #d9f0ff 44%, #f4fbff 100%);
box-shadow: 0 10rpx 28rpx rgba(65, 105, 135, 0.12);
}
.hero-bg {
position: absolute;
border-radius: 50%;
filter: blur(8rpx);
opacity: 0.9;
}
.hero-bg-left {
top: 84rpx;
left: -30rpx;
width: 300rpx;
height: 200rpx;
background: radial-gradient(circle, rgba(197, 237, 255, 0.95) 0%, rgba(197, 237, 255, 0) 72%);
}
.hero-bg-right {
top: 46rpx;
right: -40rpx;
width: 360rpx;
height: 260rpx;
background: radial-gradient(circle, rgba(184, 231, 255, 0.92) 0%, rgba(184, 231, 255, 0) 72%);
}
.navbar {
position: relative;
z-index: 2;
padding-left: 28rpx;
padding-right: 20rpx;
}
.navbar-inner {
display: flex;
align-items: center;
height: 88rpx;
justify-content: center;
position: relative;
}
.navbar-left {
display: flex;
align-items: center;
gap: 8rpx;
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
}
.nav-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 72rpx;
height: 72rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.72);
box-shadow: 0 8rpx 18rpx rgba(115, 164, 196, 0.14);
}
/* List / history icon */
.icon-list {
display: flex;
flex-direction: column;
gap: 7rpx;
width: 38rpx;
}
.icon-list-line {
height: 4rpx;
border-radius: 4rpx;
background: #101214;
}
.icon-list-line--sm {
width: 60%;
}
/* Plus / new-chat icon */
.icon-compose {
position: relative;
width: 36rpx;
height: 36rpx;
}
.icon-compose-h {
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 4rpx;
border-radius: 4rpx;
background: #101214;
transform: translateY(-50%);
}
.icon-compose-v {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 4rpx;
border-radius: 4rpx;
background: #101214;
transform: translateX(-50%);
}
.navbar-center {
min-width: 0;
text-align: center;
padding: 0 168rpx;
}
.navbar-title {
font-size: 28rpx;
font-weight: 700;
color: #101214;
line-height: 1.3;
letter-spacing: 2rpx;
}
.navbar-sub {
font-size: 22rpx;
color: #5f6570;
margin-top: 2rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.navbar-placeholder {
width: 152rpx;
flex-shrink: 0;
}
.navbar-spacer {
flex-shrink: 0;
}
.assistant-shell {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.message-scroll {
flex: 1;
min-height: 0;
padding: 4rpx 2rpx 0;
}
.prompt-panel,
.empty-card,
.notice-card {
padding: 24rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 10rpx 28rpx rgba(76, 49, 35, 0.08);
}
.prompt-title,
.notice-title,
.empty-title {
font-size: 28rpx;
font-weight: 700;
color: #23272f;
}
.prompt-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 18rpx;
}
.prompt-chip {
padding: 14rpx 22rpx;
border-radius: 999rpx;
background: #fbebea;
color: #1496f2;
font-size: 24rpx;
}
.notice-card,
.empty-card {
margin-top: 22rpx;
}
.notice-desc,
.empty-desc {
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.7;
color: #7d726d;
}
.message-row {
display: flex;
align-items: flex-start;
margin-top: 22rpx;
}
.message-row--user {
flex-direction: row-reverse;
}
.avatar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 68rpx;
height: 68rpx;
border-radius: 22rpx;
background: rgba(20, 150, 242, 0.12);
color: #1496f2;
font-size: 26rpx;
font-weight: 700;
}
.avatar--user {
background: linear-gradient(145deg, #0f7dd1 0%, #1496f2 100%);
color: #fff;
}
.message-main {
display: flex;
flex-direction: column;
align-items: flex-start;
max-width: calc(100% - 96rpx);
margin-left: 16rpx;
}
.message-row--user .message-main {
align-items: flex-end;
margin-left: 0;
margin-right: 16rpx;
}
.bubble {
max-width: 100%;
padding: 22rpx 24rpx;
border-radius: 26rpx 26rpx 26rpx 8rpx;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 10rpx 28rpx rgba(76, 49, 35, 0.08);
}
.bubble--user {
border-radius: 26rpx 26rpx 8rpx 26rpx;
background: linear-gradient(145deg, #0f7dd1 0%, #1496f2 100%);
}
.bubble--pending {
border: 1rpx solid rgba(240, 122, 61, 0.22);
}
.bubble-text {
font-size: 28rpx;
line-height: 1.75;
color: #27303a;
word-break: break-all;
white-space: pre-wrap;
}
.bubble--user .bubble-text,
.bubble--user .bubble-time {
color: #fff;
}
.bubble-time {
margin-top: 12rpx;
font-size: 20rpx;
color: #918781;
}
.thinking-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 10rpx;
font-size: 24rpx;
color: #c1652d;
}
.thinking-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #f07a3d;
box-shadow: 0 0 0 10rpx rgba(240, 122, 61, 0.12);
}
.bubble-actions {
display: flex;
gap: 14rpx;
margin-top: 12rpx;
}
.bubble-action {
padding: 8rpx 16rpx;
border-radius: 999rpx;
background: rgba(20, 150, 242, 0.08);
color: #1496f2;
font-size: 22rpx;
}
.composer {
padding: 16rpx 16rpx 16rpx 24rpx;
border-radius: 30rpx;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 12rpx 30rpx rgba(76, 49, 35, 0.08);
}
.mode-tags {
display: flex;
flex-wrap: wrap;
gap: 14rpx;
margin-bottom: 16rpx;
}
.mode-tag {
padding: 10rpx 22rpx;
border-radius: 999rpx;
background: rgba(20, 150, 242, 0.08);
color: #1496f2;
font-size: 24rpx;
line-height: 1.4;
}
.mode-tag--active {
background: linear-gradient(145deg, #0f7dd1 0%, #1496f2 100%);
color: #fff;
box-shadow: 0 10rpx 20rpx rgba(20, 150, 242, 0.18);
}
.mode-tag--disabled {
opacity: 0.6;
}
.composer-row {
display: flex;
align-items: flex-end;
gap: 12rpx;
}
.composer-input {
flex: 1;
min-height: 72rpx;
max-height: 240rpx;
padding: 18rpx 0;
font-size: 28rpx;
line-height: 1.6;
color: #27303a;
}
.composer-send {
flex-shrink: 0;
height: 72rpx;
padding: 0 30rpx;
border-radius: 20rpx;
background: linear-gradient(145deg, #0f7dd1 0%, #1496f2 100%);
color: #fff;
font-size: 28rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.composer-send--disabled {
background: #d8cfcb;
}
.history-mask {
position: fixed;
inset: 0;
z-index: 99;
background: rgba(44, 28, 23, 0.36);
display: flex;
justify-content: flex-end;
}
.history-panel {
width: 84%;
height: 100vh;
padding: 30rpx 24rpx 24rpx;
background: #fff7f4;
box-shadow: -12rpx 0 30rpx rgba(44, 28, 23, 0.12);
display: flex;
flex-direction: column;
}
.history-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
}
.history-title {
font-size: 32rpx;
font-weight: 700;
color: #2b2c31;
}
.history-subtitle {
margin-top: 10rpx;
font-size: 22rpx;
color: #8a7f79;
}
.history-close {
padding: 10rpx 18rpx;
border-radius: 18rpx;
background: rgba(20, 150, 242, 0.08);
color: #1496f2;
font-size: 24rpx;
}
.history-scroll {
flex: 1;
min-height: 0;
margin-top: 24rpx;
}
.history-section + .history-section {
margin-top: 24rpx;
}
.history-label {
margin-bottom: 14rpx;
padding-left: 6rpx;
font-size: 22rpx;
color: #8b817b;
}
.history-item {
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.92);
border: 1rpx solid rgba(20, 150, 242, 0.06);
box-shadow: 0 10rpx 28rpx rgba(76, 49, 35, 0.06);
}
.history-item + .history-item {
margin-top: 14rpx;
}
.history-item--active {
border-color: rgba(20, 150, 242, 0.2);
background: #fff1ec;
}
.history-item-main {
flex: 1;
min-width: 0;
}
.history-item-title {
font-size: 26rpx;
font-weight: 700;
color: #2f3136;
line-height: 1.5;
}
.history-item-time {
margin-top: 10rpx;
font-size: 22rpx;
color: #8e837d;
}
.history-delete {
flex-shrink: 0;
padding: 10rpx 16rpx;
border-radius: 18rpx;
background: rgba(20, 150, 242, 0.08);
color: #1496f2;
font-size: 22rpx;
}
.history-empty {
padding: 40rpx 20rpx;
text-align: center;
font-size: 24rpx;
color: #978b84;
}
.scroll-anchor {
height: 2rpx;
}
/* Markdown rendered inside assistant bubbles */
.bubble-markdown {
display: block;
font-size: 28rpx;
line-height: 1.75;
color: #27303a;
word-break: break-all;
p { margin: 0 0 16rpx; }
p:last-child { margin-bottom: 0; }
h1, h2, h3, h4, h5, h6 {
font-weight: 700;
margin: 20rpx 0 10rpx;
color: #1a1f27;
}
h1 { font-size: 38rpx; }
h2 { font-size: 34rpx; }
h3 { font-size: 30rpx; }
ul, ol {
padding-left: 36rpx;
margin: 10rpx 0;
}
li { margin-bottom: 8rpx; }
code {
display: inline;
padding: 2rpx 10rpx;
border-radius: 8rpx;
background: rgba(20, 150, 242, 0.08);
color: #1496f2;
font-size: 24rpx;
font-family: monospace;
}
pre {
padding: 22rpx;
border-radius: 16rpx;
background: #f3ede8;
overflow-x: auto;
margin: 16rpx 0;
}
pre code {
padding: 0;
background: transparent;
color: #3d2e27;
font-size: 24rpx;
white-space: pre;
}
blockquote {
margin: 12rpx 0;
padding: 10rpx 20rpx;
border-left: 6rpx solid rgba(20, 150, 242, 0.3);
color: #6b5f59;
background: rgba(20, 150, 242, 0.04);
border-radius: 0 12rpx 12rpx 0;
}
hr {
margin: 20rpx 0;
border: none;
border-top: 1rpx solid rgba(20, 150, 242, 0.15);
}
strong { font-weight: 700; }
em { font-style: italic; }
}
</style>