feat: 对话功能开发

This commit is contained in:
2026-04-12 15:01:06 +08:00
parent d907a37c2d
commit ca389824a1
4 changed files with 573 additions and 51 deletions

View File

@@ -9,25 +9,102 @@
<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 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.reasoning" :key="idx" class="reasoning-item">
<div v-for="(r, idx) in msg.parts.filter((p) => p.type === 'reasoning')" :key="idx" class="reasoning-item">
{{ r.text }}
</div>
</div>
</div>
<!-- 文本内容 -->
<pre class="bubble-text">{{ msg.text }}</pre>
<pre v-if="msg.text" class="bubble-text">{{ msg.text }}</pre>
<!-- 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 class="input-area">
<div v-if="!hasActiveQuestion" class="input-area">
<el-input
v-model="inputText"
type="textarea"
@@ -43,7 +120,7 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
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';
@@ -60,6 +137,18 @@ 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
@@ -92,38 +181,22 @@ async function loadHistoryMessages(sessionId) {
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('');
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: partsByType, // 存储分类后的 parts方便后续按 type 渲染
rawParts: parts, // 保留原始 parts
showReasoning: false, // 默认折叠推理过程
parts: normalizedParts,
showReasoning: true, // 默认展开推理过程
});
// 记录 assistant 消息 ID
@@ -181,13 +254,32 @@ function scrollToBottom() {
});
}
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 });
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();
}
@@ -199,12 +291,12 @@ 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) 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 || '');
upsertAssistantPart(part.messageID, part);
});
// 监听消息更新事件
@@ -227,8 +319,31 @@ function registerSSEListeners() {
}
});
// 监听 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);
unsubscribeCallbacks.push(unsubscribePartUpdated, unsubscribeMessageUpdated, unsubscribeSessionIdle, unsubscribeQuestionAsked);
}
/**
@@ -243,6 +358,21 @@ function unregisterSSEListeners() {
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;
@@ -399,6 +529,243 @@ onUnmounted(() => {
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;