Files
tuanwei-uniapp/pages/assistant/index.vue
T
weicw-dshsa 024d3dd122 fix(ai): 兜底收敛公共模块,失败态不再 stringify 整包(issue 03)
「拿不到字段就 stringify 整包」的兜底此前在 3 个页面各写一份,
其中 activity-outline:332 显式把 payload.raw 当兜底内容源,是最严重的一例。

- 新增 utils/ai-result.js:getPayloadStatus / isFailedPayload /
  isDisplayableAnswer / pickDisplayable / resolveDisplayText /
  logRawPayload 统一一处;解析不出内容给可读文案,原始报文只进
  console.error 不进 UI;UUID 与无空格长 token 不算可展示内容
- manuscript parseResult:失败态短路、pickDisplayable 挡标识符、
  summary 不再回显内部字段名
- activity-outline parseOutline:删掉 payload.raw 显式兜底,失败态短路
- assistant 页删除自带副本,改为 import 公共模块
- 流式收尾:appendAssistantChunk / appendOutlineChunk 支持 reset——
  后端判定失败时丢弃已累积的半截内容;新增 streamFailed 跟踪,
  失败后不再 toast「内容生成完成」/「活动策划大纲生成完成」
- 删除死代码:两页 stringifyUnknown、大纲页 pickNonEmptyString

验证:verify-other-links.js 27/27(修复前代码跑同脚本 13 失败,判别力已证伪);
verify-frontend-fallback.js 17/17 回归通过。

Refs: .scratch/ai-assistant/issues/03-other-links-raw-payload-fallback.md
2026-09-24 04:22:41 +08:00

1770 lines
44 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'
import {
FALLBACK_TEXT,
FAILED_TEXT,
isDisplayableAnswer,
isFailedPayload,
pickDisplayable
} from '../../utils/ai-result'
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
},
/**
* 兜底文案:解析不出业务字段时的可读提示。
*
* <p>统一来自 `utils/ai-result`,不再各页面自带一份(见 issue 03 定位第 1 条)。
*/
buildFallbackText() {
return FALLBACK_TEXT
},
/**
* 工作流是否以失败收场。判定逻辑统一在 `utils/ai-result`。
*/
isFailedPayload(payload) {
return isFailedPayload(payload)
},
/**
* 同 `pickString`,但会跳过不像给人看的 `answer`(如 UUID)。
*/
pickDisplayableText(...values) {
return pickDisplayable(...values)
},
isDisplayableAnswer(value) {
return isDisplayableAnswer(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
}
// 与大纲 / 创意链路对齐:失败态直接给可读文案。
// 否则 answer 里的失败提示会被当成正文,渲染成「标题 + 失败文案」。
if (this.isFailedPayload(payload)) {
return '生成失败,请稍后重试'
}
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,
this.isDisplayableAnswer(data.answer) ? 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.buildFallbackText()
},
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) {
// 与创意链路同理:失败态直接给可读文案,不落到整包兜底,
// 也不信任 payload.answer(失败时它可能是 UUID)。
if (this.isFailedPayload(payload)) {
return '生成失败,请稍后重试'
}
const data = payload || {}
return (
this.pickDisplayableText(
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.buildFallbackText()
)
},
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) {
// 失败态优先:此时 outputs 为空、拿不到任何业务字段,
// 必须直接给可读文案,不能落到下面的整包兜底(issue 01)。
// 注意不能信任 payload.answer:失败报文中它可能是 workflow_run_id
// 这类 UUID(后端 extractAnswer 的伴生现象),必须换成固定文案。
if (this.isFailedPayload(payload)) {
return '生成失败,请稍后重试'
}
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')
// 注意:这里不再回落到 payload.answer。issue 01 的报文里 answer 被
// extractAnswer 填成了 workflow_run_id(UUID);status 缺失时(如 message_end
// 事件)失败短路不生效,回落 answer 就会把裸 UUID 贴进气泡。
// 拿不到已知业务字段时一律给可读兜底。
return text || this.buildFallbackText()
},
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>