421 lines
11 KiB
Vue
421 lines
11 KiB
Vue
<template>
|
||
<div class="chat-page">
|
||
<!-- 消息列表 -->
|
||
<div ref="messagesRef" class="messages">
|
||
<div v-if="messages.length === 0" class="empty-hint">
|
||
<el-icon :size="40" color="#c0c4cc"><ChatDotRound /></el-icon>
|
||
<p>启动服务后开始对话</p>
|
||
</div>
|
||
<div v-for="msg in messages" :key="msg.id" class="bubble-wrap" :class="msg.role">
|
||
<div class="bubble">
|
||
<!-- 推理过程(仅助手消息显示) -->
|
||
<div v-if="msg.parts?.reasoning?.length > 0" class="reasoning-section">
|
||
<div class="reasoning-header" @click="msg.showReasoning = !msg.showReasoning">
|
||
<el-icon :size="14"><ArrowRight v-if="!msg.showReasoning" /><ArrowDown v-else /></el-icon>
|
||
<span>思考过程</span>
|
||
</div>
|
||
<div v-show="msg.showReasoning" class="reasoning-content">
|
||
<div v-for="(r, idx) in msg.parts.reasoning" :key="idx" class="reasoning-item">
|
||
{{ r.text }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 文本内容 -->
|
||
<pre class="bubble-text">{{ msg.text }}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 输入区 -->
|
||
<div class="input-area">
|
||
<el-input
|
||
v-model="inputText"
|
||
type="textarea"
|
||
:autosize="{ minRows: 2, maxRows: 5 }"
|
||
placeholder="输入消息,Ctrl+Enter 发送"
|
||
:disabled="isSending"
|
||
resize="none"
|
||
@keydown.ctrl.enter.prevent="send"
|
||
/>
|
||
<el-button type="primary" :disabled="isSending || !inputText.trim()" :loading="isSending" @click="send"> 发送 </el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
|
||
import { useRoute, useRouter } from 'vue-router';
|
||
import { ElMessage } from 'element-plus';
|
||
import { ChatDotRound, ArrowRight, ArrowDown } from '@element-plus/icons-vue';
|
||
import { useAppStore } from '@/stores/app.js';
|
||
import { sseManager } from '@/http/sse.js';
|
||
import axios from 'axios';
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const appStore = useAppStore();
|
||
const isSending = ref(false);
|
||
const inputText = ref('');
|
||
const messages = ref([]);
|
||
const messagesRef = ref(null);
|
||
const currentSessionId = ref(null);
|
||
const localAssistantMessageIds = new Set();
|
||
let unsubscribeCallbacks = [];
|
||
|
||
// 从路由参数中获取 sessionId
|
||
const routeSessionId = computed(() => route.params.id || route.query.sessionId);
|
||
|
||
// 加载历史消息
|
||
async function loadHistoryMessages(sessionId) {
|
||
if (!sessionId) return;
|
||
try {
|
||
const baseUrl = window.__opencodeBaseUrl || 'http://127.0.0.1:4096';
|
||
const requestUrl = `${baseUrl}/session/${sessionId}/message`;
|
||
console.log('[loadHistoryMessages] ========================================');
|
||
console.log('[loadHistoryMessages] 开始获取消息,sessionId:', sessionId);
|
||
console.log('[loadHistoryMessages] 请求URL:', requestUrl);
|
||
|
||
const response = await axios.get(requestUrl);
|
||
const messagesData = response.data || [];
|
||
|
||
console.log('[loadHistoryMessages] 获取成功!消息数量:', messagesData.length);
|
||
console.log('[loadHistoryMessages] 原始消息数据 (JSON):', JSON.stringify(messagesData, null, 2));
|
||
console.log('[loadHistoryMessages] ========================================');
|
||
|
||
// 清空当前消息
|
||
messages.value = [];
|
||
localAssistantMessageIds.clear();
|
||
appStore.clearAssistantMessageIds();
|
||
|
||
// 处理历史消息
|
||
messagesData.forEach((item) => {
|
||
const { info, parts } = item;
|
||
if (!info || !parts) return;
|
||
|
||
// 按 type 分类存储 parts
|
||
const partsByType = {
|
||
text: [],
|
||
reasoning: [],
|
||
'step-start': [],
|
||
'step-finish': [],
|
||
// 可以在这里添加更多 type
|
||
};
|
||
|
||
parts.forEach((part) => {
|
||
if (partsByType.hasOwnProperty(part.type)) {
|
||
partsByType[part.type].push(part);
|
||
} else {
|
||
// 未识别的 type 统一放到 others
|
||
if (!partsByType.others) partsByType.others = [];
|
||
partsByType.others.push(part);
|
||
}
|
||
});
|
||
|
||
console.log(`[loadHistoryMessages] 消息 ${info.id} 的 parts 分类:`, partsByType);
|
||
|
||
// 提取文本内容(用于展示)
|
||
const text = partsByType.text.map((part) => part.text).join('');
|
||
|
||
if (text || info.role === 'assistant') {
|
||
messages.value.push({
|
||
id: info.id,
|
||
role: info.role,
|
||
text: text,
|
||
parts: partsByType, // 存储分类后的 parts,方便后续按 type 渲染
|
||
rawParts: parts, // 保留原始 parts
|
||
showReasoning: false, // 默认折叠推理过程
|
||
});
|
||
|
||
// 记录 assistant 消息 ID
|
||
if (info.role === 'assistant') {
|
||
localAssistantMessageIds.add(info.id);
|
||
appStore.addAssistantMessageId(info.id);
|
||
}
|
||
}
|
||
});
|
||
|
||
console.log('[loadHistoryMessages] 处理后的消息列表:', messages.value);
|
||
scrollToBottom();
|
||
} catch (err) {
|
||
console.error('加载历史消息失败:', err);
|
||
ElMessage.error(`加载历史消息失败: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
// 监听路由参数变化,加载对应会话
|
||
watch(
|
||
routeSessionId,
|
||
async (newSessionId) => {
|
||
if (newSessionId) {
|
||
currentSessionId.value = newSessionId;
|
||
// 等待历史消息加载完毕,避免在 send 时 messages 被清空
|
||
await loadHistoryMessages(newSessionId);
|
||
|
||
// 处理从首页带过来的初始消息
|
||
const text = route.query.text;
|
||
if (text) {
|
||
inputText.value = text;
|
||
// 清除 query 中的 text,防止刷新页面时重复发送
|
||
const query = { ...route.query };
|
||
delete query.text;
|
||
router.replace({ query });
|
||
|
||
// 触发发送逻辑
|
||
send();
|
||
}
|
||
|
||
// 确保 SSE 连接已建立
|
||
if (!appStore.sseConnected) {
|
||
appStore.initSSE();
|
||
}
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
function scrollToBottom() {
|
||
nextTick(() => {
|
||
if (messagesRef.value) {
|
||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight;
|
||
}
|
||
});
|
||
}
|
||
|
||
function upsertAssistantBubble(msgId, text) {
|
||
const existing = messages.value.find((m) => m.id === msgId);
|
||
if (existing) {
|
||
existing.text = text;
|
||
} else {
|
||
messages.value.push({ id: msgId, role: 'assistant', text });
|
||
}
|
||
scrollToBottom();
|
||
}
|
||
|
||
/**
|
||
* 注册 SSE 事件监听器
|
||
*/
|
||
function registerSSEListeners() {
|
||
// 监听消息部分更新事件
|
||
const unsubscribePartUpdated = sseManager.on('message.part.updated', (data) => {
|
||
const props = data.properties || {};
|
||
const part = props.part;
|
||
if (!part || part.type !== 'text') return;
|
||
if (part.sessionID !== currentSessionId.value) return;
|
||
// 通过 messageID 前缀区分用户/助手消息:只渲染 assistant 消息的 part
|
||
// assistant 消息的 messageID 会在 message.updated 事件中记录,用 localAssistantMessageIds 集合过滤
|
||
if (!localAssistantMessageIds.has(part.messageID) && !appStore.isAssistantMessage(part.messageID)) return;
|
||
upsertAssistantBubble(part.messageID, part.text || '');
|
||
});
|
||
|
||
// 监听消息更新事件
|
||
const unsubscribeMessageUpdated = sseManager.on('message.updated', (data) => {
|
||
const props = data.properties || {};
|
||
const info = props.info;
|
||
// 记录 assistant 消息的 ID,供 message.part.updated 过滤使用
|
||
if (info && info.role === 'assistant' && info.sessionID === currentSessionId.value) {
|
||
localAssistantMessageIds.add(info.id);
|
||
appStore.addAssistantMessageId(info.id);
|
||
}
|
||
});
|
||
|
||
// 监听会话空闲事件
|
||
const unsubscribeSessionIdle = sseManager.on('session.idle', (data) => {
|
||
const props = data.properties || {};
|
||
// session.idle 表示 AI 响应已全部完成,重置发送状态
|
||
if (props.sessionID === currentSessionId.value) {
|
||
isSending.value = false;
|
||
}
|
||
});
|
||
|
||
// 保存取消订阅函数
|
||
unsubscribeCallbacks.push(unsubscribePartUpdated, unsubscribeMessageUpdated, unsubscribeSessionIdle);
|
||
}
|
||
|
||
/**
|
||
* 注销 SSE 事件监听器
|
||
*/
|
||
function unregisterSSEListeners() {
|
||
unsubscribeCallbacks.forEach((unsubscribe) => {
|
||
if (typeof unsubscribe === 'function') {
|
||
unsubscribe();
|
||
}
|
||
});
|
||
unsubscribeCallbacks = [];
|
||
}
|
||
|
||
async function send() {
|
||
const text = inputText.value.trim();
|
||
if (!text || isSending.value) return;
|
||
|
||
// 首次发送时创建会话,使用用户输入的第一条消息作为 title
|
||
if (!currentSessionId.value) {
|
||
try {
|
||
const session = await window.opencode.createSession({ title: text });
|
||
currentSessionId.value = session.id;
|
||
} catch (err) {
|
||
ElMessage.error(`创建会话失败: ${err.message}`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
messages.value.push({ id: Date.now(), role: 'user', text });
|
||
inputText.value = '';
|
||
isSending.value = true;
|
||
scrollToBottom();
|
||
|
||
try {
|
||
await window.opencode.promptAsync(currentSessionId.value, text);
|
||
// 发送成功后等待 SSE 事件流推送 AI 响应,isSending 由 session.idle 事件重置
|
||
} catch (err) {
|
||
console.error('发送指令失败:', err);
|
||
// 如果是服务未运行,尝试启动服务
|
||
if (appStore.serviceStatus !== appStore.SERVICE_STATUS.RUNNING) {
|
||
ElMessage.info('服务未运行,正在尝试启动...');
|
||
appStore.triggerStartService();
|
||
}
|
||
ElMessage.error(`发送失败: ${err.message}`);
|
||
isSending.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
// 组件挂载时注册 SSE 监听器
|
||
registerSSEListeners();
|
||
// 确保全局 SSE 连接已建立
|
||
if (!appStore.sseConnected) {
|
||
appStore.initSSE();
|
||
}
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
// 组件卸载时注销 SSE 监听器
|
||
unregisterSSEListeners();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.chat-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
min-height: 0;
|
||
gap: 12px;
|
||
}
|
||
|
||
.messages {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
padding: 8px 4px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.empty-hint {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12px;
|
||
color: #c0c4cc;
|
||
font-size: 14px;
|
||
margin: auto;
|
||
}
|
||
|
||
.bubble-wrap {
|
||
display: flex;
|
||
}
|
||
|
||
.bubble-wrap.user {
|
||
justify-content: flex-end;
|
||
}
|
||
.bubble-wrap.assistant {
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.bubble {
|
||
max-width: 75%;
|
||
padding: 10px 14px;
|
||
border-radius: 12px;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.bubble-wrap.user .bubble {
|
||
background: #409eff;
|
||
color: #fff;
|
||
border-bottom-right-radius: 4px;
|
||
}
|
||
|
||
.bubble-wrap.assistant .bubble {
|
||
background: #f0f2f5;
|
||
color: #303133;
|
||
border-bottom-left-radius: 4px;
|
||
}
|
||
|
||
.bubble-text {
|
||
margin: 0;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.reasoning-section {
|
||
margin-bottom: 8px;
|
||
border-radius: 6px;
|
||
background: rgba(0, 0, 0, 0.03);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.reasoning-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 6px 10px;
|
||
font-size: 12px;
|
||
color: #666;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.reasoning-header:hover {
|
||
background: rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
.reasoning-content {
|
||
padding: 0 10px 8px;
|
||
}
|
||
|
||
.reasoning-item {
|
||
font-size: 12px;
|
||
color: #888;
|
||
line-height: 1.5;
|
||
padding: 4px 0;
|
||
border-left: 2px solid #ddd;
|
||
padding-left: 8px;
|
||
margin: 4px 0;
|
||
}
|
||
|
||
.input-area {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: flex-end;
|
||
padding: 12px 16px;
|
||
background: #fff;
|
||
border-radius: 10px;
|
||
border: 1px solid #e4e7ed;
|
||
}
|
||
|
||
.input-area .el-textarea {
|
||
flex: 1;
|
||
}
|
||
|
||
.input-area .el-button {
|
||
height: 60px;
|
||
padding: 0 20px;
|
||
}
|
||
</style>
|