Files
electron-opencode/src/renderer/views/chat/ChatView.vue
2026-04-12 15:36:36 +08:00

789 lines
22 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?.filter((p) => p.type === '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.filter((p) => p.type === 'reasoning')" :key="idx" class="reasoning-item">
{{ r.text }}
</div>
</div>
</div>
<!-- 文本内容 -->
<MarkdownRender v-if="msg.text" :content="msg.text"></MarkdownRender>
<!-- question 类型 part 展示 -->
<template v-if="msg.parts">
<div v-for="(p, idx) in msg.parts.filter((p) => p.type === 'question')" :key="'question-' + idx" class="question-part">
<div v-for="(q, qi) in p.questions" :key="qi" class="question-item">
<div class="question-header">{{ q.header }}</div>
<div class="question-text">{{ q.question }}</div>
<div class="question-options">
<div v-for="(opt, oi) in q.options" :key="oi" class="question-option" @click="sendAnswer(p.id, opt.label)">
<span class="option-label">{{ opt.label }}</span>
<span v-if="opt.description" class="option-desc">{{ opt.description }}</span>
</div>
</div>
</div>
</div>
</template>
<!-- tool 类型 part 展示 -->
<template v-if="msg.parts">
<template v-for="(p, idx) in msg.parts.filter((p) => p.type === 'tool')" :key="'tool-' + idx">
<!-- question 工具展示问答卡片历史加载状态为 running 时仍可回复 -->
<div v-if="p.tool === 'question'" class="question-part">
<div v-for="(q, qi) in p.state?.input?.questions || []" :key="qi" class="question-item">
<div class="question-header">{{ q.header }}</div>
<div class="question-text">{{ q.question }}</div>
<div class="question-options">
<div
v-for="(opt, oi) in q.options"
:key="oi"
class="question-option"
:class="{ disabled: p.state?.status !== 'running' }"
@click="p.state?.status === 'running' && p._questionId && sendAnswer(p._questionId, opt.label)"
>
<span class="option-radio"></span>
<span class="option-content">
<span class="option-label">{{ opt.label }}</span>
<span v-if="opt.description" class="option-desc">{{ opt.description }}</span>
</span>
</div>
</div>
</div>
</div>
<!-- 普通工具展示终端卡片 -->
<div v-else class="tool-part">
<div class="tool-header" @click="p._expanded = !p._expanded">
<el-icon :size="13"><ArrowRight v-if="!p._expanded" /><ArrowDown v-else /></el-icon>
<span class="tool-name">{{ p.tool }}</span>
<span class="tool-desc">{{ p.state?.input?.description || p.state?.title || '' }}</span>
<span class="tool-status" :class="'status-' + (p.state?.status || 'unknown')">{{ p.state?.status || '' }}</span>
</div>
<div v-show="p._expanded" class="tool-body">
<div v-if="p.state?.input?.command" class="tool-command">
<span class="tool-label">$ </span><code>{{ p.state.input.command }}</code>
</div>
<pre v-if="p.state?.output" class="tool-output">{{ p.state.output }}</pre>
<div v-if="p.state?.time" class="tool-time">耗时 {{ p.state.time.end - p.state.time.start }} ms</div>
</div>
</div>
</template>
</template>
<!-- 未知类型 part 原始展示 -->
<template v-if="msg.parts">
<div
v-for="(p, idx) in msg.parts.filter(
(p) =>
p.type !== 'text' &&
p.type !== 'reasoning' &&
p.type !== 'tool' &&
p.type !== 'question' &&
p.type !== 'step-start' &&
p.type !== 'step-finish'
)"
:key="'raw-' + idx"
class="raw-part"
>
<span class="raw-part-type">[{{ p.type }}]</span>
<pre class="raw-part-content">{{ JSON.stringify(p, null, 2) }}</pre>
</div>
</template>
</div>
</div>
</div>
<!-- 输入区 -->
<div v-if="!hasActiveQuestion" class="input-area">
<el-input
v-model="inputText"
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
placeholder="输入消息Ctrl+Enter 发送"
:disabled="isSending"
resize="none"
@keydown.enter="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, reactive } 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';
import MarkdownRender from '@/components/MarkdownRender/index.vue';
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();
// 是否有正在等待回答的 question
const hasActiveQuestion = computed(() => {
for (const msg of messages.value) {
if (!msg.parts) continue;
for (const p of msg.parts) {
if (p.type === 'tool' && p.tool === 'question' && p.state?.status === 'running' && p._questionId) return true;
if (p.type === 'question') return true;
}
}
return false;
});
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;
// 提取文本内容(用于展示)
const text = parts
.filter((p) => p.type === 'text')
.map((part) => part.text)
.join('');
// 为每个 part 初始化响应式字段
const normalizedParts = parts.map((p) => (p.type === 'tool' ? { ...p, _expanded: true, _freeInput: p.tool === 'question' ? '' : undefined } : p));
// 提取 question 类型 part来自 question.asked 事件,历史中可能存在)
if (text || info.role === 'assistant') {
messages.value.push({
id: info.id,
role: info.role,
text: text,
parts: normalizedParts,
showReasoning: true, // 默认展开推理过程
});
// 记录 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 upsertAssistantPart(msgId, part) {
let existing = messages.value.find((m) => m.id === msgId);
if (!existing) {
existing = { id: msgId, role: 'assistant', text: '', parts: [], showReasoning: true };
messages.value.push(existing);
}
if (!existing.parts) existing.parts = [];
// 找到同类型同 partID 的 part有则更新无则追加
const partId = part.id || part.partID;
const idx = partId ? existing.parts.findIndex((p) => (p.id || p.partID) === partId) : -1;
if (idx >= 0) {
const preserved = { _expanded: existing.parts[idx]._expanded, _freeInput: existing.parts[idx]._freeInput, _questionId: existing.parts[idx]._questionId };
existing.parts[idx] = { ...existing.parts[idx], ...part, ...preserved };
} else {
const newPart = { ...part };
if (newPart.type === 'tool' && newPart._expanded === undefined) newPart._expanded = true;
if (newPart.type === 'tool' && newPart.tool === 'question' && newPart._freeInput === undefined) newPart._freeInput = '';
existing.parts.push(newPart);
}
// 同步 text 字段(取所有 text 类型 part 拼接)
existing.text = existing.parts
.filter((p) => p.type === 'text')
.map((p) => p.text || '')
.join('');
scrollToBottom();
}
/**
* 注册 SSE 事件监听器
*/
function registerSSEListeners() {
// 监听消息部分更新事件
const unsubscribePartUpdated = sseManager.on('message.part.updated', (data) => {
const props = data.properties || {};
const part = props.part;
if (!part) 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;
upsertAssistantPart(part.messageID, part);
});
// 监听消息更新事件
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;
}
});
// 监听 question.asked 事件
const unsubscribeQuestionAsked = sseManager.on('question.asked', (data) => {
const props = data.properties || {};
if (props.sessionID !== currentSessionId.value) return;
const msgId = props.tool?.messageID;
const callId = props.tool?.callID;
if (!msgId) return;
// 将 question 作为一个 part 插入对应消息
upsertAssistantPart(msgId, {
id: props.id,
type: 'question',
questions: props.questions || [],
});
// 同时将 question id 关联到对应的 tool part通过 callID 匹配)
if (callId) {
const msg = messages.value.find((m) => m.id === msgId);
if (msg && msg.parts) {
const toolPart = msg.parts.find((p) => p.type === 'tool' && p.callID === callId);
if (toolPart) toolPart._questionId = props.id;
}
}
});
// 保存取消订阅函数
unsubscribeCallbacks.push(unsubscribePartUpdated, unsubscribeMessageUpdated, unsubscribeSessionIdle, unsubscribeQuestionAsked);
}
/**
* 注销 SSE 事件监听器
*/
function unregisterSSEListeners() {
unsubscribeCallbacks.forEach((unsubscribe) => {
if (typeof unsubscribe === 'function') {
unsubscribe();
}
});
unsubscribeCallbacks = [];
}
async function sendAnswer(questionId, label) {
if (!currentSessionId.value) return;
messages.value.push({ id: Date.now(), role: 'user', text: label });
isSending.value = true;
scrollToBottom();
try {
const baseUrl = window.__opencodeBaseUrl || 'http://127.0.0.1:4096';
await axios.post(`${baseUrl}/question/${questionId}/reply`, { answers: [[label]] });
} catch (err) {
console.error('发送答案失败:', err);
ElMessage.error(`发送失败: ${err.message}`);
isSending.value = false;
}
}
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;
}
.tool-part {
margin-top: 6px;
border-radius: 6px;
background: #1e1e2e;
color: #cdd6f4;
overflow: hidden;
font-size: 13px;
}
.tool-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
cursor: pointer;
user-select: none;
transition: background 0.2s;
}
.tool-header:hover {
background: rgba(255, 255, 255, 0.05);
}
.tool-name {
font-weight: bold;
color: #89b4fa;
font-family: monospace;
}
.tool-desc {
flex: 1;
color: #a6adc8;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-status {
font-size: 11px;
padding: 1px 6px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.08);
}
.tool-status.status-completed {
color: #a6e3a1;
}
.tool-status.status-running {
color: #f9e2af;
}
.tool-status.status-error {
color: #f38ba8;
}
.tool-body {
padding: 0 10px 8px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.tool-command {
padding: 6px 0 4px;
font-family: monospace;
font-size: 13px;
color: #cba6f7;
}
.tool-label {
color: #a6e3a1;
font-weight: bold;
}
.tool-output {
margin: 4px 0 0;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
font-size: 12px;
color: #cdd6f4;
white-space: pre-wrap;
word-break: break-all;
font-family: monospace;
max-height: 300px;
overflow-y: auto;
}
.tool-time {
font-size: 11px;
color: #6c7086;
margin-top: 4px;
text-align: right;
}
.question-part {
margin-top: 8px;
}
.question-item {
border: 1px solid #e0e4ea;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 6px;
background: #fff;
}
.question-header {
font-size: 12px;
font-weight: bold;
color: #409eff;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.question-text {
font-size: 13px;
color: #303133;
margin-bottom: 8px;
line-height: 1.5;
}
.question-options {
display: flex;
flex-direction: column;
gap: 6px;
}
.question-option {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
padding: 7px 10px;
border-radius: 6px;
border: 1px solid #dcdfe6;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
}
.option-radio {
flex-shrink: 0;
width: 14px;
height: 14px;
margin-top: 2px;
border-radius: 50%;
border: 2px solid #c0c4cc;
background: #fff;
transition: border-color 0.15s;
box-sizing: border-box;
}
.question-option:hover .option-radio {
border-color: #409eff;
}
.question-option.disabled .option-radio {
border-color: #dcdfe6;
}
.option-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.question-option:hover {
background: #ecf5ff;
border-color: #409eff;
}
.question-option.disabled {
cursor: not-allowed;
opacity: 0.5;
}
.question-option.disabled:hover {
background: transparent;
border-color: #dcdfe6;
}
.option-label {
font-size: 13px;
color: #303133;
font-weight: 500;
}
.option-desc {
font-size: 11px;
color: #909399;
}
.question-free-input {
display: flex;
gap: 8px;
align-items: flex-end;
margin-top: 6px;
}
.question-free-input .el-textarea {
flex: 1;
}
.question-free-input .el-button {
height: 60px;
padding: 0 16px;
flex-shrink: 0;
}
.raw-part {
margin-top: 6px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.03);
padding: 6px 10px;
overflow: hidden;
}
.raw-part-type {
font-size: 11px;
color: #999;
font-weight: bold;
display: block;
margin-bottom: 4px;
}
.raw-part-content {
margin: 0;
font-size: 12px;
color: #555;
white-space: pre-wrap;
word-break: break-all;
font-family: monospace;
}
.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>