feat(m2): wire ChatPanel to stream LLM responses — thinking state, token accumulation, error display

This commit is contained in:
pyr0ball 2026-05-19 07:28:04 -07:00
parent 0e3bfd24f1
commit cba4175260

View file

@ -26,8 +26,11 @@
class="chat-input"
placeholder="Ask Robin something..."
@keydown.enter="send"
:disabled="thinking"
/>
<button class="send-btn" @click="send"></button>
<button class="send-btn" @click="send" :disabled="thinking">
{{ thinking ? '…' : '→' }}
</button>
</footer>
</div>
</template>
@ -42,13 +45,12 @@ interface RobinEvent { pattern_id: string; title: string; body: string; severity
const messages = ref<Message[]>([])
const input = ref('')
const thinking = ref(false)
const messagesEl = ref<HTMLElement | null>(null)
let unlisten: UnlistenFn | null = null
let activeCleanup: (() => void) | null = null
onMounted(async () => {
// Signal backend: stop queuing events into PENDING while panel is open.
// Must happen before drain so new events that arrive during mount are not
// double-delivered on the next open cycle.
try { await invoke('panel_opened') } catch {}
try {
@ -60,7 +62,6 @@ onMounted(async () => {
console.warn('Robin: failed to drain pending events:', err)
}
// Always set up the live listener, even if drain failed
unlisten = await listen<RobinEvent>('robin:event', ({ payload }) => {
pushRobinEvent(payload)
})
@ -69,6 +70,7 @@ onMounted(async () => {
onUnmounted(() => {
unlisten?.()
invoke('panel_closed').catch(() => {})
activeCleanup?.()
})
function pushRobinEvent(e: RobinEvent) {
@ -80,13 +82,55 @@ function pushRobinEvent(e: RobinEvent) {
async function send() {
const text = input.value.trim()
if (!text) return
if (!text || thinking.value) return
input.value = ''
thinking.value = true
messages.value.push({ role: 'user', content: text })
// M4: invoke('chat', { message: text }) and stream response
messages.value.push({ role: 'robin', content: '(LLM chat not yet connected — M4)' })
messages.value.push({ role: 'robin', content: '' })
const robinIdx = messages.value.length - 1
await nextTick()
messagesEl.value?.scrollTo({ top: messagesEl.value.scrollHeight, behavior: 'smooth' })
let unlistenToken: UnlistenFn | null = null
let unlistenDone: UnlistenFn | null = null
let unlistenError: UnlistenFn | null = null
function cleanup() {
unlistenToken?.()
unlistenDone?.()
unlistenError?.()
thinking.value = false
activeCleanup = null
}
activeCleanup = cleanup
unlistenToken = await listen<string>('robin:chat-token', ({ payload }) => {
messages.value[robinIdx].content += payload
nextTick(() => {
messagesEl.value?.scrollTo({ top: messagesEl.value.scrollHeight, behavior: 'smooth' })
})
})
unlistenDone = await listen('robin:chat-done', () => cleanup())
unlistenError = await listen<string>('robin:chat-error', ({ payload }) => {
if (!messages.value[robinIdx].content) {
messages.value[robinIdx].content =
`Robin couldn't reach Ollama. Make sure it's running at the configured URL. (${payload})`
}
cleanup()
})
try {
await invoke('chat', { message: text })
} catch (err) {
if (!messages.value[robinIdx].content) {
messages.value[robinIdx].content = `Something went wrong starting the chat. (${err})`
}
cleanup()
}
}
</script>
@ -154,4 +198,6 @@ async function send() {
cursor: pointer;
}
.send-btn:hover { opacity: 0.85; }
.send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.chat-input:disabled { opacity: 0.6; cursor: not-allowed; }
</style>