Files
tuanwei-uniapp/pages/assistant/index.vue
T
weicw-dshsa 6bbbd386c8 fix(ai): 补齐失败态判定的三处遗漏(评审跟进)
对抗式评审发现上一版仍有三处同类缺口,一并修掉:

- formatCreativeResult 不再回落 payload.answer:失败报文里 answer 是
  workflow_run_id(UUID),而 status 缺失时(message_end 事件、后端流式兜底
  分支)失败短路不生效,裸 UUID 会进气泡
- 新增 isDisplayableAnswer / pickDisplayable:把 UUID 与无空格长 token
  这类内部标识符挡在展示之外,formatOutlineResult 与 formatManuscriptResult
  复用;大纳/稿件也一并加失败态短路,避免 03 落地后端后渲染成
  「标题 + 失败文案」
- creative-assistant 的 workflowFailed 补 data.status 回退,与 assistant 页
  及后端 workflowStatus(两处都看)对齐,避免退回误导性的「待执行」空态

验证:verify-frontend-fallback.js 17/17;verify-frontend-e2e.js 以后端真实产出
为输入跑通,渲染结果无内部标识符与原始 JSON。

Refs: .scratch/ai-assistant/issues/01-creative-assistant-raw-json-bubble.md
2026-09-24 03:51:53 +08:00

1814 lines
46 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 ?? '')
}
},
/**
* 取载荷里的工作流执行状态。
* 后端会把 status 放在顶层;兼容 data.status 的嵌套写法。
*/
getPayloadStatus(payload) {
if (!payload || typeof payload !== 'object') {
return ''
}
const status =
payload.status !== undefined && payload.status !== null
? payload.status
: payload.data && payload.data.status
return String(status || '').trim().toLowerCase()
},
/**
* 工作流是否以失败收场。
* 只在拿到明确 failed 时为真,状态缺失视为未失败,避免误伤正常响应。
*/
isFailedPayload(payload) {
return this.getPayloadStatus(payload) === 'failed'
},
/**
* 兜底文案:解析不出业务字段时的可读提示。
*
* <p>绝不能退回 JSON.stringify 整包 —— 那会把 workflow_id / req_id
* 等内部标识符与上游错误栈直接贴进聊天气泡(见 issue 01)。
*/
buildFallbackText() {
return '接口已返回,但未解析到可展示的内容。'
},
/**
* 判断一个 `answer` 值是否可以直接展示给用户。
*
* <p>issue 01 的失败报文里 `extractAnswer` 会把顶层 `answer` 填成
* `workflow_run_id`(UUID)。status 缺失时(如 `message_end` 事件)
* 失败短路不生效,此时若直接信任 `answer`,气泡里就是一个裸 UUID。
* 宁可回落到兜底文案,也不要把内部标识符当正文。
*/
isDisplayableAnswer(value) {
if (typeof value !== 'string') {
return false
}
const text = value.trim()
if (!text) {
return false
}
// 纯 UUID / 十六进制串 / 无空格的超长 token,都视为内部标识符
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text)) {
return false
}
if (!/\s/.test(text) && /^[0-9a-zA-Z_-]{32,}$/.test(text)) {
return false
}
return true
},
/**
* 同 `pickString`,但会跳过不像给人看的 `answer`(如 UUID)。
*/
pickDisplayable(...values) {
for (let index = 0; index < values.length; index += 1) {
const value = values[index]
if (typeof value === 'string' && value.trim() && this.isDisplayableAnswer(value)) {
return value.trim()
}
}
return ''
},
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.pickDisplayable(
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>