1726 lines
42 KiB
Vue
1726 lines
42 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">AI 助手</view>
|
||
<view class="navbar-sub">
|
||
{{ activeChatTitle }}<text v-if="userName"> · {{ userName }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="navbar-placeholder"></view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Spacer matching navbar height -->
|
||
<view class="navbar-spacer" :style="{ height: navbarTotalHeight + 'px' }"></view>
|
||
|
||
<view class="assistant-shell">
|
||
|
||
<scroll-view
|
||
scroll-y
|
||
scroll-with-animation
|
||
class="message-scroll"
|
||
:scroll-into-view="scrollIntoView"
|
||
>
|
||
<view v-if="!currentUser.userId" class="notice-card">
|
||
<view class="notice-title">请先登录后使用 AI 助手</view>
|
||
<view class="notice-desc">当前页面已接入真实会话逻辑,发送消息和历史会话依赖登录态。</view>
|
||
</view>
|
||
|
||
<view v-else-if="!messageList.length" class="empty-card">
|
||
<view class="empty-title">你好,我是 AI 团委助手</view>
|
||
<view class="empty-desc">
|
||
你可以直接提问团务流程问题,我会通过实时消息回复。
|
||
</view>
|
||
</view>
|
||
|
||
<view
|
||
v-for="item in messageList"
|
||
:key="item.id"
|
||
:id="item.anchorId"
|
||
class="message-row"
|
||
:class="{ 'message-row--user': isUserMessage(item) }"
|
||
>
|
||
<view class="avatar" :class="{ 'avatar--user': isUserMessage(item) }">
|
||
{{ isUserMessage(item) ? '我' : '助' }}
|
||
</view>
|
||
<view class="message-main">
|
||
<view class="bubble" :class="{ 'bubble--user': isUserMessage(item), 'bubble--pending': item.loading }">
|
||
<view
|
||
v-if="!isUserMessage(item) && shouldShowAssistantThinking(item)"
|
||
class="thinking-row"
|
||
>
|
||
<view class="thinking-dot"></view>
|
||
<text>{{ ASSISTANT_PENDING_TEXT }}</text>
|
||
</view>
|
||
<view
|
||
v-if="isUserMessage(item)"
|
||
class="bubble-text"
|
||
>{{ item.content }}</view>
|
||
<zero-markdown-view
|
||
v-else-if="hasAssistantDisplayContent(item.content)"
|
||
class="bubble-text bubble-markdown"
|
||
:markdown="getAssistantDisplayContent(item.content)"
|
||
/>
|
||
</view>
|
||
<view class="bubble-actions">
|
||
<view class="bubble-action" @tap="copyMessage(item)">复制</view>
|
||
<view
|
||
v-if="isUserMessage(item)"
|
||
class="bubble-action"
|
||
@tap="fillContent(item.content || '')"
|
||
>
|
||
重用
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view :id="bottomAnchorId" class="scroll-anchor"></view>
|
||
</scroll-view>
|
||
|
||
<view class="composer">
|
||
<view class="mode-tags">
|
||
<view
|
||
v-for="item in chatModeOptions"
|
||
:key="item.value"
|
||
class="mode-tag"
|
||
:class="{ 'mode-tag--active': activeChatMode === item.value, 'mode-tag--disabled': loading }"
|
||
@tap="switchChatMode(item.value)"
|
||
>
|
||
{{ item.label }}
|
||
</view>
|
||
</view>
|
||
<view class="composer-row">
|
||
<textarea
|
||
v-model="draft"
|
||
class="composer-input"
|
||
placeholder-style="color:#a59a94;"
|
||
:placeholder="inputPlaceholder"
|
||
:disabled="loading || !currentUser.userId"
|
||
confirm-type="send"
|
||
auto-height
|
||
maxlength="-1"
|
||
show-confirm-bar="false"
|
||
@confirm="onSend"
|
||
/>
|
||
<view
|
||
class="composer-send"
|
||
:class="{ 'composer-send--disabled': !canSend }"
|
||
@tap="onSend"
|
||
>{{ loading ? '···' : '发送' }}</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="showHistoryPanel" class="history-mask" @tap="closeHistoryPanel">
|
||
<view class="history-panel" @tap.stop>
|
||
<view class="history-head">
|
||
<view>
|
||
<view class="history-title">历史会话</view>
|
||
<view class="history-subtitle">按最近更新时间展示</view>
|
||
</view>
|
||
<view class="history-close" @tap="closeHistoryPanel">关闭</view>
|
||
</view>
|
||
|
||
<scroll-view scroll-y class="history-scroll">
|
||
<view v-if="historySections.length">
|
||
<view v-for="section in historySections" :key="section.label" class="history-section">
|
||
<view class="history-label">{{ section.label }}</view>
|
||
<view
|
||
v-for="item in section.items"
|
||
:key="getHistoryKey(item)"
|
||
class="history-item"
|
||
:class="{ 'history-item--active': activeHistoryKey === getHistoryKey(item) }"
|
||
>
|
||
<view class="history-item-main" @tap="selectChat(item)">
|
||
<view class="history-item-title">{{ getHistoryTitle(item) }}</view>
|
||
<view class="history-item-time">
|
||
{{ formatHistoryItemTime(item.createTime) }}
|
||
</view>
|
||
</view>
|
||
<view
|
||
class="history-delete"
|
||
@tap.stop="removeChat(item)"
|
||
>
|
||
{{ deletingChatId === item.id ? '删除中' : '删除' }}
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view v-else class="history-empty">暂无历史对话</view>
|
||
</scroll-view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import dayjs from '../../uni_modules/uv-ui-tools/libs/util/dayjs.js'
|
||
import {
|
||
activityOutlineGen,
|
||
buildWebSocketUrl,
|
||
creativeAssistant,
|
||
getCurrentUser,
|
||
listAiChatHistory,
|
||
listAiChatList,
|
||
manuscriptGen,
|
||
removeAiChatList,
|
||
sendMessage as sendChatMessage
|
||
} from './chat-service'
|
||
|
||
const ASSISTANT_PENDING_TEXT = '正在思考中...'
|
||
const THINK_OPEN_TAG = '<think>'
|
||
const THINK_CLOSE_TAG = '</think>'
|
||
const CHAT_MODE_OPTIONS = [
|
||
{ label: '聊天', value: 'chat' },
|
||
{ label: '稿件', value: 'manuscript' },
|
||
{ label: '创意', value: 'creative' },
|
||
{ label: '策划', value: 'outline' }
|
||
]
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
ASSISTANT_PENDING_TEXT,
|
||
chatModeOptions: CHAT_MODE_OPTIONS,
|
||
statusBarHeight: 0,
|
||
draft: '',
|
||
activeChatMode: 'chat',
|
||
loading: false,
|
||
wsConnected: false,
|
||
scrollIntoView: '',
|
||
bottomAnchorId: `chat-bottom-${Date.now()}`,
|
||
promptList: [
|
||
'帮我生成团日活动策划大纲',
|
||
'写一篇活动新闻稿开头',
|
||
'优化竞赛申报书摘要',
|
||
'整理五四评优申报材料清单'
|
||
],
|
||
messageList: [],
|
||
historyList: [],
|
||
activeChatId: null,
|
||
activeConversationId: '',
|
||
streamingMessageId: '',
|
||
showHistoryPanel: false,
|
||
deletingChatId: null,
|
||
currentUser: {
|
||
userId: null,
|
||
nickname: '',
|
||
realName: '',
|
||
token: ''
|
||
},
|
||
socketTask: null,
|
||
socketConnectPromise: null,
|
||
initialized: false
|
||
}
|
||
},
|
||
computed: {
|
||
navbarTotalHeight() {
|
||
return this.statusBarHeight + 44
|
||
},
|
||
canSend() {
|
||
return !!this.currentUser.userId && !!this.draft.trim() && !this.loading
|
||
},
|
||
inputPlaceholder() {
|
||
if (!this.currentUser.userId) {
|
||
return '请先登录后使用 AI 助手'
|
||
}
|
||
if (this.activeChatMode === 'manuscript') {
|
||
return '请输入稿件标题、活动时间地点、参与人员和亮点内容,可直接用自然语言描述'
|
||
}
|
||
if (this.activeChatMode === 'creative') {
|
||
return '请输入创意生成指令,例如短视频脚本、海报排版、推文封面等需求'
|
||
}
|
||
if (this.activeChatMode === 'outline') {
|
||
return '请输入活动策划需求,建议包含主题、受众、时间、经费、地点等信息'
|
||
}
|
||
return '说说你想问的问题'
|
||
},
|
||
userName() {
|
||
return (
|
||
this.currentUser.nickname ||
|
||
this.currentUser.realName ||
|
||
(this.currentUser.userId ? `用户${this.currentUser.userId}` : '')
|
||
)
|
||
},
|
||
chatHeaderText() {
|
||
if (this.loading) {
|
||
return this.wsConnected ? 'AI 助手在线答疑' : 'AI 助手连接中...'
|
||
}
|
||
return 'AI 助手待命中'
|
||
},
|
||
activeHistoryKey() {
|
||
if (this.activeChatId) {
|
||
return `chat-${this.activeChatId}`
|
||
}
|
||
if (this.activeConversationId) {
|
||
return `conversation-${this.activeConversationId}`
|
||
}
|
||
return ''
|
||
},
|
||
activeChatTitle() {
|
||
if (!this.activeChatId && !this.activeConversationId) {
|
||
return '新对话'
|
||
}
|
||
const target = this.historyList.find(
|
||
(item) =>
|
||
item.id === this.activeChatId ||
|
||
(item.conversationId && item.conversationId === this.activeConversationId)
|
||
)
|
||
return target ? this.getHistoryTitle(target) : '当前会话'
|
||
},
|
||
historySections() {
|
||
const groupMap = {}
|
||
;[...this.historyList]
|
||
.sort((left, right) => this.getTimestamp(right.updateTime || right.createTime) - this.getTimestamp(left.updateTime || left.createTime))
|
||
.forEach((item) => {
|
||
const label = this.getHistoryDateLabel(item.createTime || item.updateTime)
|
||
if (!groupMap[label]) {
|
||
groupMap[label] = []
|
||
}
|
||
groupMap[label].push(item)
|
||
})
|
||
console.log(Object.keys(groupMap).map((label) => ({
|
||
label,
|
||
items: groupMap[label]
|
||
})))
|
||
return Object.keys(groupMap).map((label) => ({
|
||
label,
|
||
items: groupMap[label]
|
||
}))
|
||
}
|
||
},
|
||
onLoad() {
|
||
const { statusBarHeight } = uni.getSystemInfoSync()
|
||
this.statusBarHeight = statusBarHeight || 0
|
||
},
|
||
onShow() {
|
||
this.refreshUser()
|
||
if (!this.initialized) {
|
||
this.initAssistant()
|
||
return
|
||
}
|
||
if (this.currentUser.userId) {
|
||
this.loadHistoryList(false).catch((error) => {
|
||
this.showError(error, '刷新历史会话失败')
|
||
})
|
||
}
|
||
},
|
||
onUnload() {
|
||
this.disconnectWebSocket()
|
||
},
|
||
methods: {
|
||
refreshUser() {
|
||
const storedUser = uni.getStorageSync('user_info') || {}
|
||
this.currentUser = {
|
||
userId: Number(storedUser.userId || 0) || null,
|
||
nickname: storedUser.nickname || storedUser.nickName || '',
|
||
realName: storedUser.realName || storedUser.name || '',
|
||
token: uni.getStorageSync('token') || null
|
||
}
|
||
return this.currentUser
|
||
},
|
||
async initAssistant() {
|
||
try {
|
||
this.refreshUser()
|
||
if (!this.currentUser.userId) {
|
||
return
|
||
}
|
||
await this.loadHistoryList(true)
|
||
} catch (error) {
|
||
this.showError(error, '初始化聊天页失败')
|
||
} finally {
|
||
this.initialized = true
|
||
}
|
||
},
|
||
showToast(title) {
|
||
uni.showToast({
|
||
title,
|
||
icon: 'none'
|
||
})
|
||
},
|
||
showError(error, fallback) {
|
||
this.showToast((error && error.message) || fallback)
|
||
},
|
||
ensureLogin(showToast = true) {
|
||
this.refreshUser()
|
||
if (this.currentUser.userId) {
|
||
return true
|
||
}
|
||
if (showToast) {
|
||
this.showToast('请先登录后使用 AI 助手')
|
||
}
|
||
return false
|
||
},
|
||
createMessageId(prefix) {
|
||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||
},
|
||
isUserMessage(item) {
|
||
return item.role === 'user'
|
||
},
|
||
getTimestamp(value) {
|
||
if (!value) {
|
||
return 0
|
||
}
|
||
const normalized = String(value).replace(/-/g, '/')
|
||
const result = new Date(normalized).getTime()
|
||
return Number.isNaN(result) ? 0 : result
|
||
},
|
||
getTimeLabel(value) {
|
||
const source = value ? new Date(String(value).replace(/-/g, '/')) : new Date()
|
||
if (Number.isNaN(source.getTime())) {
|
||
return ''
|
||
}
|
||
const hours = `${source.getHours()}`.padStart(2, '0')
|
||
const minutes = `${source.getMinutes()}`.padStart(2, '0')
|
||
return `${hours}:${minutes}`
|
||
},
|
||
formatHistoryItemTime(value) {
|
||
if (!value) {
|
||
return ''
|
||
}
|
||
const date = dayjs(String(value).replace(/-/g, '/'))
|
||
if (!date.isValid()) {
|
||
return String(value || '')
|
||
}
|
||
return date.format('YYYY-MM-DD HH:mm')
|
||
},
|
||
isSameDate(left, right) {
|
||
return (
|
||
left.getFullYear() === right.getFullYear() &&
|
||
left.getMonth() === right.getMonth() &&
|
||
left.getDate() === right.getDate()
|
||
)
|
||
},
|
||
getHistoryDateLabel(value) {
|
||
if (!value) {
|
||
return '更早'
|
||
}
|
||
const date = new Date(String(value).replace(/-/g, '/'))
|
||
if (Number.isNaN(date.getTime())) {
|
||
return '更早'
|
||
}
|
||
const now = new Date()
|
||
if (this.isSameDate(date, now)) {
|
||
return '今天'
|
||
}
|
||
const yesterday = new Date()
|
||
yesterday.setDate(yesterday.getDate() - 1)
|
||
if (this.isSameDate(date, yesterday)) {
|
||
return '昨天'
|
||
}
|
||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
||
},
|
||
getHistoryKey(item) {
|
||
if (item.id) {
|
||
return `chat-${item.id}`
|
||
}
|
||
return `conversation-${item.conversationId || item.createTime || this.createMessageId('history')}`
|
||
},
|
||
getHistoryTitle(item) {
|
||
const title = (item.title || '').trim()
|
||
if (title) {
|
||
return title
|
||
}
|
||
if (item.conversationId) {
|
||
return `会话 ${item.conversationId.slice(0, 8)}`
|
||
}
|
||
return '未命名对话'
|
||
},
|
||
parseAssistantContent(value) {
|
||
if (!value || value === ASSISTANT_PENDING_TEXT) {
|
||
return {
|
||
content: '',
|
||
thinking: value === ASSISTANT_PENDING_TEXT
|
||
}
|
||
}
|
||
|
||
let cursor = 0
|
||
let content = ''
|
||
let thinking = false
|
||
|
||
while (cursor < value.length) {
|
||
if (!thinking) {
|
||
const openIndex = value.indexOf(THINK_OPEN_TAG, cursor)
|
||
const closeIndex = value.indexOf(THINK_CLOSE_TAG, cursor)
|
||
|
||
if (closeIndex !== -1 && (openIndex === -1 || closeIndex < openIndex)) {
|
||
cursor = closeIndex + THINK_CLOSE_TAG.length
|
||
continue
|
||
}
|
||
if (openIndex === -1) {
|
||
content += value.slice(cursor)
|
||
break
|
||
}
|
||
content += value.slice(cursor, openIndex)
|
||
cursor = openIndex + THINK_OPEN_TAG.length
|
||
thinking = true
|
||
continue
|
||
}
|
||
|
||
const closeIndex = value.indexOf(THINK_CLOSE_TAG, cursor)
|
||
if (closeIndex === -1) {
|
||
break
|
||
}
|
||
cursor = closeIndex + THINK_CLOSE_TAG.length
|
||
thinking = false
|
||
}
|
||
|
||
return {
|
||
content,
|
||
thinking
|
||
}
|
||
},
|
||
getAssistantDisplayContent(value) {
|
||
return this.parseAssistantContent(value).content
|
||
},
|
||
hasAssistantDisplayContent(value) {
|
||
return !!this.getAssistantDisplayContent(value).trim()
|
||
},
|
||
shouldShowAssistantThinking(item) {
|
||
if (item.content === ASSISTANT_PENDING_TEXT) {
|
||
return true
|
||
}
|
||
if (!item.loading) {
|
||
return false
|
||
}
|
||
return this.parseAssistantContent(item.content).thinking
|
||
},
|
||
getMessageCopyContent(item) {
|
||
return this.isUserMessage(item)
|
||
? item.content || ''
|
||
: this.getAssistantDisplayContent(item.content).trim()
|
||
},
|
||
copyMessage(item) {
|
||
const text = this.getMessageCopyContent(item)
|
||
if (!text) {
|
||
this.showToast('暂无可复制内容')
|
||
return
|
||
}
|
||
uni.setClipboardData({
|
||
data: text,
|
||
success: () => {
|
||
this.showToast('已复制')
|
||
}
|
||
})
|
||
},
|
||
fillContent(value) {
|
||
if (this.loading) {
|
||
return
|
||
}
|
||
this.draft = value
|
||
},
|
||
switchChatMode(mode) {
|
||
if (this.loading || this.activeChatMode === mode) {
|
||
return
|
||
}
|
||
this.activeChatMode = mode
|
||
},
|
||
scrollToBottom() {
|
||
this.bottomAnchorId = `chat-bottom-${Date.now()}`
|
||
this.$nextTick(() => {
|
||
this.scrollIntoView = this.bottomAnchorId
|
||
})
|
||
},
|
||
formatHistoryMessages(list) {
|
||
const result = []
|
||
;[...(list || [])]
|
||
.sort((left, right) => this.getTimestamp(left.createTime) - this.getTimestamp(right.createTime))
|
||
.forEach((item) => {
|
||
const time = this.getTimeLabel(item.createTime)
|
||
if (item.content) {
|
||
result.push({
|
||
id: this.createMessageId('user'),
|
||
anchorId: this.createMessageId('anchor'),
|
||
role: 'user',
|
||
content: item.content,
|
||
time
|
||
})
|
||
}
|
||
if (item.reply) {
|
||
result.push({
|
||
id: this.createMessageId('assistant'),
|
||
anchorId: this.createMessageId('anchor'),
|
||
role: 'assistant',
|
||
content: item.reply,
|
||
time
|
||
})
|
||
}
|
||
})
|
||
return result
|
||
},
|
||
syncActiveChat() {
|
||
if (!this.activeConversationId) {
|
||
return
|
||
}
|
||
const current = this.historyList.find((item) => item.conversationId === this.activeConversationId)
|
||
if (current && current.id) {
|
||
this.activeChatId = current.id
|
||
}
|
||
},
|
||
appendAssistantChunk(answer, conversationId) {
|
||
if (conversationId) {
|
||
this.activeConversationId = conversationId
|
||
}
|
||
if (!this.streamingMessageId) {
|
||
const messageId = this.createMessageId('assistant')
|
||
this.streamingMessageId = messageId
|
||
this.messageList.push({
|
||
id: messageId,
|
||
anchorId: this.createMessageId('anchor'),
|
||
role: 'assistant',
|
||
content: answer,
|
||
loading: true,
|
||
time: this.getTimeLabel()
|
||
})
|
||
this.scrollToBottom()
|
||
return
|
||
}
|
||
const target = this.messageList.find((item) => item.id === this.streamingMessageId)
|
||
if (!target) {
|
||
return
|
||
}
|
||
target.content =
|
||
target.content === ASSISTANT_PENDING_TEXT ? answer : `${target.content}${answer}`
|
||
this.scrollToBottom()
|
||
},
|
||
async finishAssistantMessage(conversationId) {
|
||
if (conversationId) {
|
||
this.activeConversationId = conversationId
|
||
}
|
||
const target = this.messageList.find((item) => item.id === this.streamingMessageId)
|
||
if (target) {
|
||
target.loading = false
|
||
if (target.content === ASSISTANT_PENDING_TEXT) {
|
||
target.content = ''
|
||
}
|
||
}
|
||
this.streamingMessageId = ''
|
||
this.loading = false
|
||
await this.loadHistoryList(false)
|
||
this.syncActiveChat()
|
||
},
|
||
disconnectWebSocket() {
|
||
this.socketConnectPromise = null
|
||
this.wsConnected = false
|
||
const socketTask = this.socketTask
|
||
this.socketTask = null
|
||
if (socketTask && typeof socketTask.close === 'function') {
|
||
try {
|
||
socketTask.close({})
|
||
} catch (error) {
|
||
console.warn('socket close failed', error)
|
||
}
|
||
}
|
||
},
|
||
async handleSocketMessage(raw) {
|
||
if (!raw) {
|
||
return
|
||
}
|
||
try {
|
||
const data = JSON.parse(raw)
|
||
if (data && data.answer === '__END__') {
|
||
await this.finishAssistantMessage(data.conversationId)
|
||
this.disconnectWebSocket()
|
||
return
|
||
}
|
||
if (data && (data.loading === 'true' || data.loading === true)) {
|
||
return
|
||
}
|
||
const notice = (data && (data.notice || data.answer)) || ''
|
||
if (notice) {
|
||
this.appendAssistantChunk(String(notice), data.conversationId)
|
||
}
|
||
} catch (error) {
|
||
console.warn('websocket message parse failed', error)
|
||
this.appendAssistantChunk(String(raw))
|
||
}
|
||
},
|
||
connectWebSocket() {
|
||
const { userId } = this.refreshUser()
|
||
if (!userId) {
|
||
return Promise.reject(new Error('未获取到当前登录用户'))
|
||
}
|
||
const currentSocket = this.socketTask
|
||
if (currentSocket && this.wsConnected) {
|
||
return Promise.resolve()
|
||
}
|
||
if (currentSocket && this.socketConnectPromise) {
|
||
return this.socketConnectPromise
|
||
}
|
||
if (currentSocket) {
|
||
this.disconnectWebSocket()
|
||
}
|
||
|
||
let cleanupPending = () => undefined
|
||
const connectPromise = new Promise((resolve, reject) => {
|
||
let settled = false
|
||
const socketTask = uni.connectSocket({
|
||
url: buildWebSocketUrl(userId),
|
||
complete: () => {}
|
||
})
|
||
|
||
cleanupPending = () => {
|
||
if (this.socketConnectPromise === connectPromise) {
|
||
this.socketConnectPromise = null
|
||
}
|
||
}
|
||
|
||
socketTask.onOpen(() => {
|
||
this.wsConnected = false
|
||
})
|
||
socketTask.onMessage((event) => {
|
||
const raw = String((event && event.data) || '')
|
||
if (raw === '连接成功') {
|
||
this.wsConnected = true
|
||
if (!settled) {
|
||
settled = true
|
||
cleanupPending()
|
||
resolve()
|
||
}
|
||
return
|
||
}
|
||
this.handleSocketMessage(raw)
|
||
})
|
||
socketTask.onClose(() => {
|
||
this.wsConnected = false
|
||
if (this.socketTask === socketTask) {
|
||
this.socketTask = null
|
||
}
|
||
if (!settled) {
|
||
settled = true
|
||
cleanupPending()
|
||
reject(new Error('聊天连接已关闭'))
|
||
return
|
||
}
|
||
if (this.loading && this.streamingMessageId) {
|
||
const target = this.messageList.find((item) => item.id === this.streamingMessageId)
|
||
if (target) {
|
||
target.loading = false
|
||
if (target.content === ASSISTANT_PENDING_TEXT) {
|
||
target.content = '连接已断开,请稍后重试。'
|
||
}
|
||
}
|
||
this.streamingMessageId = ''
|
||
this.loading = false
|
||
}
|
||
cleanupPending()
|
||
})
|
||
socketTask.onError(() => {
|
||
this.wsConnected = false
|
||
if (!settled) {
|
||
settled = true
|
||
cleanupPending()
|
||
reject(new Error('聊天连接失败'))
|
||
}
|
||
})
|
||
this.socketTask = socketTask
|
||
})
|
||
|
||
this.socketConnectPromise = connectPromise
|
||
return connectPromise
|
||
},
|
||
async loadHistoryList(autoSelect = true) {
|
||
const { userId } = this.refreshUser()
|
||
if (!userId) {
|
||
this.historyList = []
|
||
return
|
||
}
|
||
const response = await listAiChatList({ userId })
|
||
this.historyList = Array.isArray(response) ? response : []
|
||
this.syncActiveChat()
|
||
if (
|
||
autoSelect &&
|
||
!this.activeChatId &&
|
||
!this.activeConversationId &&
|
||
this.historyList.length
|
||
) {
|
||
await this.selectChat(this.historyList[0], true)
|
||
}
|
||
},
|
||
async loadChatHistory(item) {
|
||
const { userId } = this.refreshUser()
|
||
const response = await listAiChatHistory({
|
||
chatId: item.id,
|
||
conversationId: item.conversationId,
|
||
userId
|
||
})
|
||
this.messageList = this.formatHistoryMessages(Array.isArray(response) ? response : [])
|
||
this.scrollToBottom()
|
||
},
|
||
async selectChat(item, silent = false) {
|
||
if (this.loading && !silent) {
|
||
this.showToast('当前正在生成回复,请稍后再切换会话')
|
||
return
|
||
}
|
||
this.activeChatId = item.id || null
|
||
this.activeConversationId = item.conversationId || ''
|
||
this.showHistoryPanel = false
|
||
await this.loadChatHistory(item)
|
||
},
|
||
startNewChat() {
|
||
if (this.loading) {
|
||
this.showToast('当前正在生成回复,请稍后再开启新对话')
|
||
return
|
||
}
|
||
this.disconnectWebSocket()
|
||
this.activeChatId = null
|
||
this.activeConversationId = ''
|
||
this.streamingMessageId = ''
|
||
this.messageList = []
|
||
this.draft = ''
|
||
this.showHistoryPanel = false
|
||
},
|
||
stringifyUnknown(value) {
|
||
try {
|
||
return JSON.stringify(value, null, 2)
|
||
} catch (error) {
|
||
return String(value ?? '')
|
||
}
|
||
},
|
||
pickString(...values) {
|
||
for (let index = 0; index < values.length; index += 1) {
|
||
const value = values[index]
|
||
if (typeof value === 'string' && value.trim()) {
|
||
return value.trim()
|
||
}
|
||
}
|
||
return ''
|
||
},
|
||
parseKeyValueInput(value) {
|
||
const result = {}
|
||
String(value || '')
|
||
.split(/\n+/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
.forEach((line) => {
|
||
const match = line.match(/^([^::]+)[::]\s*(.*)$/)
|
||
if (match) {
|
||
result[match[1].trim()] = match[2].trim()
|
||
}
|
||
})
|
||
return result
|
||
},
|
||
buildManuscriptPayload(query) {
|
||
const fields = this.parseKeyValueInput(query)
|
||
return {
|
||
docType: fields['稿件类型'] || fields['类型'] || '新闻稿',
|
||
eventType: fields['活动类型'] || '校园活动',
|
||
theme: fields['稿件标题'] || fields['标题'] || fields['主题'] || query,
|
||
time: fields['活动时间'] || fields['时间'] || '',
|
||
location: fields['活动地点'] || fields['地点'] || '',
|
||
participants: fields['参与人员'] || fields['人员'] || '',
|
||
highlights: fields['亮点内容'] || fields['亮点'] || fields['内容'] || query,
|
||
number: Number(fields['建议篇幅'] || fields['字数'] || 800),
|
||
files: []
|
||
}
|
||
},
|
||
formatManuscriptResult(payload, query) {
|
||
if (typeof payload === 'string') {
|
||
return payload
|
||
}
|
||
const data = payload || {}
|
||
const title = this.pickString(
|
||
data.title,
|
||
data.theme,
|
||
data.name,
|
||
data.subject,
|
||
data.data && data.data.title
|
||
)
|
||
const body = this.pickString(
|
||
data.content,
|
||
data.text,
|
||
data.result,
|
||
data.output,
|
||
data.answer,
|
||
data.manuscript,
|
||
data.article,
|
||
data.message,
|
||
data.data && data.data.content,
|
||
data.data && data.data.text,
|
||
data.data && data.data.result,
|
||
data.data && data.data.output,
|
||
data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content
|
||
)
|
||
if (title || body) {
|
||
return `${title || this.buildManuscriptPayload(query).theme}\n\n${body}`
|
||
}
|
||
return this.stringifyUnknown(payload)
|
||
},
|
||
buildOutlinePayload(query) {
|
||
const fields = this.parseKeyValueInput(query)
|
||
return {
|
||
event_theme: fields['活动主题'] || fields['主题'] || query,
|
||
people: fields['面向受众'] || fields['受众'] || fields['参与人员'] || '团员青年',
|
||
event_time: fields['活动时间'] || fields['时间'] || '待定',
|
||
event_funding: fields['活动资金'] || fields['资金'] || fields['经费'] || '待定',
|
||
event_type: fields['活动形式'] || fields['形式'] || '',
|
||
event_location: fields['活动地点'] || fields['地点'] || '',
|
||
event_meme: fields['备注'] || fields['补充'] || query
|
||
}
|
||
},
|
||
formatOutlineResult(payload) {
|
||
const data = payload || {}
|
||
return (
|
||
this.pickString(
|
||
data.answer,
|
||
data.raw && data.raw.answer,
|
||
data.raw && data.raw.data && data.raw.data.answer,
|
||
data.raw && data.raw.output,
|
||
data.raw && data.raw.result,
|
||
data.raw && data.raw.content,
|
||
data.raw && data.raw.message
|
||
) || this.stringifyUnknown(payload)
|
||
)
|
||
},
|
||
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) {
|
||
const outputs = this.getCreativeOutputs(payload)
|
||
const script = this.pickString(outputs.Script_result, outputs.script, outputs.text)
|
||
const videoMessage = this.pickString(outputs.video_message)
|
||
const photoMessage = this.pickString(outputs.photo_message)
|
||
const photoFiles = Array.isArray(outputs.photo_files)
|
||
? outputs.photo_files
|
||
: Array.isArray(outputs.photo_file)
|
||
? outputs.photo_file
|
||
: []
|
||
const imageFiles = photoFiles.filter((item) => this.isCreativeImageFile(item))
|
||
const otherPhotoFiles = photoFiles.filter((item) => imageFiles.indexOf(item) === -1)
|
||
const files = [
|
||
this.formatFileList('视频生成结果', outputs.video_result || []),
|
||
this.formatImageList('图像 / 排版结果', imageFiles),
|
||
this.formatFileList('图像 / 排版结果文件', otherPhotoFiles)
|
||
].join('')
|
||
const text = [
|
||
videoMessage,
|
||
photoMessage,
|
||
script ? `### 脚本创作结果\n${script}` : '',
|
||
files
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n\n')
|
||
return text || this.stringifyUnknown(payload)
|
||
},
|
||
async sendModeMessage(query, assistantId) {
|
||
const target = this.messageList.find((item) => item.id === assistantId)
|
||
const setResult = (value) => {
|
||
if (target) {
|
||
target.content = value || '接口已返回,但未解析到内容。'
|
||
target.loading = false
|
||
}
|
||
}
|
||
if (this.activeChatMode === 'manuscript') {
|
||
const result = await manuscriptGen(this.buildManuscriptPayload(query))
|
||
setResult(this.formatManuscriptResult(result, query))
|
||
return
|
||
}
|
||
if (this.activeChatMode === 'creative') {
|
||
const result = await creativeAssistant({
|
||
user_content: query,
|
||
user_contentString: query,
|
||
user_files: []
|
||
})
|
||
setResult(this.formatCreativeResult(result))
|
||
return
|
||
}
|
||
if (this.activeChatMode === 'outline') {
|
||
const result = await activityOutlineGen(this.buildOutlinePayload(query))
|
||
setResult(this.formatOutlineResult(result))
|
||
}
|
||
},
|
||
openHistoryPanel() {
|
||
this.showHistoryPanel = true
|
||
if (this.currentUser.userId) {
|
||
this.loadHistoryList(false).catch((error) => {
|
||
this.showError(error, '加载历史会话失败')
|
||
})
|
||
}
|
||
},
|
||
closeHistoryPanel() {
|
||
this.showHistoryPanel = false
|
||
},
|
||
async removeChat(item) {
|
||
if (this.loading) {
|
||
this.showToast('当前正在生成回复,请稍后再删除会话')
|
||
return
|
||
}
|
||
if (!item.id) {
|
||
this.showToast('当前会话缺少标识,无法删除')
|
||
return
|
||
}
|
||
|
||
const result = await new Promise((resolve) => {
|
||
uni.showModal({
|
||
title: '删除会话',
|
||
content: `确定删除“${this.getHistoryTitle(item)}”吗?`,
|
||
success: resolve,
|
||
fail: () => resolve({ confirm: false })
|
||
})
|
||
})
|
||
if (!result.confirm) {
|
||
return
|
||
}
|
||
|
||
this.deletingChatId = item.id
|
||
try {
|
||
const isActive =
|
||
this.activeChatId === item.id ||
|
||
(item.conversationId && this.activeConversationId === item.conversationId)
|
||
await removeAiChatList(item.id)
|
||
await this.loadHistoryList(false)
|
||
if (isActive) {
|
||
const nextChat = this.historyList[0]
|
||
if (nextChat) {
|
||
this.activeChatId = nextChat.id || null
|
||
this.activeConversationId = nextChat.conversationId || ''
|
||
await this.loadChatHistory(nextChat)
|
||
} else {
|
||
this.startNewChat()
|
||
}
|
||
}
|
||
this.showToast('删除成功')
|
||
} catch (error) {
|
||
this.showError(error, '删除失败')
|
||
} finally {
|
||
this.deletingChatId = null
|
||
}
|
||
},
|
||
usePrompt(text) {
|
||
this.fillContent(text)
|
||
},
|
||
async onSend() {
|
||
if (!this.canSend) {
|
||
if (!this.currentUser.userId) {
|
||
this.ensureLogin(true)
|
||
}
|
||
return
|
||
}
|
||
const query = this.draft.trim()
|
||
this.loading = true
|
||
try {
|
||
const assistantId = this.createMessageId('assistant')
|
||
const userTime = this.getTimeLabel()
|
||
this.messageList.push({
|
||
id: this.createMessageId('user'),
|
||
anchorId: this.createMessageId('anchor'),
|
||
role: 'user',
|
||
content: query,
|
||
time: userTime
|
||
})
|
||
this.messageList.push({
|
||
id: assistantId,
|
||
anchorId: this.createMessageId('anchor'),
|
||
role: 'assistant',
|
||
content: ASSISTANT_PENDING_TEXT,
|
||
loading: true,
|
||
time: this.getTimeLabel()
|
||
})
|
||
this.streamingMessageId = assistantId
|
||
this.draft = ''
|
||
this.scrollToBottom()
|
||
if (this.activeChatMode === 'chat') {
|
||
await this.connectWebSocket()
|
||
await sendChatMessage({
|
||
query,
|
||
conversationId: this.activeConversationId || undefined
|
||
})
|
||
return
|
||
}
|
||
await this.sendModeMessage(query, assistantId)
|
||
this.streamingMessageId = ''
|
||
this.loading = false
|
||
this.scrollToBottom()
|
||
} catch (error) {
|
||
const target = this.streamingMessageId
|
||
? this.messageList.find((item) => item.id === this.streamingMessageId)
|
||
: null
|
||
if (target) {
|
||
target.loading = false
|
||
target.content = '出错了,请稍后再试。'
|
||
}
|
||
this.streamingMessageId = ''
|
||
this.loading = false
|
||
this.disconnectWebSocket()
|
||
this.showError(error, '发送失败')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.page {
|
||
height: 100vh;
|
||
padding: 0 24rpx 24rpx;
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background:
|
||
radial-gradient(circle at top left, rgba(20, 150, 242, 0.1), transparent 26%),
|
||
//linear-gradient(180deg, #f9f4ef 0%, #f4ede7 100%);
|
||
rgba(20, 150, 242, 0.1)
|
||
}
|
||
|
||
.custom-navbar {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 100;
|
||
overflow: hidden;
|
||
background: linear-gradient(180deg, #f7fbff 0%, #d9f0ff 44%, #f4fbff 100%);
|
||
box-shadow: 0 10rpx 28rpx rgba(65, 105, 135, 0.12);
|
||
}
|
||
|
||
.hero-bg {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
filter: blur(8rpx);
|
||
opacity: 0.9;
|
||
}
|
||
|
||
.hero-bg-left {
|
||
top: 84rpx;
|
||
left: -30rpx;
|
||
width: 300rpx;
|
||
height: 200rpx;
|
||
background: radial-gradient(circle, rgba(197, 237, 255, 0.95) 0%, rgba(197, 237, 255, 0) 72%);
|
||
}
|
||
|
||
.hero-bg-right {
|
||
top: 46rpx;
|
||
right: -40rpx;
|
||
width: 360rpx;
|
||
height: 260rpx;
|
||
background: radial-gradient(circle, rgba(184, 231, 255, 0.92) 0%, rgba(184, 231, 255, 0) 72%);
|
||
}
|
||
|
||
.navbar {
|
||
position: relative;
|
||
z-index: 2;
|
||
padding-left: 28rpx;
|
||
padding-right: 20rpx;
|
||
}
|
||
|
||
.navbar-inner {
|
||
display: flex;
|
||
align-items: center;
|
||
height: 88rpx;
|
||
justify-content: center;
|
||
position: relative;
|
||
}
|
||
|
||
.navbar-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
position: absolute;
|
||
left: 0;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
}
|
||
|
||
.nav-icon-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 72rpx;
|
||
height: 72rpx;
|
||
border-radius: 24rpx;
|
||
background: rgba(255, 255, 255, 0.72);
|
||
box-shadow: 0 8rpx 18rpx rgba(115, 164, 196, 0.14);
|
||
}
|
||
|
||
/* List / history icon */
|
||
.icon-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 7rpx;
|
||
width: 38rpx;
|
||
}
|
||
|
||
.icon-list-line {
|
||
height: 4rpx;
|
||
border-radius: 4rpx;
|
||
background: #101214;
|
||
}
|
||
|
||
.icon-list-line--sm {
|
||
width: 60%;
|
||
}
|
||
|
||
/* Plus / new-chat icon */
|
||
.icon-compose {
|
||
position: relative;
|
||
width: 36rpx;
|
||
height: 36rpx;
|
||
}
|
||
|
||
.icon-compose-h {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 0;
|
||
right: 0;
|
||
height: 4rpx;
|
||
border-radius: 4rpx;
|
||
background: #101214;
|
||
transform: translateY(-50%);
|
||
}
|
||
|
||
.icon-compose-v {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
left: 50%;
|
||
width: 4rpx;
|
||
border-radius: 4rpx;
|
||
background: #101214;
|
||
transform: translateX(-50%);
|
||
}
|
||
|
||
.navbar-center {
|
||
min-width: 0;
|
||
text-align: center;
|
||
padding: 0 168rpx;
|
||
}
|
||
|
||
.navbar-title {
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
color: #101214;
|
||
line-height: 1.3;
|
||
letter-spacing: 2rpx;
|
||
}
|
||
|
||
.navbar-sub {
|
||
font-size: 22rpx;
|
||
color: #5f6570;
|
||
margin-top: 2rpx;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.navbar-placeholder {
|
||
width: 152rpx;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.navbar-spacer {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.assistant-shell {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20rpx;
|
||
}
|
||
|
||
.message-scroll {
|
||
flex: 1;
|
||
min-height: 0;
|
||
padding: 4rpx 2rpx 0;
|
||
}
|
||
|
||
.prompt-panel,
|
||
.empty-card,
|
||
.notice-card {
|
||
padding: 24rpx;
|
||
border-radius: 28rpx;
|
||
background: rgba(255, 255, 255, 0.92);
|
||
box-shadow: 0 10rpx 28rpx rgba(76, 49, 35, 0.08);
|
||
}
|
||
|
||
.prompt-title,
|
||
.notice-title,
|
||
.empty-title {
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
color: #23272f;
|
||
}
|
||
|
||
.prompt-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 16rpx;
|
||
margin-top: 18rpx;
|
||
}
|
||
|
||
.prompt-chip {
|
||
padding: 14rpx 22rpx;
|
||
border-radius: 999rpx;
|
||
background: #fbebea;
|
||
color: #1496f2;
|
||
font-size: 24rpx;
|
||
}
|
||
|
||
.notice-card,
|
||
.empty-card {
|
||
margin-top: 22rpx;
|
||
}
|
||
|
||
.notice-desc,
|
||
.empty-desc {
|
||
margin-top: 12rpx;
|
||
font-size: 24rpx;
|
||
line-height: 1.7;
|
||
color: #7d726d;
|
||
}
|
||
|
||
.message-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
margin-top: 22rpx;
|
||
}
|
||
|
||
.message-row--user {
|
||
flex-direction: row-reverse;
|
||
}
|
||
|
||
.avatar {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 68rpx;
|
||
height: 68rpx;
|
||
border-radius: 22rpx;
|
||
background: rgba(20, 150, 242, 0.12);
|
||
color: #1496f2;
|
||
font-size: 26rpx;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.avatar--user {
|
||
background: linear-gradient(145deg, #0f7dd1 0%, #1496f2 100%);
|
||
color: #fff;
|
||
}
|
||
|
||
.message-main {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
max-width: calc(100% - 96rpx);
|
||
margin-left: 16rpx;
|
||
}
|
||
|
||
.message-row--user .message-main {
|
||
align-items: flex-end;
|
||
margin-left: 0;
|
||
margin-right: 16rpx;
|
||
}
|
||
|
||
.bubble {
|
||
max-width: 100%;
|
||
padding: 22rpx 24rpx;
|
||
border-radius: 26rpx 26rpx 26rpx 8rpx;
|
||
background: rgba(255, 255, 255, 0.92);
|
||
box-shadow: 0 10rpx 28rpx rgba(76, 49, 35, 0.08);
|
||
}
|
||
|
||
.bubble--user {
|
||
border-radius: 26rpx 26rpx 8rpx 26rpx;
|
||
background: linear-gradient(145deg, #0f7dd1 0%, #1496f2 100%);
|
||
}
|
||
|
||
.bubble--pending {
|
||
border: 1rpx solid rgba(240, 122, 61, 0.22);
|
||
}
|
||
|
||
.bubble-text {
|
||
font-size: 28rpx;
|
||
line-height: 1.75;
|
||
color: #27303a;
|
||
word-break: break-all;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.bubble--user .bubble-text,
|
||
.bubble--user .bubble-time {
|
||
color: #fff;
|
||
}
|
||
|
||
.bubble-time {
|
||
margin-top: 12rpx;
|
||
font-size: 20rpx;
|
||
color: #918781;
|
||
}
|
||
|
||
.thinking-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
margin-bottom: 10rpx;
|
||
font-size: 24rpx;
|
||
color: #c1652d;
|
||
}
|
||
|
||
.thinking-dot {
|
||
width: 16rpx;
|
||
height: 16rpx;
|
||
border-radius: 50%;
|
||
background: #f07a3d;
|
||
box-shadow: 0 0 0 10rpx rgba(240, 122, 61, 0.12);
|
||
}
|
||
|
||
.bubble-actions {
|
||
display: flex;
|
||
gap: 14rpx;
|
||
margin-top: 12rpx;
|
||
}
|
||
|
||
.bubble-action {
|
||
padding: 8rpx 16rpx;
|
||
border-radius: 999rpx;
|
||
background: rgba(20, 150, 242, 0.08);
|
||
color: #1496f2;
|
||
font-size: 22rpx;
|
||
}
|
||
|
||
.composer {
|
||
padding: 16rpx 16rpx 16rpx 24rpx;
|
||
border-radius: 30rpx;
|
||
background: rgba(255, 255, 255, 0.96);
|
||
box-shadow: 0 12rpx 30rpx rgba(76, 49, 35, 0.08);
|
||
}
|
||
|
||
.mode-tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 14rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.mode-tag {
|
||
padding: 10rpx 22rpx;
|
||
border-radius: 999rpx;
|
||
background: rgba(20, 150, 242, 0.08);
|
||
color: #1496f2;
|
||
font-size: 24rpx;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.mode-tag--active {
|
||
background: linear-gradient(145deg, #0f7dd1 0%, #1496f2 100%);
|
||
color: #fff;
|
||
box-shadow: 0 10rpx 20rpx rgba(20, 150, 242, 0.18);
|
||
}
|
||
|
||
.mode-tag--disabled {
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.composer-row {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
.composer-input {
|
||
flex: 1;
|
||
min-height: 72rpx;
|
||
max-height: 240rpx;
|
||
padding: 18rpx 0;
|
||
font-size: 28rpx;
|
||
line-height: 1.6;
|
||
color: #27303a;
|
||
}
|
||
|
||
.composer-send {
|
||
flex-shrink: 0;
|
||
height: 72rpx;
|
||
padding: 0 30rpx;
|
||
border-radius: 20rpx;
|
||
background: linear-gradient(145deg, #0f7dd1 0%, #1496f2 100%);
|
||
color: #fff;
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.composer-send--disabled {
|
||
background: #d8cfcb;
|
||
}
|
||
|
||
.history-mask {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 99;
|
||
background: rgba(44, 28, 23, 0.36);
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.history-panel {
|
||
width: 84%;
|
||
height: 100vh;
|
||
padding: 30rpx 24rpx 24rpx;
|
||
background: #fff7f4;
|
||
box-shadow: -12rpx 0 30rpx rgba(44, 28, 23, 0.12);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.history-head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.history-title {
|
||
font-size: 32rpx;
|
||
font-weight: 700;
|
||
color: #2b2c31;
|
||
}
|
||
|
||
.history-subtitle {
|
||
margin-top: 10rpx;
|
||
font-size: 22rpx;
|
||
color: #8a7f79;
|
||
}
|
||
|
||
.history-close {
|
||
padding: 10rpx 18rpx;
|
||
border-radius: 18rpx;
|
||
background: rgba(20, 150, 242, 0.08);
|
||
color: #1496f2;
|
||
font-size: 24rpx;
|
||
}
|
||
|
||
.history-scroll {
|
||
flex: 1;
|
||
min-height: 0;
|
||
margin-top: 24rpx;
|
||
}
|
||
|
||
.history-section + .history-section {
|
||
margin-top: 24rpx;
|
||
}
|
||
|
||
.history-label {
|
||
margin-bottom: 14rpx;
|
||
padding-left: 6rpx;
|
||
font-size: 22rpx;
|
||
color: #8b817b;
|
||
}
|
||
|
||
.history-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
padding: 20rpx;
|
||
border-radius: 24rpx;
|
||
background: rgba(255, 255, 255, 0.92);
|
||
border: 1rpx solid rgba(20, 150, 242, 0.06);
|
||
box-shadow: 0 10rpx 28rpx rgba(76, 49, 35, 0.06);
|
||
}
|
||
|
||
.history-item + .history-item {
|
||
margin-top: 14rpx;
|
||
}
|
||
|
||
.history-item--active {
|
||
border-color: rgba(20, 150, 242, 0.2);
|
||
background: #fff1ec;
|
||
}
|
||
|
||
.history-item-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.history-item-title {
|
||
font-size: 26rpx;
|
||
font-weight: 700;
|
||
color: #2f3136;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.history-item-time {
|
||
margin-top: 10rpx;
|
||
font-size: 22rpx;
|
||
color: #8e837d;
|
||
}
|
||
|
||
.history-delete {
|
||
flex-shrink: 0;
|
||
padding: 10rpx 16rpx;
|
||
border-radius: 18rpx;
|
||
background: rgba(20, 150, 242, 0.08);
|
||
color: #1496f2;
|
||
font-size: 22rpx;
|
||
}
|
||
|
||
.history-empty {
|
||
padding: 40rpx 20rpx;
|
||
text-align: center;
|
||
font-size: 24rpx;
|
||
color: #978b84;
|
||
}
|
||
|
||
.scroll-anchor {
|
||
height: 2rpx;
|
||
}
|
||
|
||
/* Markdown rendered inside assistant bubbles */
|
||
.bubble-markdown {
|
||
display: block;
|
||
font-size: 28rpx;
|
||
line-height: 1.75;
|
||
color: #27303a;
|
||
word-break: break-all;
|
||
|
||
p { margin: 0 0 16rpx; }
|
||
p:last-child { margin-bottom: 0; }
|
||
|
||
h1, h2, h3, h4, h5, h6 {
|
||
font-weight: 700;
|
||
margin: 20rpx 0 10rpx;
|
||
color: #1a1f27;
|
||
}
|
||
h1 { font-size: 38rpx; }
|
||
h2 { font-size: 34rpx; }
|
||
h3 { font-size: 30rpx; }
|
||
|
||
ul, ol {
|
||
padding-left: 36rpx;
|
||
margin: 10rpx 0;
|
||
}
|
||
li { margin-bottom: 8rpx; }
|
||
|
||
code {
|
||
display: inline;
|
||
padding: 2rpx 10rpx;
|
||
border-radius: 8rpx;
|
||
background: rgba(20, 150, 242, 0.08);
|
||
color: #1496f2;
|
||
font-size: 24rpx;
|
||
font-family: monospace;
|
||
}
|
||
|
||
pre {
|
||
padding: 22rpx;
|
||
border-radius: 16rpx;
|
||
background: #f3ede8;
|
||
overflow-x: auto;
|
||
margin: 16rpx 0;
|
||
}
|
||
pre code {
|
||
padding: 0;
|
||
background: transparent;
|
||
color: #3d2e27;
|
||
font-size: 24rpx;
|
||
white-space: pre;
|
||
}
|
||
|
||
blockquote {
|
||
margin: 12rpx 0;
|
||
padding: 10rpx 20rpx;
|
||
border-left: 6rpx solid rgba(20, 150, 242, 0.3);
|
||
color: #6b5f59;
|
||
background: rgba(20, 150, 242, 0.04);
|
||
border-radius: 0 12rpx 12rpx 0;
|
||
}
|
||
|
||
hr {
|
||
margin: 20rpx 0;
|
||
border: none;
|
||
border-top: 1rpx solid rgba(20, 150, 242, 0.15);
|
||
}
|
||
|
||
strong { font-weight: 700; }
|
||
em { font-style: italic; }
|
||
}
|
||
</style>
|