244f0c24d8
模式标签由「聊天/稿件/创意/策划」改为「聊天/写作/图片」,去掉策划链路; 写作与图片两个入口补上 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
2113 lines
54 KiB
Vue
2113 lines
54 KiB
Vue
<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 ``
|
||
})
|
||
.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>
|