Files
tuanwei-uniapp/pages/assistant/index.vue
T
weicw-dshsa 244f0c24d8 feat(ai): 问答助手页入口对齐 web,改为「聊天/写作/图片」(issue 15)
模式标签由「聊天/稿件/创意/策划」改为「聊天/写作/图片」,去掉策划链路;
写作与图片两个入口补上 web 端已有的表单与附件能力。

- 新增 pages/assistant/writing-params.js:web 端 writing-params.ts 的等价移植
  (零依赖纯函数)。写作模式加「文体/字数」面板,入参优先级改为
  「界面显式选择 > 键值入参 > 自然语言推断 > 默认值」——过去没有表单,
  只按「键:值」解析,解析不到就一律回落 doc_type=新闻稿 / Number=800,
  而上游提示词把这两项当主控指令,会直接压掉用户原话里的诉求(issue 13)
- 图片模式加「图片比例 + 参考素材」:附件组装成 user_files(上限 5,
  类型 image/video/audio/document),比例作为文本追加进 user_content
- chat-service 新增 uploadAiFile(file, app);上传必须带 app='creativeAssistant'
  —— Dify 的 /files/upload 按应用鉴权,不传 app 的附件必然报
  Invalid upload file(issue 14)
- 删除 buildOutlinePayload / formatOutlineResult 与 activityOutlineGen import;
  顺带清掉本次改动块内的死代码(未使用的 getCurrentUser、pickDisplayableText)

顺带修掉同源缺陷:pages/creative-assistant 的 uploadAiFile(file) 也没传应用凭证,
该页附件同样是坏的(issue 14 记的 gxmu/creativeAssistant 表单页那个缺陷)。

验证:移植等价性用例 8/8 —— 同一张用例表同时喂给小程序与 web 两端实现逐字段比对
JSON,另加 4 条常量/边界断言(防「两边一起错」);模板↔脚本静态一致性检查
(模板绑定名、this.X() 调用点、已删标识符残留)全绿,判别力经 5 处变异测试证伪;
5 个改动文件 node --check 通过,style 块花括号配平。
本机无 HBuilderX CLI 与 package.json,真机/微信开发者工具未跑。

本文件同时带有此前未提交的 issue 01 跟进改动(resolveFailedCreativeText:
创意失败态采用后端按原因映射的文案,但仍过 isDisplayableAnswer 兜底),
同一文件无法拆分。

Refs: .scratch/ai-assistant/issues/15-uniapp-assistant-modes-align-web.md
2026-10-13 20:17:58 +08:00

2113 lines
54 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">{{ assistantTitle }}</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">你好,我是 {{ assistantTitle }}</view>
<view class="empty-desc">
{{ assistantHint }}
</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 v-if="activeChatMode === 'writing'" class="option-panel">
<text class="option-label">文体</text>
<view
v-for="item in writingDocTypeOptions"
:key="item.value"
class="option-chip"
:class="{ 'option-chip--active': writingDocType === item.value, 'option-chip--disabled': loading }"
@tap="pickWritingDocType(item.value)"
>{{ item.label }}</view>
<text class="option-label">字数</text>
<view
v-for="item in writingWordCountOptions"
:key="item.value"
class="option-chip"
:class="{ 'option-chip--active': writingWordCount === item.value, 'option-chip--disabled': loading }"
@tap="pickWritingWordCount(item.value)"
>{{ item.label }}</view>
</view>
<view v-if="activeChatMode === 'image'" class="option-panel">
<text class="option-label">图片比例</text>
<view
v-for="item in imageRatioOptions"
:key="item.value"
class="option-chip"
:class="{ 'option-chip--active': imageRatio === item.value, 'option-chip--disabled': loading }"
@tap="pickImageRatio(item.value)"
>{{ item.label }}</view>
</view>
<view v-if="activeChatMode === 'image'" class="option-panel">
<text class="option-label">参考素材</text>
<view
class="option-chip"
:class="{ 'option-chip--disabled': !canPickImageFiles }"
@tap="chooseImageFiles"
>{{ uploadingFiles ? '上传中...' : '添加附件' }}</view>
<text class="attach-hint">
最多 {{ IMAGE_FILE_LIMIT }} 个,支持图片 / 视频 / 音频 / 文档
</text>
<view v-if="imageFiles.length" class="attach-list">
<view v-for="(item, index) in imageFiles" :key="item.id" class="attach-item">
<text class="attach-name">{{ item.name }}</text>
<view class="attach-remove" @tap="removeImageFile(index)">移除</view>
</view>
</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 {
buildWebSocketUrl,
creativeAssistant,
listAiChatHistory,
listAiChatList,
manuscriptGen,
removeAiChatList,
sendMessage as sendChatMessage,
uploadAiFile
} from './chat-service'
import {
WRITING_DOC_TYPE_OPTIONS,
WRITING_WORD_COUNT_OPTIONS,
parseWritingRequest
} from './writing-params'
import {
FALLBACK_TEXT,
FAILED_TEXT,
isDisplayableAnswer,
isFailedPayload
} 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: 'writing' },
{ label: '图片', value: 'image' }
]
/**
* 图片比例选项。
*
* 上游「AI 创意助手」开始节点**没有**比例变量,只能把它作为一行文本追加进
* `user_content`(与 web 端 `buildImagePrompt` 同一做法)。
*/
const IMAGE_RATIO_OPTIONS = [
{ label: '1:1', value: '1:1' },
{ label: '4:3', value: '4:3' },
{ label: '3:4', value: '3:4' },
{ label: '16:9', value: '16:9' },
{ label: '9:16', value: '9:16' }
]
/** 参考素材上限,与 Dify 开始节点 file-list 变量 user_files 的 max_length 一致 */
const IMAGE_FILE_LIMIT = 5
/** 单个附件大小上限(与既有 pages/creative-assistant 口径一致) */
const MAX_UPLOAD_MB = 100
const IMAGE_FILE_EXTS = ['PNG', 'JPG', 'JPEG', 'GIF', 'WEBP', 'BMP', 'SVG']
const VIDEO_FILE_EXTS = ['MP4', 'MOV', 'MPEG', 'MPGA', 'AVI', 'M4V']
const AUDIO_FILE_EXTS = ['MP3', 'M4A', 'WAV', 'AMR', 'AAC', 'OGG', 'WEBM']
export default {
data() {
return {
ASSISTANT_PENDING_TEXT,
chatModeOptions: CHAT_MODE_OPTIONS,
writingDocTypeOptions: WRITING_DOC_TYPE_OPTIONS,
writingWordCountOptions: WRITING_WORD_COUNT_OPTIONS,
imageRatioOptions: IMAGE_RATIO_OPTIONS,
IMAGE_FILE_LIMIT,
// 写作模式的显式选择:默认 auto,即由 writing-params 从用户输入里自动识别
writingDocType: 'auto',
writingWordCount: 'auto',
imageRatio: '1:1',
// 图片模式的参考素材(Dify 开始节点 file-list 变量 user_files)
imageFiles: [],
uploadingFiles: false,
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 &&
!this.uploadingFiles
)
},
canPickImageFiles() {
return !this.loading && !this.uploadingFiles && this.imageFiles.length < IMAGE_FILE_LIMIT
},
assistantTitle() {
if (this.activeChatMode === 'writing') {
return 'AI 写作助手'
}
if (this.activeChatMode === 'image') {
return 'AI 图片生成助手'
}
return 'AI 团委助手'
},
assistantHint() {
if (this.activeChatMode === 'writing') {
return '直接说需求就行,例如「帮我写一篇300字关于迎新晚会的推文,风格活泼」;也可以在下方指定文体和字数。'
}
if (this.activeChatMode === 'image') {
return '描述你要的图片,例如校园活动海报、公众号头图;还可以选择图片比例并添加参考素材。'
}
return '你可以直接提问团务流程问题,我会通过实时消息回复。'
},
inputPlaceholder() {
if (!this.currentUser.userId) {
return '请先登录后使用 AI 助手'
}
if (this.activeChatMode === 'writing') {
return '直接描述需求即可,例如:帮我写一篇300字关于迎新晚会的推文,风格活泼,面向大一新生(也可在下方直接指定文体和字数)'
}
if (this.activeChatMode === 'image') {
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)
},
/**
* 创意模式失败态文案。
*
* 后端按失败原因给固定映射:上游生成服务容量不足时是
* 「生成服务繁忙,请稍后重试」,其余仍是「生成失败,请稍后重试」。
* 用后端的提示,用户才能区分"是我的内容有问题"还是"服务这会儿忙"。
*
* 但这份映射是后端契约,端上不能无条件相信:失败报文中 `answer`
* 历史上被填成过 `workflow_run_id` 这类 UUID(后端 extractAnswer 的
* 伴生现象),所以必须再过一遍 `isDisplayableAnswer`,
* 不像给人看的就换成固定文案(issue 01 的兜底不能丢)。
*/
resolveFailedCreativeText(payload) {
const answer = payload && payload.answer
return this.isDisplayableAnswer(answer) ? String(answer).trim() : FAILED_TEXT
},
/**
* 这个 `answer` 像不像给人看的文本(UUID / 长 token 一律判否)。
*
* 判定逻辑统一在 `utils/ai-result`(issue 01 加的兜底)。
*/
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 ''
},
/**
* 写作模式入参。
*
* 优先级:**界面显式选择 > 「键:值」入参 > 自然语言推断 > 默认值**。
* 解析规则全部在 `./writing-params`(与 web 端 `writing-params.ts` 等价移植)。
*
* 过去这里没有表单,只按「键:值」解析,解析不到就一律回落
* `doc_type=新闻稿` / `Number=800`;而上游提示词把这两项当**主控指令**,
* 会直接压掉用户原话里的诉求(实测见 issue 13 / 15)。
*/
buildWritingPayload(query) {
const overrides = {}
if (this.writingDocType !== 'auto') {
overrides.docType = this.writingDocType
}
if (this.writingWordCount !== 'auto') {
overrides.number = Number(this.writingWordCount)
}
return {
...parseWritingRequest(query, overrides),
files: []
}
},
formatWritingResult(payload, query) {
if (typeof payload === 'string') {
return payload
}
// 与创意链路对齐:失败态直接给可读文案。
// 否则 answer 里的失败提示会被当成正文,渲染成「标题 + 失败文案」。
if (this.isFailedPayload(payload)) {
return FAILED_TEXT
}
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.buildWritingPayload(query).theme}\n\n${body}`
}
return this.buildFallbackText()
},
/** 图片模式:把比例与「直接出图」的要求作为文本追加进 user_content */
buildImagePrompt(query) {
return [
query,
`图片比例:${this.imageRatio}`,
'请直接生成图片,优先输出图片文件或图片链接。'
].join('\n')
},
/**
* 图片模式入参:`user_content` 一段文本 + `user_files`(Dify file-list 变量)。
*
* 附件上限 5、类型取 image/video/audio/document —— 都来自上游开始节点的变量定义。
*/
buildImagePayload(query) {
return {
user_content: this.buildImagePrompt(query),
user_files: this.imageFiles.map((item) => ({
transfer_method: 'local_file',
type: item.type,
upload_file_id: item.id,
url: ''
}))
}
},
/**
* 按 MIME / 扩展名判定 Dify file-list 要求的 type(image/video/audio/document)。
*
* `pickedType` 是 `uni.chooseMessageFile` 给的微信侧分类(image/video/file),
* 只在拿不到 MIME 与扩展名时才作为兜底依据。
*/
resolveUploadFileType(contentType, fileName, pickedType) {
const mime = String(contentType || '').toLowerCase()
if (mime.indexOf('image/') === 0) {
return 'image'
}
if (mime.indexOf('video/') === 0) {
return 'video'
}
if (mime.indexOf('audio/') === 0) {
return 'audio'
}
const ext = String(fileName || '').split('.').pop().toUpperCase()
if (IMAGE_FILE_EXTS.indexOf(ext) > -1) {
return 'image'
}
if (VIDEO_FILE_EXTS.indexOf(ext) > -1) {
return 'video'
}
if (AUDIO_FILE_EXTS.indexOf(ext) > -1) {
return 'audio'
}
if (pickedType === 'image' || pickedType === 'video') {
return pickedType
}
return 'document'
},
guessFileName(path, index) {
const segment = String(path || '').split(/[\\/]/).pop() || ''
return segment || `参考素材${index + 1}`
},
pickWritingDocType(value) {
if (this.loading) {
return
}
this.writingDocType = value
},
pickWritingWordCount(value) {
if (this.loading) {
return
}
this.writingWordCount = value
},
pickImageRatio(value) {
if (this.loading) {
return
}
this.imageRatio = value
},
/**
* 选参考素材。
*
* 小程序没有 web 那种「一个文件选择器覆盖所有类型」的能力,所以给两个来源:
* 相册 / 拍照(图片,最常用)与聊天记录里的文件(可覆盖视频 / 音频 / 文档)。
*/
chooseImageFiles() {
if (!this.canPickImageFiles) {
if (!this.loading && !this.uploadingFiles) {
this.showToast(`参考素材最多 ${IMAGE_FILE_LIMIT} 个`)
}
return
}
uni.showActionSheet({
itemList: ['从相册选择图片', '从聊天记录选择文件'],
success: (result) => {
if (result.tapIndex === 0) {
this.pickLocalImages()
return
}
this.pickMessageFiles()
}
})
},
pickLocalImages() {
const remain = IMAGE_FILE_LIMIT - this.imageFiles.length
uni.chooseImage({
count: remain > 0 ? remain : 1,
sizeType: ['original', 'compressed'],
sourceType: ['album', 'camera'],
success: (result) => {
const paths = result.tempFilePaths || []
const tempFiles = result.tempFiles || []
const picked = paths.map((path, index) => {
const info = tempFiles[index] || {}
return {
path: info.path || path,
name: this.guessFileName(info.path || path, index),
size: info.size,
type: 'image'
}
})
this.uploadPickedFiles(picked)
}
})
},
pickMessageFiles() {
const remain = IMAGE_FILE_LIMIT - this.imageFiles.length
uni.chooseMessageFile({
count: remain > 0 ? remain : 1,
type: 'all',
success: (result) => {
this.uploadPickedFiles(
(result.tempFiles || []).map((item) => ({
path: item.path,
name: item.name,
size: item.size,
type: item.type
}))
)
}
})
},
async uploadPickedFiles(files) {
const picked = (files || []).filter((item) => item && item.path)
if (!picked.length) {
return
}
const remain = IMAGE_FILE_LIMIT - this.imageFiles.length
if (remain <= 0) {
this.showToast(`参考素材最多 ${IMAGE_FILE_LIMIT} 个`)
return
}
const selected = picked.slice(0, remain)
if (picked.length > remain) {
this.showToast(`参考素材最多 ${IMAGE_FILE_LIMIT} 个,本次只上传前 ${remain} 个`)
}
this.uploadingFiles = true
try {
for (let index = 0; index < selected.length; index += 1) {
const file = selected[index]
if (file.size && file.size / 1024 / 1024 > MAX_UPLOAD_MB) {
this.showToast(`单个附件不能超过 ${MAX_UPLOAD_MB}MB`)
continue
}
// app 必须传 creativeAssistant:Dify 的 /files/upload 按应用鉴权,
// 用别的应用凭证上传的 file id,工作流会报 Invalid upload file。
const record = await uploadAiFile(file, 'creativeAssistant')
const name = record.name || file.name || this.guessFileName(file.path, index)
this.imageFiles = [
...this.imageFiles,
{
id: String(record.id),
name,
type: this.resolveUploadFileType(
record.contentType,
name,
file.type
)
}
]
}
this.showToast('参考素材已上传')
} catch (error) {
this.showToast((error && error.message) || '参考素材上传失败')
} finally {
this.uploadingFiles = false
}
},
removeImageFile(index) {
if (this.loading) {
return
}
this.imageFiles = this.imageFiles.filter((item, current) => current !== index)
},
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)。
if (this.isFailedPayload(payload)) {
return this.resolveFailedCreativeText(payload)
}
const outputs = this.getCreativeOutputs(payload)
const script = this.pickString(outputs.Script_result, outputs.script, outputs.text)
const videoMessage = this.pickString(outputs.video_message)
const photoMessage = this.pickString(outputs.photo_message)
const photoFiles = Array.isArray(outputs.photo_files)
? outputs.photo_files
: Array.isArray(outputs.photo_file)
? outputs.photo_file
: []
const imageFiles = photoFiles.filter((item) => this.isCreativeImageFile(item))
const otherPhotoFiles = photoFiles.filter((item) => imageFiles.indexOf(item) === -1)
const files = [
this.formatFileList('视频生成结果', outputs.video_result || []),
this.formatImageList('图像 / 排版结果', imageFiles),
this.formatFileList('图像 / 排版结果文件', otherPhotoFiles)
].join('')
const text = [
videoMessage,
photoMessage,
script ? `### 脚本创作结果\n${script}` : '',
files
]
.filter(Boolean)
.join('\n\n')
// 注意:这里不再回落到 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 === 'writing') {
const result = await manuscriptGen(this.buildWritingPayload(query))
setResult(this.formatWritingResult(result, query))
return
}
if (this.activeChatMode === 'image') {
const result = await creativeAssistant(this.buildImagePayload(query))
setResult(this.formatCreativeResult(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;
}
/* 写作 / 图片模式的参数面板(文体、字数、图片比例、参考素材) */
.option-panel {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12rpx;
margin-bottom: 16rpx;
}
.option-label {
margin-right: 2rpx;
font-size: 22rpx;
color: #8a7f79;
}
.option-chip {
padding: 8rpx 20rpx;
border-radius: 999rpx;
border: 1rpx solid rgba(20, 150, 242, 0.18);
background: rgba(255, 255, 255, 0.9);
color: #1496f2;
font-size: 22rpx;
line-height: 1.4;
}
.option-chip--active {
border-color: transparent;
background: linear-gradient(145deg, #0f7dd1 0%, #1496f2 100%);
color: #fff;
}
.option-chip--disabled {
opacity: 0.5;
}
.attach-hint {
width: 100%;
font-size: 20rpx;
line-height: 1.5;
color: #a59a94;
}
.attach-list {
width: 100%;
}
.attach-item {
display: flex;
align-items: center;
gap: 12rpx;
padding: 10rpx 16rpx;
border-radius: 16rpx;
background: rgba(20, 150, 242, 0.06);
}
.attach-item + .attach-item {
margin-top: 10rpx;
}
.attach-name {
flex: 1;
min-width: 0;
overflow: hidden;
font-size: 22rpx;
color: #4a5560;
text-overflow: ellipsis;
white-space: nowrap;
}
.attach-remove {
flex-shrink: 0;
padding: 6rpx 14rpx;
border-radius: 999rpx;
background: rgba(20, 150, 242, 0.1);
color: #1496f2;
font-size: 20rpx;
}
.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>