XieXing 6 days ago
parent
commit
e59a2dc6eb

+ 44 - 22
miniprogram/api/message.ts

@@ -79,23 +79,45 @@ const printText = (callback: Function = () => {}) => {
 						if (resultData.message.contentType.value == 'card') {
 							let MessageContent = JSON.parse(resultData.message.content);
 							let messageData = JSON.parse(MessageContent.data ?? '{}');
-							let cardList = [];
-							if (typeof messageData.variables == 'object') {
-								if (!messageData.variables) {
-									Object.keys(messageData.variables).forEach(key => {
-										let itemContent = messageData.variables[key];
-										if (itemContent.defaultValue.source) {
-											cardList = cardList.concat(itemContent.defaultValue.source);
-										}
-									});
-								}
+							let cardList: any[] = [];
+							if (typeof messageData.variables == 'object' && messageData.variables) {
+								Object.keys(messageData.variables).forEach(key => {
+									let itemContent = messageData.variables[key];
+									if (itemContent.defaultValue && itemContent.defaultValue.source) {
+										cardList = cardList.concat(itemContent.defaultValue.source);
+									}
+								});
 							}
 							if (cardList.length) {
 								callback({
-									type: 'messageCompleted',
+									type: 'source',
 									value: cardList
 								});
 							}
+						} else if (resultData.message.contentType.value == 'text') {
+							// 处理 Coze 返回的 resources 数据(网络搜索结果)
+							if (isJSON(resultData.message.content)) {
+								let MessageContent = JSON.parse(resultData.message.content);
+								let cardList: any[] = [];
+								if (Array.isArray(MessageContent) && MessageContent.length) {
+									MessageContent.forEach((itemContent: any) => {
+										if (itemContent.resource == 'net') {
+											cardList.push({
+												logo_url: itemContent.icon ?? '',
+												title: itemContent.title ?? '',
+												summary: itemContent.summary ?? '',
+												url: itemContent.url ?? ''
+											});
+										}
+									});
+								}
+								if (cardList.length) {
+									callback({
+										type: 'source',
+										value: cardList
+									});
+								}
+							}
 						}
 						break;
 					case 'conversation.chat.completed':
@@ -132,7 +154,7 @@ const connetSSE = async (message: string, conversationId: string, callback: Func
 			url: API_BASE_URL + '/v1/chat/sendMsg',
 			enableChunked: true, // 启用分块传输
 			data: params,
-			timeout: 20000,
+			timeout: 600000,
 			header: {
 				'Content-Type': 'text/event-stream;charset=utf-8', // 根据服务器要求设置请求头
 				'h-timestamp': hTimestamp,
@@ -160,16 +182,16 @@ const connetSSE = async (message: string, conversationId: string, callback: Func
 			},
 			fail: (err) => {
 				console.error('SSE请求失败', err);
-				let message = '';
-				if (err.errMsg == 'request:fail timeout') {
-					message = '网络请求超时,请稍后再试...';
-				} else {
-					message = '网络请求异常,请稍后再试:' + err.errMsg;
-				}
-				uni.showToast({
-					title: message,
-					icon: 'none'
-				});
+				// let message = '';
+				// if (err.errMsg == 'request:fail timeout') {
+				// 	message = '网络请求超时,请稍后再试...';
+				// } else {
+				// 	message = '网络请求异常,请稍后再试:' + err.errMsg;
+				// }
+				// uni.showToast({
+				// 	title: message,
+				// 	icon: 'none'
+				// });
 				stopSSE();
 				callback({
 					type: 'done',

+ 209 - 0
miniprogram/components/SsDisclaimer.vue

@@ -0,0 +1,209 @@
+<template>
+	<!-- 透明遮罩层,点击后弹出免责声明 -->
+	<view v-if="showOverlay" class="disclaimer-overlay" @tap="handleOverlayTap"></view>
+
+	<!-- 免责声明弹窗 -->
+	<view v-if="visible" class="disclaimer-modal">
+		<view class="disclaimer-mask"></view>
+		<view class="disclaimer-content">
+			<view class="disclaimer-title">免责声明</view>
+			<scroll-view class="disclaimer-body" scroll-y :show-scrollbar="false">
+				<view class="disclaimer-text">
+					<view class="disclaimer-item">1、四川商务智能体(商小川)运行期间,若回答与事实有所出入,最终解释权归四川省商务厅(以下简称商务厅)所有。智能体回复内容不代表商务厅官方立场,任何截屏、转发不具备法律效力,商务厅不对此承担任何责任。</view>
+					<view class="disclaimer-item">2、当智能体以链接形式推荐其他网站内容时,任何因使用从此类网站或资源上获取的内容、服务而造成的任何损失,商务厅网站不对此承担任何责任。</view>
+					<view class="disclaimer-item">3、用户上传、发表、转载于智能体上的信息仅代表用户或作者个人观点,若侵犯了第三方的知识产权或其他合法权利,责任由用户本人承担,商务厅不对此承担任何责任。</view>
+				</view>
+			</scroll-view>
+			<view class="disclaimer-footer">
+				<button class="btn-cancel" @tap="handleCancel">不同意</button>
+				<button class="btn-confirm" @tap="handleConfirm">同意并继续</button>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+const DISCLAIMER_KEY = 'ss_disclaimer_accepted'
+
+export default {
+	name: 'SsDisclaimer',
+	data() {
+		return {
+			visible: false,
+			showOverlay: false,
+			hasAccepted: false
+		}
+	},
+	mounted() {
+		this.checkAccepted()
+	},
+	methods: {
+		checkAccepted() {
+			const accepted = uni.getStorageSync(DISCLAIMER_KEY)
+			if (accepted === 'true') {
+				this.hasAccepted = true
+				this.showOverlay = false
+			} else {
+				this.showOverlay = true
+			}
+		},
+		handleOverlayTap() {
+			this.visible = true
+		},
+		handleConfirm() {
+			uni.setStorageSync(DISCLAIMER_KEY, 'true')
+			this.hasAccepted = true
+			this.showOverlay = false
+			this.visible = false
+		},
+		handleCancel() {
+			// 小程序无法直接跳转外部链接,显示提示并关闭小程序
+			uni.showModal({
+				title: '提示',
+				content: '您需要同意免责声明才能使用本服务,点击确定将退出小程序',
+				showCancel: false,
+				success: () => {
+					// 退出小程序
+					uni.exitMiniProgram({
+						fail: () => {
+							// 如果退出失败,返回上一页或关闭当前页面
+							uni.navigateBack({
+								fail: () => {
+									// 都失败则重新显示弹窗
+									this.visible = true
+								}
+							})
+						}
+					})
+				}
+			})
+		}
+	}
+}
+</script>
+
+<style scoped lang="scss">
+.disclaimer-overlay {
+	position: fixed;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	z-index: 9998;
+	background: transparent;
+}
+
+.disclaimer-modal {
+	position: fixed;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	z-index: 9999;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+
+	.disclaimer-mask {
+		position: absolute;
+		top: 0;
+		left: 0;
+		right: 0;
+		bottom: 0;
+		background: rgba(0, 0, 0, 0.5);
+	}
+
+	.disclaimer-content {
+		position: relative;
+		width: 600rpx;
+		max-height: 80vh;
+		margin: 0 auto;
+		background: #ffffff;
+		border-radius: 24rpx;
+		overflow: hidden;
+		box-sizing: border-box;
+		display: flex;
+		flex-direction: column;
+
+		.disclaimer-title {
+			flex-shrink: 0;
+			padding: 32rpx;
+			text-align: center;
+			font-size: 36rpx;
+			font-weight: bold;
+			color: #333;
+			border-bottom: 1rpx solid #eee;
+			box-sizing: border-box;
+		}
+
+		.disclaimer-body {
+			flex: 1;
+			min-height: 0;
+			height: 500rpx;
+			box-sizing: border-box;
+			overflow: hidden;
+
+			.disclaimer-text {
+				padding: 32rpx;
+
+				.disclaimer-item {
+					font-size: 28rpx;
+					color: #333;
+					line-height: 1.8;
+					margin-bottom: 24rpx;
+					text-align: justify;
+					word-break: break-all;
+
+					&:last-child {
+						margin-bottom: 0;
+					}
+				}
+			}
+
+			// 隐藏滚动条
+			::-webkit-scrollbar {
+				display: none;
+				width: 0;
+				height: 0;
+				background: transparent;
+			}
+		}
+
+		.disclaimer-footer {
+			flex-shrink: 0;
+			display: flex;
+			padding: 24rpx 20rpx 32rpx;
+			box-sizing: border-box;
+			background: #ffffff;
+			border-top: 1rpx solid #eee;
+
+			button {
+				flex: 1;
+				height: 80rpx !important;
+				min-height: 80rpx !important;
+				max-height: 80rpx !important;
+				line-height: 80rpx !important;
+				font-size: 28rpx;
+				border-radius: 40rpx;
+				margin: 0 12rpx;
+				padding: 0 !important;
+				overflow: hidden;
+
+				&::after {
+					display: none;
+				}
+			}
+
+			.btn-cancel {
+				background: #f5f5f5;
+				color: #666;
+			}
+
+			.btn-confirm {
+				background: #2943d6;
+				color: #ffffff;
+			}
+		}
+	}
+}
+</style>

+ 3 - 7
miniprogram/components/SsInputBox.vue

@@ -159,6 +159,7 @@ export default {
 					case 'text':
 						this.globalStore.replyChatList(result.value);
 						break;
+					case 'source':
 					case 'messageCompleted':
 						this.globalStore.replyChatListSource(result.value);
 						break;
@@ -223,7 +224,7 @@ export default {
 }
 
 .input-box {
-	margin-bottom: 64rpx;
+	padding-bottom: 20rpx;
 
 	.input-container {
 		margin: 0 auto;
@@ -347,12 +348,7 @@ export default {
 	}
 
 	&.spread {
-		margin-bottom: 300rpx;
-		// position: fixed;
-		// z-index: 100;
-		// bottom: 16rpx;
-		// bottom: calc(env(safe-area-inset-bottom) + 16rpx);
-		// bottom: calc(constant(safe-area-inset-bottom) + 16rpx);
+		padding-bottom: 20rpx;
 
 		.input-container {
 			display: block;

+ 15 - 5
miniprogram/components/SsRecording.vue

@@ -1,8 +1,9 @@
 <template>
-	<view class="ss-recording" @tap="clickMicBtn">
-		<uni-icons :type="micIcon" size="24" color="#101333"></uni-icons>
-	</view>
-	<uni-popup ref="recordPopup" background-color="#fff" :is-mask-click="false">
+	<view class="ss-recording-wrapper">
+		<view class="ss-recording" @tap="clickMicBtn">
+			<uni-icons :type="micIcon" size="24" color="#101333"></uni-icons>
+		</view>
+		<uni-popup ref="recordPopup" background-color="#fff" :is-mask-click="false">
 		<view class="record-dialog">
 			<view class="record-title">录音将在{{ countDown }}秒后自动结束</view>
 			<view class="record-animation" :class="'show-bg-' + decibel">
@@ -24,7 +25,8 @@
 				</button>
 			</view>
 		</view>
-	</uni-popup>
+		</uni-popup>
+	</view>
 </template>
 
 <script>
@@ -197,8 +199,16 @@ export default {
 	}
 }
 
+.ss-recording-wrapper {
+	display: inline-flex;
+	align-items: center;
+}
+
 .ss-recording {
+	display: inline-flex;
+	align-items: center;
 }
+
 .record-dialog {
 	background-color: #ffffff;
 	overflow: hidden;

+ 0 - 3
miniprogram/components/ss_chat/SsChat.vue

@@ -10,9 +10,6 @@
 					@quick-send="quickSend" @change-chat="changeChat" />
 			</template>
 		</view>
-		<view v-if="globalStore.chatLoading" class="loading">
-			<uni-load-more iconType="circle" :show-text="false" status="loading" color="#7E8AA2" :icon-size="12" />
-		</view>
 		<view id="scrollBottom" class="scroll-bottom"></view>
 	</scroll-view>
 </template>

+ 237 - 183
miniprogram/components/ss_chat/child/SsChatReply.vue

@@ -1,16 +1,71 @@
 <template>
 	<view class="ss-chat-reply">
-		<view class="reply-header">
-			<view class="reply-robot">
-				<image class="replay-avatar" mode="aspectFill" :src="reply.avatar" />
-				<view class="reply-title" :class="{ big: reply.type == 'category' }">{{ reply.title }}</view>
+		<!-- 网络搜索结果 -->
+		<view v-if="reply.source && reply.source.length" class="quota" :class="{unfold: unfold}">
+			<view class="quota-intro">
+				<view class="quota-small" @tap="toggleUnfold">
+					<view class="quota-title">基于{{ reply.source.length }}个搜索来源</view>
+					<view class="quota-brand">
+						<template v-for="(item, index) in reply.source">
+							<image v-if="index < 6" class="quota-brand-logo" mode="aspectFill" :src="getLogoUrl(item)" />
+						</template>
+					</view>
+				</view>
+				<view class="quota-detail">
+					<view class="quota-line">
+						<view class="line-left">✓</view>
+						<view class="line-content">
+							<view class="line-title">理解问题</view>
+						</view>
+						<view class="quota-close" @tap="toggleUnfold">▲</view>
+					</view>
+					<view class="quota-line">
+						<view class="line-left">✓</view>
+						<view class="line-content">
+							<view class="line-title">搜索网页</view>
+							<view class="line-box flex">
+								<view class="search-text">{{ reply.title }}</view>
+							</view>
+						</view>
+					</view>
+					<view class="quota-line">
+						<view class="line-left">✓</view>
+						<view class="line-content">
+							<view class="line-title">找到{{ reply.source.length }}个搜索来源</view>
+						</view>
+					</view>
+					<scroll-view class="source-scroll" scroll-x :scroll-with-animation="true">
+						<view class="source">
+							<view v-for="(item, index) in reply.source" :key="index" class="source-item" @tap.stop="copyLink(item.url)">
+								<view class="source-title">{{ item.title }}</view>
+								<view class="source-footer">
+									<view class="source-brand">
+										<image class="source-icon" mode="aspectFill" :src="getLogoUrl(item)" />
+										<view class="source-summary">{{ item.summary }}</view>
+									</view>
+									<view class="source-number">{{ index + 1 }}</view>
+								</view>
+							</view>
+						</view>
+					</scroll-view>
+				</view>
 			</view>
 		</view>
-		<view class="replay-container">
-			<ss-chat-reply-markdown v-if="reply.type == 'text'" :chat-id="reply.chatId" :content="reply.content"
-				:last="last" />
-			<ss-chat-reply-category v-else-if="reply.type == 'category'" :category-id="reply.content"
-				@quick-send="quickSend" @change-chat="changeChat" />
+		<!-- 回复内容 -->
+		<view class="reply-content">
+			<!-- Loading 动画:三个点交替跃动 -->
+			<view v-if="last && showLoading" class="typing-indicator">
+				<view class="dot"></view>
+				<view class="dot"></view>
+				<view class="dot"></view>
+			</view>
+			<!-- 内容区域 -->
+			<view v-else class="replay-container">
+				<ss-chat-reply-markdown v-if="reply.type == 'text'" :chat-id="reply.chatId" :content="reply.content"
+					:title="reply.title" :last="last" />
+				<ss-chat-reply-category v-else-if="reply.type == 'category'" :category-id="reply.content"
+					@quick-send="quickSend" @change-chat="changeChat" />
+			</view>
 		</view>
 	</view>
 </template>
@@ -19,6 +74,8 @@
 	import SsChatReplyMarkdown from '@/components/ss_chat/child/SsChatReplyMarkdown.vue';
 	import SsChatReplyCategory from '@/components/ss_chat/child/SsChatReplyCategory.vue';
 
+	import { useGlobalStore } from '@/stores/global';
+
 	export default {
 		name: 'SsChatReply',
 		components: {
@@ -44,19 +101,40 @@
 		},
 		data() {
 			return {
-				unfold: false
+				unfold: false,
+				globalStore: useGlobalStore()
+			}
+		},
+		computed: {
+			showLoading() {
+				return this.globalStore.chatLoading && this.reply.type === 'text' && !this.reply.content
 			}
 		},
 		methods: {
-			getLogoUrl(url) {
+			getLogoUrl(item) {
+				// 兼容 logo_url 和 icon 两种字段名
+				let url = item.logo_url || item.icon || '';
 				if (url == '') {
-					url = this.getStaticImageUrl('/images/unlink.png');
+					url = '/static/images/unlink.png';
 				}
 				return url;
 			},
 			toggleUnfold() {
 				this.unfold = !this.unfold;
 			},
+			copyLink(url) {
+				if (url) {
+					uni.setClipboardData({
+						data: url,
+						success: () => {
+							uni.showToast({
+								title: '链接已复制',
+								icon: 'none'
+							});
+						}
+					});
+				}
+			},
 			quickSend(msg) {
 				this.$emit('quickSend', msg)
 			},
@@ -70,19 +148,13 @@
 <style scoped lang="scss">
 	.ss-chat-reply {
 		.quota {
-			display: flex;
 			margin-bottom: 32rpx;
 
 			.quota-intro {
-				display: flex;
-				cursor: pointer;
-				overflow: hidden;
-				align-items: center;
 				border-radius: 32rpx;
-				transition: width 0.2s linear;
 
 				.quota-small {
-					display: flex;
+					display: inline-flex;
 					align-items: center;
 					border-radius: 32rpx;
 					padding: 16rpx 32rpx;
@@ -118,174 +190,142 @@
 
 			&.unfold {
 				.quota-intro {
-					padding: 16rpx 16rpx 0 16rpx;
-					border: 0;
+					padding: 24rpx;
+					border-radius: 16rpx;
 					background-color: #f6f7fb;
 
 					.quota-small {
 						display: none;
 					}
-				}
 
-				.quota-detail {
-					display: block;
-					width: 100%;
+					.quota-detail {
+						display: block;
+
+						.source-scroll {
+							width: 100%;
+							white-space: nowrap;
+
+							.source {
+								display: inline-block;
+								white-space: nowrap;
+								padding-bottom: 16rpx;
+
+								.source-item {
+									display: inline-block;
+									vertical-align: top;
+									width: 400rpx;
+									border: 1px solid #d5d6d8;
+									border-radius: 16rpx;
+									background-color: #ffffff;
+									padding: 20rpx;
+									margin-right: 16rpx;
+
+									.source-title {
+										color: #101333;
+										font-size: 28rpx;
+										line-height: 36rpx;
+										font-weight: bold;
+										margin-bottom: 16rpx;
+										overflow: hidden;
+										display: -webkit-box;
+										-webkit-box-orient: vertical;
+										-webkit-line-clamp: 2;
+										text-overflow: ellipsis;
+										white-space: normal;
+										height: 72rpx;
+									}
 
-					.quota-line {
-						display: flex;
-						margin-bottom: 32rpx;
+									.source-footer {
+										display: flex;
+										align-items: center;
 
-						.line-left {
-							width: 52rpx;
+										.source-brand {
+											flex: 1;
+											display: flex;
+											align-items: center;
+											overflow: hidden;
 
-							.line-left-icon {
-								width: 32rpx;
-								height: 32rpx;
-								color: #545764;
-							}
-						}
+											.source-icon {
+												width: 32rpx;
+												height: 32rpx;
+												border-radius: 16rpx;
+												margin-right: 8rpx;
+												flex-shrink: 0;
+											}
 
-						.quota-close {
-							background-color: transparent;
-							border: unset;
-							border-radius: 0;
-							height: 32rpx;
-							line-height: 32rpx;
-							padding-top: 12rpx;
+											.source-summary {
+												flex: 1;
+												color: #545764;
+												font-size: 24rpx;
+												line-height: 32rpx;
+												height: 64rpx;
+												overflow: hidden;
+												display: -webkit-box;
+												-webkit-box-orient: vertical;
+												-webkit-line-clamp: 2;
+												text-overflow: ellipsis;
+												white-space: normal;
+											}
+										}
 
-							&::after {
-								display: none;
+										.source-number {
+											color: #ffffff;
+											font-size: 22rpx;
+											font-weight: bold;
+											text-align: center;
+											background-color: #2943d6;
+											width: 32rpx;
+											height: 32rpx;
+											line-height: 32rpx;
+											border-radius: 16rpx;
+											margin-left: 8rpx;
+											flex-shrink: 0;
+										}
+									}
+								}
 							}
 						}
 
-						.line-content {
-							width: calc(100% - 51rpx);
+						.quota-line {
+							display: flex;
+							margin-bottom: 24rpx;
 
-							.line-title {
-								color: #545764;
+							.line-left {
+								width: 40rpx;
+								color: #2943d6;
 								font-size: 28rpx;
-								line-height: 28rpx;
-								margin: 16rpx;
 							}
 
-							.line-box {
-								&.flex {
-									display: flex;
-									flex-wrap: wrap;
-								}
+							.line-content {
+								flex: 1;
 
-								&.modal {
-									position: relative;
-
-									&:before,
-									&::before,
-									&:after,
-									&::after {
-										content: '';
-										position: absolute;
-										top: 0;
-										bottom: 0;
-										width: 64rpx;
-										z-index: 20;
-									}
-
-									&:before,
-									&::before {
-										left: 0;
-										background: linear-gradient(90deg, rgb(244, 245, 249), rgba(255, 255, 255, 0));
-									}
-
-									&:after,
-									&::after {
-										right: 0;
-										background: linear-gradient(270deg, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0));
-									}
-								}
-
-								.search-text {
+								.line-title {
 									color: #545764;
 									font-size: 28rpx;
-									padding: 16rpx 32rpx;
-									border-radius: 8rpx;
-									background-color: #eaedfb;
-								}
-
-								.source-scroll {
-									width: 100%;
+									line-height: 40rpx;
+									margin-bottom: 16rpx;
 								}
 
-								.source {
+								.line-box.flex {
 									display: flex;
-									margin: 0 32rpx;
-
-									.source-item {
-										border: 1px solid #d5d6d8;
-										border-radius: 16rpx;
-										background-color: #ffffff;
-										padding: 24rpx;
-										flex-shrink: 0;
-										margin: 0 16rpx;
-
-										.source-title {
-											color: #101333;
-											font-size: 30rpx;
-											line-height: 30rpx;
-											font-weight: bold;
-											margin-bottom: 40rpx;
-											white-space: nowrap;
-											overflow: hidden;
-											text-overflow: ellipsis;
-											-o-text-overflow: ellipsis;
-										}
-
-										.source-footer {
-											display: flex;
-											align-items: center;
-
-											.source-brand {
-												width: calc(100% - 38rpx);
-												color: #545764;
-												font-size: 28rpx;
-												line-height: 28rpx;
-												display: flex;
-												align-items: center;
-
-												.source-icon {
-													border-radius: 32rpx;
-													width: 32rpx;
-													height: 32rpx;
-													margin-right: 8rpx;
-													overflow: hidden;
-												}
-
-												.source-summary {
-													width: 400rpx;
-													line-height: 30rpx;
-													/* 设置行高 */
-													height: 60rpx;
-													/* 行高 × 行数 = 2 × 1.5em */
-													overflow: hidden;
-													display: -webkit-box;
-													-webkit-box-orient: vertical;
-													-webkit-line-clamp: 2;
-													text-overflow: ellipsis;
-												}
-											}
+									flex-wrap: wrap;
 
-											.source-number {
-												color: #ffffff;
-												font-size: 24rpx;
-												font-weight: bold;
-												line-height: 29rpx;
-												text-align: center;
-												background-color: #2943d6;
-												width: 32rpx;
-												height: 32rpx;
-												border-radius: 32rpx;
-											}
-										}
+									.search-text {
+										color: #545764;
+										font-size: 26rpx;
+										padding: 12rpx 24rpx;
+										border-radius: 8rpx;
+										background-color: #eaedfb;
 									}
 								}
+
+							}
+
+							.quota-close {
+								width: 40rpx;
+								color: #545764;
+								font-size: 24rpx;
+								text-align: center;
 							}
 						}
 					}
@@ -293,38 +333,52 @@
 			}
 		}
 
-		.reply-header {
-			display: flex;
-			align-items: center;
-			justify-content: space-between;
-			margin-bottom: 28rpx;
+		.reply-content {
+			width: 100%;
 
-			.reply-robot {
-				display: flex;
+			.typing-indicator {
+				display: inline-flex;
 				align-items: center;
+				gap: 8rpx;
+				padding: 16rpx 24rpx;
+				background-color: #f5f5f5;
+				border-radius: 24rpx;
+
+				.dot {
+					width: 12rpx;
+					height: 12rpx;
+					background-color: #2942D4;
+					border-radius: 50%;
+					animation: bounce 1.4s infinite ease-in-out both;
+
+					&:nth-child(1) {
+						animation-delay: -0.32s;
+					}
 
-				.replay-avatar {
-					width: 48rpx;
-					height: 48rpx;
-					margin-right: 16rpx;
-				}
-
-				.reply-title {
-					font-size: 28rpx;
-					line-height: 28rpx;
-					color: #101333;
-					font-weight: 400;
+					&:nth-child(2) {
+						animation-delay: -0.16s;
+					}
 
-					&.big {
-						font-size: 46rpx;
-						font-weight: 500;
+					&:nth-child(3) {
+						animation-delay: 0s;
 					}
 				}
 			}
 		}
 
-		.replay-container {
-			padding-left: 64rpx;
+		@keyframes bounce {
+
+			0%,
+			80%,
+			100% {
+				transform: translateY(0);
+				opacity: 0.4;
+			}
+
+			40% {
+				transform: translateY(-10rpx);
+				opacity: 1;
+			}
 		}
 	}
 </style>

+ 4 - 0
miniprogram/components/ss_chat/child/SsChatReplyMarkdown.vue

@@ -48,6 +48,10 @@
 				type: String,
 				default: ''
 			},
+			title: {
+				type: String,
+				default: ''
+			},
 			last: {
 				type: Boolean,
 				default: false

+ 52 - 437
miniprogram/components/ua-markdown/ua-markdown.vue

@@ -15,7 +15,6 @@
 <script setup>
 	import {
 		ref,
-		computed,
 		watch
 	} from 'vue'
 	import MarkdownIt from './lib/markdown-it.min.js'
@@ -68,17 +67,14 @@
 		if (clearLinkTmpTimeout.value) {
 			clearTimeout(clearLinkTmpTimeout.value);
 		}
-		// 提取单独的链接
-		let matches = parsedContent.value.matchAll(linkRegex);
-		if (matches.length > 0) {
-			for (const match of matches) {
-				console.log(match);
-				// 存储链接信息
-				linkMaps.value.push({
-					text: match[2],
-					href: match[1].replace(/&amp;/g, "&")
-				});
-			}
+		// 提取单独的链接(使用 exec 循环代替 matchAll,兼容小程序环境)
+		const linkRegex2 = /<a[^>]+href="([^"]+)"[^>]*>(.*?)<\/a>/gi;
+		let match;
+		while ((match = linkRegex2.exec(parsedContent.value)) !== null) {
+			linkMaps.value.push({
+				text: match[2],
+				href: match[1].replace(/&amp;/g, "&")
+			});
 		}
 		clearLinkTmpTimeout.value = setTimeout(() => {
 			linkTmp.value = '';
@@ -160,12 +156,43 @@
 		} else {
 			htmlString = markdown.render(value)
 		}
-		// 解决小程序表格边框型失效问题
-		htmlString = htmlString.replace(/<table/g, `<table class="table"`)
-		htmlString = htmlString.replace(/<tr/g, `<tr class="tr"`)
-		htmlString = htmlString.replace(/<th>/g, `<th class="th">`)
-		htmlString = htmlString.replace(/<td/g, `<td class="td"`)
-		htmlString = htmlString.replace(/<hr>|<hr\/>|<hr \/>/g, `<hr class="hr">`)
+		
+		// 小程序 rich-text 不继承外部CSS,必须使用内联样式
+		// 表格样式
+		htmlString = htmlString.replace(/<table>/g, `<table style="border-collapse:collapse;width:100%;margin:0.8em 0;border:1px solid #e5e5e5;">`)
+		htmlString = htmlString.replace(/<tr>/g, `<tr style="border-top:1px solid #e5e5e5;">`)
+		htmlString = htmlString.replace(/<th>/g, `<th style="padding:6px 13px;border:1px solid #e5e5e5;font-weight:600;background-color:#f5f5f5;">`)
+		htmlString = htmlString.replace(/<td>/g, `<td style="padding:6px 13px;border:1px solid #e5e5e5;">`)
+		htmlString = htmlString.replace(/<hr>|<hr\/>|<hr \/>/g, `<hr style="margin:1em 0;border:0;border-top:1px solid #e5e5e5;">`)
+		
+		// 列表样式
+		htmlString = htmlString.replace(/<ul>/g, `<ul style="padding-left:20px;margin:0.5em 0;list-style-type:disc;">`)
+		htmlString = htmlString.replace(/<ol>/g, `<ol style="padding-left:20px;margin:0.5em 0;list-style-type:decimal;">`)
+		htmlString = htmlString.replace(/<li>/g, `<li style="line-height:1.6;margin-bottom:0.2em;">`)
+		
+		// 标题样式
+		htmlString = htmlString.replace(/<h1>/g, `<h1 style="font-size:1.8em;margin:1em 0 0.5em;font-weight:bold;line-height:1.3;">`)
+		htmlString = htmlString.replace(/<h2>/g, `<h2 style="font-size:1.5em;margin:1em 0 0.5em;font-weight:bold;line-height:1.3;">`)
+		htmlString = htmlString.replace(/<h3>/g, `<h3 style="font-size:1.25em;margin:0.8em 0 0.4em;font-weight:bold;line-height:1.3;">`)
+		htmlString = htmlString.replace(/<h4>/g, `<h4 style="font-size:1.1em;margin:0.8em 0 0.4em;font-weight:bold;line-height:1.3;">`)
+		htmlString = htmlString.replace(/<h5>/g, `<h5 style="font-size:1em;margin:0.6em 0 0.3em;font-weight:bold;line-height:1.3;">`)
+		htmlString = htmlString.replace(/<h6>/g, `<h6 style="font-size:0.9em;margin:0.6em 0 0.3em;font-weight:bold;line-height:1.3;">`)
+		
+		// 段落样式
+		htmlString = htmlString.replace(/<p>/g, `<p style="margin:0.5em 0;line-height:1.6;">`)
+		
+		// 引用块样式
+		htmlString = htmlString.replace(/<blockquote>/g, `<blockquote style="padding:10px 15px;margin:0.8em 0;border-left:4px solid #ddd;color:#666;background:#f9f9f9;">`)
+		
+		// 行内代码样式
+		htmlString = htmlString.replace(/<code>(?![\s\S]*<\/code>[\s\S]*<code>)/g, `<code style="padding:2px 4px;font-size:90%;color:#c7254e;background-color:#f9f2f4;border-radius:3px;font-family:monospace;">`)
+		
+		// 链接样式
+		htmlString = htmlString.replace(/<a /g, `<a style="color:#3498db;text-decoration:underline;" `)
+		
+		// 强调样式
+		htmlString = htmlString.replace(/<strong>/g, `<strong style="font-weight:bold;">`)
+		htmlString = htmlString.replace(/<em>/g, `<em style="font-style:italic;">`)
 
 		// #ifndef APP-NVUE
 		return htmlString
@@ -243,430 +270,18 @@
 	});
 </script>
 
-<style lang="scss" scoped>
+<style lang="scss">
+	/* 小程序 rich-text 不继承外部CSS,内部元素样式通过内联方式在 parseNodes 中处理 */
+	/* 此处仅保留容器样式和非 rich-text 内的元素样式 */
 	.ua__markdown {
 		font-size: 14px;
-		line-height: 1.5;
+		line-height: 1.6;
 		word-break: break-all;
-
-		h1,
-		h2,
-		h3,
-		h4,
-		h5,
-		h6 {
-			font-family: inherit;
-			font-weight: 500;
-			line-height: 1.1;
-			color: inherit;
-		}
-
-		h1,
-		h2,
-		h3 {
-			margin-top: 20px;
-			margin-bottom: 10px
-		}
-
-		h4,
-		h5,
-		h6 {
-			margin-top: 10px;
-			margin-bottom: 10px
-		}
-
-		.h1,
-		h1 {
-			font-size: 36px
-		}
-
-		.h2,
-		h2 {
-			font-size: 30px
-		}
-
-		.h3,
-		h3 {
-			font-size: 24px
-		}
-
-		.h4,
-		h4 {
-			font-size: 18px
-		}
-
-		.h5,
-		h5 {
-			font-size: 14px
-		}
-
-		.h6,
-		h6 {
-			font-size: 12px
-		}
-
-		a {
-			background-color: transparent;
-			color: #2196f3;
-			text-decoration: none;
-			/* 添加点击效果样式 */
-			cursor: pointer;
-		}
-
-		/* 鼠标悬停时的样式 */
-		a:hover {
-			text-decoration: underline;
-		}
-
-		hr,
-		::v-deep .hr {
-			margin-top: 20px;
-			margin-bottom: 20px;
-			border: 0;
-			border-top: 1px solid #e5e5e5;
-		}
-
-		img {
-			max-width: 35%;
-		}
-
-		p {
-			margin: 0 0 10px
-		}
-
-		em {
-			font-style: italic;
-			font-weight: inherit;
-		}
-
-		ol,
-		ul {
-			margin-top: 0;
-			margin-bottom: 10px;
-			padding-left: 40px;
-		}
-
-		ol ol,
-		ol ul,
-		ul ol,
-		ul ul {
-			margin-bottom: 0;
-		}
-
-		ol ol,
-		ul ol {
-			list-style-type: lower-roman;
-		}
-
-		ol ol ol,
-		ul ul ol {
-			list-style-type: lower-alpha;
-		}
-
-		dl {
-			margin-top: 0;
-			margin-bottom: 20px;
-		}
-
-		dt {
-			font-weight: 600;
-		}
-
-		dt,
-		dd {
-			line-height: 1.4;
-		}
-
-		.task-list-item {
-			list-style-type: none;
-		}
-
-		.task-list-item input {
-			margin: 0 .2em .25em -1.6em;
-			vertical-align: middle;
-		}
-
-		pre {
-			position: relative;
-			z-index: 11;
-		}
-
-		code,
-		kbd,
-		pre,
-		samp {
-			font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
-		}
-
-		code:not(.hljs) {
-			padding: 2px 4px;
-			font-size: 90%;
-			color: #c7254e;
-			background-color: #ffe7ee;
-			border-radius: 4px;
-		}
-
-		code:empty {
-			display: none;
-		}
-
-		pre code.hljs {
-			color: var(--vg__text-1);
-			border-radius: 16px;
-			background: var(--vg__bg-1);
-			font-size: 12px;
-		}
-
-		.markdown-wrap {
-			font-size: 12px;
-			margin-bottom: 10px;
-		}
-
-		pre.code-block-wrapper {
-			background: #2b2b2b;
-			color: #f8f8f2;
-			border-radius: 4px;
-			overflow-x: auto;
-			padding: 1em;
-			position: relative;
-		}
-
-		pre.code-block-wrapper code {
-			padding: auto;
-			font-size: inherit;
-			color: inherit;
-			background-color: inherit;
-			border-radius: 0;
-		}
-
-		.code-block-header__copy {
-			font-size: 16px;
-			margin-left: 5px;
-		}
-
-		abbr[data-original-title],
-		abbr[title] {
-			cursor: help;
-			border-bottom: 1px dotted #777;
-		}
-
-		blockquote {
-			padding: 10px 20px;
-			margin: 0 0 20px;
-			font-size: 17.5px;
-			border-left: 5px solid #e5e5e5;
-		}
-
-		blockquote ol:last-child,
-		blockquote p:last-child,
-		blockquote ul:last-child {
-			margin-bottom: 0
-		}
-
-		blockquote .small,
-		blockquote footer,
-		blockquote small {
-			display: block;
-			font-size: 80%;
-			line-height: 1.42857143;
-			color: #777
-		}
-
-		blockquote .small:before,
-		blockquote footer:before,
-		blockquote small:before {
-			content: '\2014 \00A0'
-		}
-
-		.blockquote-reverse,
-		blockquote.pull-right {
-			padding-right: 15px;
-			padding-left: 0;
-			text-align: right;
-			border-right: 5px solid #eee;
-			border-left: 0
-		}
-
-		.blockquote-reverse .small:before,
-		.blockquote-reverse footer:before,
-		.blockquote-reverse small:before,
-		blockquote.pull-right .small:before,
-		blockquote.pull-right footer:before,
-		blockquote.pull-right small:before {
-			content: ''
-		}
-
-		.blockquote-reverse .small:after,
-		.blockquote-reverse footer:after,
-		.blockquote-reverse small:after,
-		blockquote.pull-right .small:after,
-		blockquote.pull-right footer:after,
-		blockquote.pull-right small:after {
-			content: '\00A0 \2014'
-		}
-
-		.footnotes {
-			-moz-column-count: 2;
-			-webkit-column-count: 2;
-			column-count: 2
-		}
-
-		.footnotes-list {
-			padding-left: 2em
-		}
-
-		table,
-		::v-deep .table {
-			border-spacing: 0;
-			border-collapse: collapse;
-			width: 100%;
-			max-width: 65em;
-			overflow: auto;
-			margin-top: 0;
-			margin-bottom: 16px;
-		}
-
-		table tr,
-		::v-deep .table .tr {
-			border-top: 1px solid #e5e5e5;
-		}
-
-		table th,
-		table td,
-		::v-deep .table .th,
-		::v-deep .table .td {
-			padding: 6px 13px;
-			border: 1px solid #e5e5e5;
-		}
-
-		table th,
-		::v-deep .table .th {
-			font-weight: 600;
-			background-color: #eee;
-		}
-
-		.hljs[class*=language-]:before {
-			position: absolute;
-			z-index: 3;
-			top: .8em;
-			right: 1em;
-			font-size: .8em;
-			color: #999;
-		}
-
-		.hljs[class~=language-js]:before {
-			content: "js"
-		}
-
-		.hljs[class~=language-ts]:before {
-			content: "ts"
-		}
-
-		.hljs[class~=language-html]:before {
-			content: "html"
-		}
-
-		.hljs[class~=language-md]:before {
-			content: "md"
-		}
-
-		.hljs[class~=language-vue]:before {
-			content: "vue"
-		}
-
-		.hljs[class~=language-css]:before {
-			content: "css"
-		}
-
-		.hljs[class~=language-sass]:before {
-			content: "sass"
-		}
-
-		.hljs[class~=language-scss]:before {
-			content: "scss"
-		}
-
-		.hljs[class~=language-less]:before {
-			content: "less"
-		}
-
-		.hljs[class~=language-stylus]:before {
-			content: "stylus"
-		}
-
-		.hljs[class~=language-go]:before {
-			content: "go"
-		}
-
-		.hljs[class~=language-java]:before {
-			content: "java"
-		}
-
-		.hljs[class~=language-c]:before {
-			content: "c"
-		}
-
-		.hljs[class~=language-sh]:before {
-			content: "sh"
-		}
-
-		.hljs[class~=language-yaml]:before {
-			content: "yaml"
-		}
-
-		.hljs[class~=language-py]:before {
-			content: "py"
-		}
-
-		.hljs[class~=language-docker]:before {
-			content: "docker"
-		}
-
-		.hljs[class~=language-dockerfile]:before {
-			content: "dockerfile"
-		}
-
-		.hljs[class~=language-makefile]:before {
-			content: "makefile"
-		}
-
-		.hljs[class~=language-javascript]:before {
-			content: "js"
-		}
-
-		.hljs[class~=language-typescript]:before {
-			content: "ts"
-		}
-
-		.hljs[class~=language-markup]:before {
-			content: "html"
-		}
-
-		.hljs[class~=language-markdown]:before {
-			content: "md"
-		}
-
-		.hljs[class~=language-json]:before {
-			content: "json"
-		}
-
-		.hljs[class~=language-ruby]:before {
-			content: "rb"
-		}
-
-		.hljs[class~=language-python]:before {
-			content: "py"
-		}
-
-		.hljs[class~=language-bash]:before {
-			content: "sh"
-		}
-
-		.hljs[class~=language-php]:before {
-			content: "php"
-		}
+		color: #333;
 
 		.links {
+			margin-top: 16rpx;
+			
 			.links-title {
 				color: #101333;
 				margin-bottom: 8rpx;

+ 1 - 1
miniprogram/manifest.json

@@ -1,6 +1,6 @@
 {
     "name" : "miniprogram",
-    "appid" : "__UNI__96E0065",
+    "appid" : "__UNI__0E180D7",
     "description" : "商小川",
     "versionName" : "1.0.0",
     "versionCode" : "100",

+ 6 - 4
miniprogram/pages/index/index.vue

@@ -33,6 +33,7 @@
 			</view>
 		</view>
 		<image class="background-image" mode="aspectFill" :src="getStaticImageUrl('/images/bg-page.png')"></image>
+		<ss-disclaimer />
 	</view>
 </template>
 
@@ -45,6 +46,7 @@ import SsService from '@/components/SsService.vue';
 import SsCommonProblem from '@/components/SsCommonProblem.vue';
 import SsGridEntrance from '@/components/SsGridEntrance.vue';
 import SsChat from '@/components/ss_chat/SsChat.vue';
+import SsDisclaimer from '@/components/SsDisclaimer.vue';
 
 import { useGlobalStore } from '@/stores/global';
 import { watch } from 'vue';
@@ -58,7 +60,8 @@ export default {
 		SsService,
 		SsCommonProblem,
 		SsGridEntrance,
-		SsChat
+		SsChat,
+		SsDisclaimer
 	},
 	data() {
 		return {
@@ -181,10 +184,8 @@ export default {
 		top: 0;
 		left: 0;
 		right: 0;
+		bottom: 0;
 		z-index: 100;
-		bottom: 16rpx;
-		bottom: calc(env(safe-area-inset-bottom) + 16rpx);
-		bottom: calc(constant(safe-area-inset-bottom) + 16rpx);
 
 		.main-body {
 			position: relative;
@@ -202,6 +203,7 @@ export default {
 
 				.scroll-content {
 					padding: 32rpx;
+					padding-bottom: 64rpx;
 
 					.ss-row {
 						display: flex;

+ 2 - 0
web/src/App.vue

@@ -2,6 +2,7 @@
 import { RouterView } from 'vue-router'
 import {onMounted} from 'vue'
 import {useGlobalStore} from "@/stores/global"
+import SsDisclaimer from '@/components/SsDisclaimer.vue'
 
 const global = useGlobalStore()
 
@@ -13,6 +14,7 @@ onMounted(() => {
 
 <template>
   <RouterView />
+  <SsDisclaimer />
 </template>
 
 <style scoped lang="scss">

+ 148 - 0
web/src/components/SsDisclaimer.vue

@@ -0,0 +1,148 @@
+<script setup lang="ts">
+import { ref, onMounted, onUnmounted } from 'vue'
+import { ElDialog, ElButton } from 'element-plus'
+
+const DISCLAIMER_KEY = 'ss_disclaimer_accepted'
+const REDIRECT_URL = 'https://swt.sc.gov.cn/'
+
+const visible = ref(false)
+const hasAccepted = ref(false)
+let pendingEvent: MouseEvent | null = null
+
+// 检查是否已接受免责声明
+const checkAccepted = (): boolean => {
+  return localStorage.getItem(DISCLAIMER_KEY) === 'true'
+}
+
+// 在捕获阶段拦截点击事件
+const handleCapture = (e: MouseEvent) => {
+  if (hasAccepted.value) return
+
+  // 检查点击是否在弹窗内,如果是则不拦截
+  const target = e.target as HTMLElement
+  if (target.closest('.el-dialog') || target.closest('.el-overlay')) {
+    return
+  }
+
+  // 阻止事件继续传播和默认行为
+  e.stopPropagation()
+  e.preventDefault()
+
+  // 保存被拦截的事件信息
+  pendingEvent = e
+
+  // 显示免责声明弹窗
+  visible.value = true
+}
+
+// 重新触发被拦截的事件
+const replayEvent = () => {
+  if (pendingEvent) {
+    const target = pendingEvent.target as HTMLElement
+    if (target) {
+      // 创建并派发新的点击事件
+      const newEvent = new MouseEvent('click', {
+        bubbles: true,
+        cancelable: true,
+        view: window
+      })
+      target.dispatchEvent(newEvent)
+    }
+    pendingEvent = null
+  }
+}
+
+// 用户确认
+const handleConfirm = () => {
+  localStorage.setItem(DISCLAIMER_KEY, 'true')
+  hasAccepted.value = true
+  visible.value = false
+
+  // 确认后重新触发之前被拦截的事件
+  setTimeout(() => {
+    replayEvent()
+  }, 100)
+}
+
+// 用户取消或关闭弹窗
+const handleCancel = () => {
+  window.location.href = REDIRECT_URL
+}
+
+onMounted(() => {
+  if (checkAccepted()) {
+    hasAccepted.value = true
+  } else {
+    // 在捕获阶段拦截所有点击事件
+    document.addEventListener('click', handleCapture, true)
+  }
+})
+
+onUnmounted(() => {
+  document.removeEventListener('click', handleCapture, true)
+})
+</script>
+
+<template>
+  <ElDialog
+    v-model="visible"
+    title="免责声明"
+    width="600px"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    :show-close="false"
+    center
+    class="ss-disclaimer-dialog"
+  >
+    <div class="disclaimer-content">
+      <p>1、四川商务智能体(商小川)运行期间,若回答与事实有所出入,最终解释权归四川省商务厅(以下简称商务厅)所有。智能体回复内容不代表商务厅官方立场,任何截屏、转发不具备法律效力,商务厅不对此承担任何责任。</p>
+      <p>2、当智能体以链接形式推荐其他网站内容时,任何因使用从此类网站或资源上获取的内容、服务而造成的任何损失,商务厅网站不对此承担任何责任。</p>
+      <p>3、用户上传、发表、转载于智能体上的信息仅代表用户或作者个人观点,若侵犯了第三方的知识产权或其他合法权利,责任由用户本人承担,商务厅不对此承担任何责任。</p>
+    </div>
+    <template #footer>
+      <div class="dialog-footer">
+        <ElButton @click="handleCancel">不同意</ElButton>
+        <ElButton type="primary" @click="handleConfirm">同意并继续</ElButton>
+      </div>
+    </template>
+  </ElDialog>
+</template>
+
+<style scoped lang="scss">
+.disclaimer-content {
+  line-height: 1.8;
+  color: #333;
+  font-size: 14px;
+
+  p {
+    margin-bottom: 16px;
+    text-align: justify;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: center;
+  gap: 20px;
+}
+</style>
+
+<style>
+.ss-disclaimer-dialog .el-dialog__header {
+  padding-bottom: 10px;
+  border-bottom: 1px solid #eee;
+}
+
+.ss-disclaimer-dialog .el-dialog__title {
+  font-weight: bold;
+  font-size: 18px;
+}
+
+.ss-disclaimer-dialog .el-dialog__body {
+  padding: 20px 30px;
+}
+</style>

+ 1 - 10
web/src/components/SsInputBox.vue

@@ -258,9 +258,7 @@ defineExpose({
           <ss-chat-send-message v-if="item.type == 'send_message'" :message="item.content" @edit-message="editMessage"/>
           <ss-chat-reply v-else :reply="item" @loaded="loaded" @quick-send="quickSend" :last="replyList.length <= idx + 1"/>
         </template>
-        <div class="loading">
-          <div class="loading-box" v-loading="global.inReply"></div>
-        </div>
+
       </div>
       <div ref="bottomRef"></div>
       <div style="height: 1rem"></div>
@@ -336,14 +334,7 @@ defineExpose({
       padding: 0 1rem;
       position: relative;
 
-      .loading {
-        display: flex;
-        margin: 0.5rem 0 0.5rem 2.6rem;
 
-        .loading-box {
-          width: 2rem;
-        }
-      }
     }
   }
 

+ 80 - 47
web/src/components/ss_chat/components/SsChatReply.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { ref } from 'vue'
+import { ref, computed } from 'vue'
 import type { PropType } from 'vue'
 import { useGlobalStore } from '@/stores/global'
 
@@ -32,6 +32,10 @@ const props = defineProps({
 })
 const emit = defineEmits(['loaded', 'quickSend'])
 
+const showLoading = computed(() => {
+  return global.inReply && props.reply.type === 'text' && !props.reply.content
+})
+
 const toggleUnfold = () => {
   unfold.value = !unfold.value
 }
@@ -139,19 +143,25 @@ const refreshProblem = () => {
         </div>
       </div>
     </div>
-    <div class="reply-header">
-      <div class="reply-robot">
-        <el-image class="replay-avatar" fit="cover" :src="reply.avatar" />
-        <div class="reply-title" :class="{big: reply.type == 'category'}">{{ reply.title }}</div>
+    <div class="reply-row">
+      <el-image class="replay-avatar" fit="cover" :src="reply.avatar" />
+      <div class="reply-content">
+        <!-- Loading 动画:三个点交替跃动 -->
+        <div v-if="last && showLoading" class="typing-indicator">
+          <span class="dot"></span>
+          <span class="dot"></span>
+          <span class="dot"></span>
+        </div>
+        <!-- 内容区域 -->
+        <div v-else class="replay-container">
+          <ss-chat-reply-markdown v-if="reply.type == 'text'" :chat-id="reply.chatId" :content="reply.content" :title="reply.title" :last="last" />
+          <ss-chat-reply-category v-else-if="reply.type == 'category'" :category-id="reply.content" @loaded="loaded" @quick-send="quickSend" />
+          <ss-chat-reply-problem v-else-if="reply.type == 'problem'" @quick-send="quickSend" />
+        </div>
+        <template v-if="reply.type == 'problem'">
+          <el-button class="refresh" :icon="Refresh" :loading="loading" @click="refreshProblem">换一换</el-button>
+        </template>
       </div>
-      <template v-if="reply.type == 'problem'">
-        <el-button class="refresh" :icon="Refresh" :loading="loading" @click="refreshProblem">换一换</el-button>
-      </template>
-    </div>
-    <div class="replay-container">
-      <ss-chat-reply-markdown v-if="reply.type == 'text'" :chat-id="reply.chatId" :content="reply.content" :last="last" />
-      <ss-chat-reply-category v-else-if="reply.type == 'category'" :category-id="reply.content" @loaded="loaded" @quick-send="quickSend" />
-      <ss-chat-reply-problem v-else-if="reply.type == 'problem'" @quick-send="quickSend" />
     </div>
   </div>
 </template>
@@ -371,53 +381,76 @@ const refreshProblem = () => {
     }
   }
 
-  .reply-header {
+  .reply-row {
     display: flex;
-    align-items: center;
-    justify-content: space-between;
-    margin-bottom: 0.44rem;
+    align-items: flex-start;
 
-    .reply-robot {
-      display: flex;
-      align-items: center;
+    .replay-avatar {
+      width: 2rem;
+      height: 2rem;
+      margin-right: 1rem;
+      flex-shrink: 0;
+    }
 
-      .replay-avatar {
-        width: 2rem;
-        height: 2rem;
-        margin-right: 1rem;
-        position: sticky;
-        top: 0;
-        left: 0;
-        z-index: 200;
-      }
+    .reply-content {
+      flex: 1;
+      min-width: 0;
+
+      .typing-indicator {
+        display: inline-flex;
+        align-items: center;
+        gap: 0.25rem;
+        padding: 0.5rem 0.75rem;
+        background-color: #f5f5f5;
+        border-radius: 0.75rem;
+
+        .dot {
+          width: 0.375rem;
+          height: 0.375rem;
+          background-color: #2942D4;
+          border-radius: 50%;
+          animation: bounce 1.4s infinite ease-in-out both;
+
+          &:nth-child(1) {
+            animation-delay: -0.32s;
+          }
 
-      .reply-title {
-        font-size: 0.88rem;
-        line-height: 0.88rem;
-        color: #101333;
-        font-weight: 400;
+          &:nth-child(2) {
+            animation-delay: -0.16s;
+          }
 
-        &.big {
-          font-size: 1.25rem;
-          font-weight: 500;
+          &:nth-child(3) {
+            animation-delay: 0s;
+          }
         }
       }
-    }
 
-    .refresh {
-      padding: 0;
-      height: unset;
-      border: 0;
+      .replay-container {
+        color: #545764;
+      }
 
-      &:hover {
-        background-color: #ffffff;
+      .refresh {
+        padding: 0;
+        height: unset;
+        border: 0;
+        margin-top: 0.5rem;
+
+        &:hover {
+          background-color: #ffffff;
+        }
       }
     }
   }
 
-  .replay-container {
-    color: #545764;
-    padding-left: 3rem;
+  @keyframes bounce {
+    0%, 80%, 100% {
+      transform: translateY(0);
+      opacity: 0.4;
+    }
+    40% {
+      transform: translateY(-0.3rem);
+      opacity: 1;
+    }
   }
 }
 </style>

+ 8 - 2
web/src/components/ss_chat/components/SsChatReplyMarkdown.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import {nextTick, onMounted, onUnmounted, ref, watch} from 'vue'
+import {computed, nextTick, onMounted, onUnmounted, ref, watch} from 'vue'
 import {extractPlainText, parseMarkdown} from '@/utils/parseMarkdown'
 import {useClipboard} from '@/hooks/useClipboard'
 import {MediaApi} from "@/api/media"
@@ -32,12 +32,18 @@ let props = defineProps({
     type: String,
     default: ''
   },
+  title: {
+    type: String,
+    default: ''
+  },
   last: {
     type: Boolean,
     default: false
   }
 })
 
+
+
 const audioElement = ref()
 const localElement = ref()
 let audioStatus = ref('wait')
@@ -480,7 +486,7 @@ watch(() => props.content, (newVal, oldVal) => {
     }
 
     ul, ol {
-      padding-left: 1.25em;
+      padding-left: 0.8em;
       margin-bottom: 1em;
     }