Files
tuanwei-uniapp/pages/assistant/index.vue
T
weicw-dshsa 4e022b4cca fix(ai): 失败态给出可读文案,不再 stringify 整包兜底
后端判定工作流失败后不再下发 outputs / raw,前端需据此给出可读提示,
而不是把整包对象 JSON.stringify 进气泡(含 workflow_id / req_id 等内部标识符)。

- assistant/index.vue:新增 getPayloadStatus / isFailedPayload / buildFallbackText;
  formatCreativeResult 与 formatOutlineResult 失败态短路为固定文案;
  formatManuscriptResult、formatOutlineResult、formatCreativeResult 的整包
  stringifyUnknown 兜底一律改为可读兜底文案
- 失败文案不取自 payload.answer:失败报文里它是 workflow_run_id(UUID),
  原样展示等于把内部标识符贴给用户
- creative-assistant/index.vue:新增 workflowFailed,状态标签显示「执行失败」,
  结果区给出失败文案,不再停在误导性的「待执行」空态

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

1769 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'
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 '接口已返回,但未解析到可展示的内容。'
},
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.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.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.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')
return text || this.pickString(payload && payload.answer) || 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>