8 Commits

Author SHA1 Message Date
d566344eb8 feat: 样式调整 2026-01-22 18:00:15 +08:00
236abba8d0 feat: 聊天界面的调整与交互处理 2026-01-22 01:04:06 +08:00
47a361e78b feat: 调整 2026-01-22 00:08:44 +08:00
8714478587 feat: 设置界面装到容器 2026-01-21 22:52:43 +08:00
14d213c4a8 feat: 消息的调整 2026-01-21 22:48:20 +08:00
34ad0ed8e3 feat: 发送消息的处理 2026-01-21 21:37:31 +08:00
aa8c1737ad feat: 处理消息发送之后的页面滚动问题 2026-01-21 21:21:30 +08:00
zoujing
bdb685b4ec feat: 更换会话id 2026-01-21 17:07:48 +08:00
12 changed files with 298 additions and 149 deletions

View File

@@ -1,72 +1,76 @@
<template>
<!-- 页面根 -->
<div class="h-full overflow-hidden flex flex-col">
<div class="flex flex-col h-full py-6 px-6" :class="isGuidePage ? 'overflow-auto' : 'overflow-hidden'">
<!-- 消息列表唯一滚动区 -->
<div ref="listRef" class="flex-1 overflow-y-auto px-6 py-6 space-y-6">
<!-- 引导页顶部 welcome仅在引导页显示 -->
<div v-if="isGuidePage" class="border-box pt-30">
<h1 class="text-[28px] font-bold mb-7 leading-tight">
你好<br />
我今天能帮你什么
</h1>
</div>
<!-- 主体滚动区聊天或引导页内容 -->
<div v-if="!isGuidePage" ref="listRef" class="flex-1 overflow-y-auto py-6 space-y-6">
<!-- 聊天消息列表 -->
<div v-for="msg in chatMsgList" :key="msg.messageId" class="flex items-start gap-3"
:class="msg.messageRole === MessageRole.ME ? 'justify-end' : 'justify-start'">
<!-- AI avatar -->
<ChatAvatar v-if="msg.messageRole === MessageRole.AI" :src="aiAvatar" />
<!-- 消息气泡 -->
<div class="max-w-[70%]">
<!-- 名字和时间 -->
<ChatNameTime :showReverse="msg.messageRole === MessageRole.ME" />
<!-- 自己 发的消息 -->
<ChatRoleMe v-if="msg.messageRole === MessageRole.ME" :msg="msg" >
<template #header>
<!-- 名字和时间 -->
<ChatNameTime :showReverse="true" />
</template>
</ChatRoleMe>
<!-- AI 发的消息 -->
<ChatRoleAI v-if="msg.messageRole === MessageRole.AI" :msg="msg" />
<!-- AI 发的消息 -->
<ChatRoleAI v-if="msg.messageRole === MessageRole.AI" :msg="msg">
<template #header>
<!-- 名字和时间 -->
<ChatNameTime :showReverse="false" />
</template>
<!-- 自己 发的消息 -->
<ChatRoleMe v-if="msg.messageRole === MessageRole.ME" :msg="msg" />
<template #footer>
<!-- 问题标签 -->
<ChatAttach v-if="msg.question && msg.question.length > 0" :question="msg.question" @select="onTagSelect" />
<!-- AI 标识 -->
<ChatAIMark v-if="msg.messageRole === MessageRole.AI && msg.finished" />
<!-- AI 标识 -->
<ChatAIMark v-if="msg.finished" />
<!-- AI 操作按钮 -->
<ChatOperation v-if="msg.messageRole === MessageRole.AI && msg.finished" :msg="msg" />
</div>
<!-- AI 操作按钮 -->
<ChatOperation v-if="msg.finished" :msg="msg" />
</template>
</ChatRoleAI>
<!-- User avatar -->
<ChatAvatar v-if="msg.messageRole === MessageRole.ME" :src="userAvatar" />
<!-- User avatar -->
<ChatAvatar v-if="msg.messageRole === MessageRole.ME" :src="userAvatar" />
</div>
</div>
<!-- 输入区固定底部不滚 -->
<div class="shrink-0 px-6 py-4 gap-3">
<!-- 输入区 -->
<div class="flex flex-col gap-3" :class="isGuidePage ? 'mt-16' : 'mt-4'">
<div class="inline-flex items-center justify-center w-[108px]
px-3 py-1.5 rounded-2xl border border-[#E5E8EE]
text-[13px] text-[#333]">
px-3 py-1.5 rounded-2xl border border-[#E5E8EE]
text-[13px] text-[#333]">
智能问数
</div>
<div class="h-[174px] bg-white rounded-lg border border-[#eef2f6]
shadow-[0_1px_0_rgba(0,0,0,0.03)]
p-[18px] mt-[8px] flex flex-col justify-between">
<textarea rows="2" placeholder="给我发布或者布置任务" class="flex-1 resize-none outline-none text-sm"
v-model="inputMessage" @keyup.enter="sendMessageAction" />
<div class="flex justify-between items-end">
<button @click="addAttachmentAction()">
<RiLink />
</button>
<button class="w-[48px] h-[48px] bg-[#F5F7FA] px-2.5 py-1.5 rounded-md flex items-center justify-center"
@click="sendMessageAction()">
<RiStopFill v-if="isSendingMessage" />
<RiSendPlaneFill v-else />
</button>
</div>
</div>
<ChatInputArea v-model="inputMessage" :isSendingMessage="isSendingMessage" @send="onGuideSend"
@attach="addAttachmentAction" />
</div>
<!-- 任务中心仅在引导页显示 -->
<TaskCenter v-if="isGuidePage" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { RiLink, RiSendPlaneFill, RiStopFill } from '@remixicon/vue'
import { onMounted, nextTick, onUnmounted } from "vue";
import { ref, defineProps, defineEmits, watch, nextTick } from 'vue'
import { onMounted, onUnmounted } from "vue";
import { WebSocketManager } from "@common/WebSocketManager";
import { MessageRole, ChatMessage } from "./model/ChatModel";
import { IdUtils } from "@common/index";
@@ -76,15 +80,39 @@ import ChatRoleAI from './components/ChatRoleAI.vue';
import ChatRoleMe from './components/ChatRoleMe.vue';
import ChatAIMark from './components/ChatAIMark.vue';
import ChatNameTime from './components/ChatNameTime.vue';
import ChatAttach from './components/ChatAttach.vue';
import ChatInputArea from './components/ChatInputArea.vue';
import TaskCenter from './TaskCenter.vue';
import { Session } from '../../utils/storage';
import userAvatar from '@assets/images/login/user_icon.png';
import aiAvatar from '@assets/images/login/blue_logo.png';
// 支持外部通过 prop 控制是否为引导页
const props = defineProps({
guide: { type: Boolean, default: true }
});
const emit = defineEmits(['update:guide']);
///(控制滚动位置
const scrollTop = ref(99999);
/// 是否是引导页(内部响应式
const isGuidePage = ref(props.guide);
// 同步外部变化到内部
watch(() => props.guide, (v) => {
isGuidePage.value = v;
});
// 将内部变化通知父组件
watch(isGuidePage, (v) => {
emit('update:guide', v);
if (v) {
// 当切换到引导页时,重置/清理会话状态
resetConversation();
}
});
// 列表滚动容器引用
const listRef = ref<HTMLElement | null>(null);
/// 会话列表
const chatMsgList = ref<ChatMessage[]>([]);
@@ -93,7 +121,7 @@ const inputMessage = ref("");
/// 发送消息中标志
const isSendingMessage = ref(false);
/// agentId 首页接口中获取
/// agentId 首页接口中获取 1953462165250859010
const agentId = ref("1");
/// 会话ID 历史数据接口中获取
const conversationId = ref("");
@@ -123,15 +151,39 @@ const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
// 当前会话的消息ID用于保持发送和终止的messageId一致
let currentSessionMessageId: string | null = null;
// 滚动到底部 - 优化版本,确保打字机效果始终可见
const scrollToBottom = () => {
// 滚动到底部 - 精确计算 last 元素位置并使用双 rAF 保证布局稳定
const scrollToBottom = (smooth = false) => {
nextTick(() => {
// 使用更大的值确保滚动到真正的底部
scrollTop.value = 99999;
// 强制触发滚动更新增加延迟确保DOM更新完成
setTimeout(() => {
scrollTop.value = scrollTop.value + Math.random();
}, 10);
const el = listRef.value;
if (!el) return;
const doScroll = () => {
const last = el.lastElementChild as HTMLElement | null;
// 计算容器 style padding-bottom保证滚动到真正可视底部
const style = window.getComputedStyle(el);
const paddingBottom = parseFloat(style.paddingBottom || '0') || 0;
if (last) {
const lastOffset = last.offsetTop + last.offsetHeight;
const target = lastOffset + paddingBottom - el.clientHeight;
const top = Math.max(0, Math.ceil(target));
if (smooth && typeof el.scrollTo === 'function') {
el.scrollTo({ top, behavior: 'smooth' });
} else {
el.scrollTop = top;
}
return;
}
// 兜底:滚到底
if (smooth && typeof el.scrollTo === 'function') {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
} else {
el.scrollTop = el.scrollHeight;
}
};
// 使用两次 requestAnimationFrame 增强在复杂渲染/打字机更新场景的可靠性
requestAnimationFrame(() => requestAnimationFrame(doScroll));
});
};
@@ -149,7 +201,6 @@ const handleReplyText = (text: string) => {
// 是发送指令消息
const handleReplyInstruct = async (message: string, type: string) => {
// await checkToken();
commonTypeMessage = type;
// 重置消息状态准备接收新的AI回复
resetMessageState();
@@ -157,6 +208,18 @@ const handleReplyInstruct = async (message: string, type: string) => {
setTimeoutScrollToBottom();
};
/// 选择标签事件:切换到聊天页并发送
const onTagSelect = (text: string) => {
isGuidePage.value = false;
nextTick(() => handleReplyText(text));
};
// 在引导页中按发送:切换到聊天页再发送
const onGuideSend = () => {
isGuidePage.value = false;
nextTick(() => sendMessageAction());
};
/// 添加附件按钮事件
const addAttachmentAction = () => {
console.log("添加附件");
@@ -169,11 +232,14 @@ const sendMessageAction = () => {
return;
}
console.log("输入消息:", inputMessage.value);
if (!inputMessage.value.trim()) return;
const raw = inputMessage.value || '';
// 去除尾部多余的换行,保留中间换行(例如 Shift+Enter
const sendText = raw.replace(/\n+$/, '');
console.log("输入消息:", sendText);
if (!sendText.trim()) return;
// 重置消息状态准备接收新的AI回复
resetMessageState();
sendMessage(inputMessage.value);
sendMessage(sendText);
setTimeoutScrollToBottom();
};
@@ -369,6 +435,7 @@ const handleWebSocketMessage = (data: any) => {
// 处理question
if (data.question && data.question.length > 0) {
console.log("收到问题标签:", data.question);
chatMsgList.value[aiMsgIndex].question = data.question;
}
@@ -388,6 +455,7 @@ const handleWebSocketMessage = (data: any) => {
isSessionActive.value = false;
// 清理当前会话的 messageId避免保留陈旧 id
resetMessageState();
setTimeoutScrollToBottom();
}
};
@@ -605,6 +673,7 @@ const sendChat = async (message: string, isInstruct = false) => {
pendingMap.delete(currentSessionMessageId);
pendingTimeouts.delete(currentSessionMessageId);
isSessionActive.value = false;
isSendingMessage.value = false;
setTimeoutScrollToBottom();
}
}, MESSAGE_TIMEOUT);
@@ -704,5 +773,33 @@ const resetConfig = () => {
pendingMap.clear();
};
// 清空会话并停止相关活动(保留 websocket 连接以便继续使用)
const resetConversation = () => {
try {
// 如果正在发送,尝试发送停止请求
try {
if (isSendingMessage.value) sendStopAction();
} catch (e) {
// ignore
}
// 清理 pendingTimeouts
for (const t of pendingTimeouts.values()) {
clearTimeout(t);
}
pendingTimeouts.clear();
pendingMap.clear();
// 清理消息与状态
chatMsgList.value = [];
inputMessage.value = '';
isSendingMessage.value = false;
isSessionActive.value = false;
currentSessionMessageId = null;
} catch (e) {
console.warn('resetConversation failed', e);
}
};
</script>

View File

@@ -1,73 +0,0 @@
<template>
<!-- 唯一滚动容器 -->
<div class="h-full overflow-y-auto">
<!-- Hero吸顶 -->
<div class="bg-white border-box px-12 pt-10 pb-6 max-[800px]:px-5">
<h1 class="text-[28px] font-bold mb-7 leading-tight">
你好<br />
我今天能帮你什么
</h1>
<!-- input -->
<div class="flex flex-col gap-3">
<div class="inline-flex items-center justify-center w-[108px]
px-3 py-1.5 rounded-2xl border border-[#E5E8EE]
text-[13px] text-[#333]">
智能问数
</div>
<div class="h-[174px] bg-white rounded-lg border border-[#eef2f6]
shadow-[0_1px_0_rgba(0,0,0,0.03)]
p-[18px] flex flex-col justify-between">
<div class="text-[#bfc9d4]">
给我发布或者布置任务
</div>
<div class="flex justify-between items-center">
<button class="text-[#9fb0c4]">🔗</button>
<button class="bg-[#f1f6fb] px-2.5 py-1.5 rounded-md">
</button>
</div>
</div>
<!-- header -->
<div class="flex justify-between items-center mt-4">
<h3 class="text-base font-semibold">任务中心</h3>
<a class="text-[#3b82f6] text-[13px] cursor-pointer">
编辑
</a>
</div>
</div>
</div>
<!-- 内容区 -->
<div class="flex-1 px-12 pb-10 pt-4 max-[800px]:px-5">
<div class="grid grid-cols-2 gap-4 max-[800px]:grid-cols-1">
<div v-for="n in 14" :key="n" class="flex gap-3 items-start p-3.5
rounded-[10px] border border-[#dfeaf6] bg-white">
<div class="w-11 h-11 bg-[#EFF6FF] rounded-lg
border border-dashed border-[#9fc0e8]
flex items-center justify-center
text-[#3b82f6] text-[23px]">
</div>
<div>
<div class="font-semibold">
每日销售数据
</div>
<div class="text-[#9aa5b1] text-[13px] mt-1.5">
分析用于销售渠道每日数据汇总及简要展示
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
</script>

View File

@@ -36,7 +36,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, defineEmits } from 'vue'
import { RiAddLine, RiArrowRightSLine, RiArrowDownSLine } from '@remixicon/vue'
/// 记录选择的历史消息ID
@@ -124,8 +124,11 @@ const selectGroupKey = (index: number) => {
}
/// TODO: 添加新对话
const emit = defineEmits(['new-chat'])
const addNewChat = () => {
console.log('add new chat')
emit('new-chat')
}
</script>

View File

@@ -0,0 +1,31 @@
<template>
<div class="flex-1 pb-6">
<div class="flex justify-between items-center py-4">
<h3 class="text-base font-semibold">任务中心</h3>
<a class="text-[#3b82f6] text-[13px] cursor-pointer">
编辑
</a>
</div>
<div class="grid grid-cols-2 gap-4 max-[800px]:grid-cols-1">
<div v-for="n in 14" :key="n" class="flex gap-3 items-start p-3.5
rounded-[10px] border border-[#dfeaf6] bg-white">
<div class="w-11 h-11 bg-[#EFF6FF] rounded-lg
border border-dashed border-[#9fc0e8]
flex items-center justify-center
text-[#3b82f6] text-[23px]">
</div>
<div>
<div class="font-semibold">
每日销售数据
</div>
<div class="text-[#9aa5b1] text-[13px] mt-1.5">
分析用于销售渠道每日数据汇总及简要展示
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,34 @@
<template>
<div class="tag-flex flex-wrap pt-3">
<div class="inline-flex items-center justify-center box-border border border-[#E5E8EE] rounded-lg py-0.5 px-2.5 mr-2 mb-2"
v-for="(item, index) in questionList" :key="index" @click="handleClick(item)">
<span class="tag-text-[#2d91ff] text-[10px]">{{ item }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { onMounted } from "vue";
const props = defineProps({
question: {
type: String,
default: "",
},
});
const questionList = ref<string[]>([]);
// 定义 emit 事件,向父组件发送选中的 tag
const emit = defineEmits<{ (e: 'select', tag: string): void }>();
const handleClick = (item: string) => {
emit('select', item);
};
onMounted(() => {
questionList.value = props.question.split(/[&|;]/).filter((tag) => tag.trim() !== "");
});
</script>

View File

@@ -0,0 +1,48 @@
<template>
<div class="h-[174px] bg-white rounded-lg border border-[#eef2f6] shadow-[0_1px_0_rgba(0,0,0,0.03)] p-4 mt-2 flex flex-col justify-between">
<textarea
rows="2"
placeholder="给我发布或者布置任务"
class="flex-1 resize-none outline-none text-sm"
:value="modelValue"
@input="onInput"
@keydown.enter="onKeydownEnter"
/>
<div class="flex justify-between items-end">
<button @click="onAttach">
<RiLink />
</button>
<button class="w-12 h-12 bg-[#F5F7FA] px-2.5 py-1.5 rounded-md flex items-center justify-center" @click="onSend">
<RiStopFill v-if="isSendingMessage" />
<RiSendPlaneFill v-else />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue'
import { RiLink, RiSendPlaneFill, RiStopFill } from '@remixicon/vue'
const props = defineProps({
modelValue: { type: String, default: '' },
isSendingMessage: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'send', 'attach'])
const onInput = (e: Event) => {
const v = (e.target as HTMLTextAreaElement).value
emit('update:modelValue', v)
}
const onKeydownEnter = (e: KeyboardEvent) => {
if ((e as KeyboardEvent).shiftKey) return
e.preventDefault()
emit('send')
}
const onAttach = () => emit('attach')
const onSend = () => emit('send')
</script>

View File

@@ -1,7 +1,11 @@
<template>
<div class="text-sm text-gray-700 flex flex-row">
<div v-html="compiledMarkdown"></div>
<ChatLoading v-if="msg.isLoading" />
<div class="max-w-[75%] flex flex-col">
<slot name="header"></slot>
<div class="text-sm text-gray-700 flex flex-row">
<div v-html="compiledMarkdown"></div>
<ChatLoading v-if="msg.isLoading" />
</div>
<slot name="footer"></slot>
</div>
</template>

View File

@@ -1,8 +1,11 @@
<template>
<div class="text-sm text-gray-700 bg-[#f7f9fc] rounded-md px-2 py-2">
{{ msg.messageContent }}
<div class="max-w-[75%]">
<slot name="header"></slot>
<div class="text-sm text-gray-700 bg-[#f7f9fc] rounded-md px-2 py-2">
{{ msg.messageContent }}
</div>
<slot name="footer"></slot>
</div>
</template>
<script lang="ts" setup>

View File

@@ -1,10 +1,9 @@
<template>
<layout>
<div class="flex h-full w-full flex-col md:flex-row ">
<chat-history class="flex-none w-64" />
<ChatHistory class="flex-none w-50" @new-chat="guide = true" />
<div class="flex-1 mr-2 overflow-hidden bg-white rounded-xl">
<!-- <chat-guide /> -->
<chat-box />
<ChatBox v-model:guide="guide" />
</div>
<TaskList />
</div>
@@ -14,6 +13,8 @@
<script setup lang="ts">
import TaskList from '@renderer/components/TaskList/index.vue'
import ChatHistory from './ChatHistory.vue'
import ChatGuide from './ChatGuide.vue'
import ChatBox from './ChatBox.vue'
import { ref } from 'vue'
const guide = ref(true)
</script>

View File

@@ -23,7 +23,7 @@ export class ChatMessage {
// 工具调用信息
toolCall?: any;
// 问题信息
question?: any;
question?: string;
constructor(
messageId: string,

View File

@@ -14,7 +14,7 @@
class="bg-sky-50 rounded-[8px] text-[14px] text-sky-600 px-[12px] py-[6px] focus-visible:outline-none cursor-pointer">注册</button> -->
</div>
<div class="flex flex-col items-center justify-center mb-[24px] box-border pt-[108px]">
<div class="flex flex-col items-center justify-center mb-[24px] box-border pt-[40px]">
<img class="w-[80px] h-[80px] mb-[12px]" src="@assets/images/login/user_icon.png" />
<div class="text-[24px] font-500 text-gray-800 line-height-[32px] mb-[4px]">登录</div>
<div class="text-[16px] text-gray-500 line-height-[24px]">24小时在岗从不打烊的数字员工</div>

View File

@@ -1,9 +1,10 @@
<template>
<div class="bg-white box-border w-full h-full flex">
<SystemConfig @change=onChange />
<component :is="currentComponent" />
</div>
<layout>
<div class="bg-white box-border w-full h-full flex">
<SystemConfig @change=onChange />
<component :is="currentComponent" />
</div>
</layout>
</template>
<script setup lang="ts">