Files
electron-opencode/src/renderer/views/chat/ChatView.vue
2026-04-12 13:05:52 +08:00

421 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>