Files
electron-opencode/src/renderer/views/chat/ChatView.vue
2026-04-09 21:35:06 +08:00

315 lines
6.9 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 class="status-bar">
<div class="status-indicator">
<span class="dot" :class="serviceStatus" />
<span class="status-text">{{ statusText }}</span>
</div>
<div class="status-actions">
<el-button v-if="!isRunning" size="small" type="primary" :loading="isStarting" @click="startService">
启动服务
</el-button>
<el-button v-else size="small" type="danger" plain @click="stopService">
停止服务
</el-button>
</div>
</div>
<!-- 消息列表 -->
<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">
<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="!isRunning || isSending"
resize="none"
@keydown.ctrl.enter.prevent="send"
/>
<el-button
type="primary"
:disabled="!isRunning || isSending || !inputText.trim()"
:loading="isSending"
@click="send"
>
发送
</el-button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onUnmounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { ChatDotRound } from '@element-plus/icons-vue'
import { createEventSource } from '@/http/api.js'
const isRunning = ref(false)
const isStarting = ref(false)
const isSending = ref(false)
const inputText = ref('')
const messages = ref([])
const messagesRef = ref(null)
const currentSessionId = ref(null)
let eventSource = null
const serviceStatus = computed(() => {
if (isStarting.value) return 'starting'
return isRunning.value ? 'running' : 'stopped'
})
const statusText = computed(() => {
if (isStarting.value) return '正在启动...'
return isRunning.value ? '服务运行中' : '服务未启动'
})
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()
}
function connectSSE() {
if (eventSource) eventSource.close()
eventSource = createEventSource()
eventSource.onmessage = (e) => {
try {
const data = JSON.parse(e.data)
const props = data.properties || {}
if (data.type === 'message.part.updated') {
const part = props.part
if (!part || part.type !== 'text') return
if (part.sessionID !== currentSessionId.value) return
upsertAssistantBubble(part.messageID, part.text || '')
}
if (data.type === 'message.completed') {
isSending.value = false
}
} catch (_) {}
}
eventSource.onerror = () => {
isSending.value = false
}
}
async function startService() {
isStarting.value = true
try {
const info = await window.opencode.start()
isRunning.value = info.running
// 更新 baseUrl 供 http 层使用
if (info.url) window.__opencodeBaseUrl = info.url
connectSSE()
ElMessage.success('服务已启动')
} catch (err) {
ElMessage.error(`启动失败: ${err.message}`)
} finally {
isStarting.value = false
}
}
async function stopService() {
await window.opencode.stop()
isRunning.value = false
currentSessionId.value = null
messages.value = []
if (eventSource) { eventSource.close(); eventSource = null }
ElMessage.info('服务已停止')
}
async function send() {
const text = inputText.value.trim()
if (!text || isSending.value) return
// 首次发送时创建会话
if (!currentSessionId.value) {
try {
const session = await window.opencode.createSession()
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.sendMessage(currentSessionId.value, text)
} catch (err) {
ElMessage.error(`发送失败: ${err.message}`)
isSending.value = false
}
}
// 初始化时同步服务状态
window.opencode?.info().then((info) => {
isRunning.value = info.running
if (info.running) {
if (info.url) window.__opencodeBaseUrl = info.url
connectSSE()
}
}).catch(() => {})
onUnmounted(() => {
if (eventSource) eventSource.close()
})
</script>
<style scoped>
.chat-page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
gap: 12px;
}
.status-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: #fff;
border-radius: 10px;
border: 1px solid #e4e7ed;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #606266;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot.stopped { background: #c0c4cc; }
.dot.starting { background: #e6a23c; animation: pulse 1s infinite; }
.dot.running { background: #67c23a; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.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;
}
.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>