feat: Reginald is real — model router + agent loop + query_project (P4)
The fixture chat panel is now a working agent. Ask Reginald a question and it consults the real project through a tool loop, then answers in grounded prose. Read-only v0 — writes still go through the propose-approve controls. core (@commitea/core/agent): - chat-client: OpenAI-wire chat completions over an injected fetch (same seam as gitea). Points at any OpenAI-compatible endpoint (LM Studio/Ollama/OpenAI). - model-router: small model for prose + the read tool; big model reserved for later decomposition (pickModel). - agent-loop: runAgentTurn drives call→tool→result→call until prose (or a step budget), recording each tool step. Injected complete + execute → fully testable. - query-project: the single read tool's engine — compact focus/board/calibration/ issue/search views built from scheduler + lifecycle + calibration; unbuilt views return a notImplemented marker (never fabricated). The model reports, never computes. - agent-tools: query_project declaration + Reginald's system prompt. app: - main model bridge (model:status, model:chat) runs the loop; query_project reconciles the repo and builds the view. Model traffic stays in main (token/CSP). gitea.ts refactored to share getGiteaClient + reconcileSnapshot. - preload + global.d.ts expose the model bridge; useChat drives the panel — real agent turn when a model is configured, scripted fixture reply otherwise (so fixture e2e is unchanged). A subtle "consulted the project" activity line. Model config (env, defaults to LM Studio on :1234): COMMITEA_MODEL_URL / _SMALL (google/gemma-4-e4b) / _BIG (qwen/qwen3.6-35b-a3b). COMMITEA_E2E=1 keeps it unconfigured so the panel stays scripted. Verified: 88 core tests green (14 agent: client parse, loop tool/error/budget, all views) + a gated live integration test. Desktop typecheck clean, 14 fixture e2e green. Gated live e2e drives the real app against gitea + gemma-4-e4b: asked "what now?", Reginald called query_project and answered "focus is on issue #2" (the real scheduler pick). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { CANNED_REPLY, CHAT, type ChatMessage } from '../../data/fixtures.js'
|
||||
import { useChat } from '../../lib/use-chat.js'
|
||||
import { Icon, IconButton } from '../ui/index.js'
|
||||
|
||||
/**
|
||||
* Reginald's panel — chat is the write-path (decisions.md D1). This is the P3-2
|
||||
* fixture shell: it echoes a canned reply so the layout + interactions are real,
|
||||
* but no model is wired. P4 replaces `send` with the model router + tools.
|
||||
* Reginald's panel — chat is the write-path (decisions.md D1). Wired to the
|
||||
* model bridge via `useChat`: when a model is configured, sending drives a real
|
||||
* agent turn (query_project + prose); otherwise it echoes the scripted fixture
|
||||
* reply so the layout stays real. Writes still go through propose-approve.
|
||||
*/
|
||||
export interface ChatPanelProps {
|
||||
onOpenDirectives?: () => void
|
||||
@@ -14,9 +15,8 @@ export interface ChatPanelProps {
|
||||
}
|
||||
|
||||
export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) {
|
||||
const [msgs, setMsgs] = useState<ChatMessage[]>(CHAT)
|
||||
const { msgs, thinking, live, steps, send: sendChat } = useChat()
|
||||
const [text, setText] = useState('')
|
||||
const [thinking, setThinking] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -27,13 +27,8 @@ export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) {
|
||||
const send = () => {
|
||||
const t = text.trim()
|
||||
if (!t) return
|
||||
setMsgs((m) => [...m, { from: 'user', text: t }])
|
||||
setText('')
|
||||
setThinking(true)
|
||||
setTimeout(() => {
|
||||
setThinking(false)
|
||||
setMsgs((m) => [...m, { from: 'agent', text: CANNED_REPLY }])
|
||||
}, 900)
|
||||
sendChat(t)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -61,7 +56,7 @@ export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) {
|
||||
<Icon name="sparkles" size={16} style={{ color: offline ? 'var(--ink-3)' : 'var(--jade)' }} />
|
||||
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>Reginald</span>
|
||||
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', marginLeft: 'auto' }}>
|
||||
{offline ? 'offline · queueing' : 'gemma-4b · local'}
|
||||
{offline ? 'offline · queueing' : live ? 'gemma-4 · local' : 'demo · scripted'}
|
||||
</span>
|
||||
<IconButton icon="history" label="Directive log" size="sm" onClick={onOpenDirectives} />
|
||||
</header>
|
||||
@@ -98,6 +93,11 @@ export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) {
|
||||
</div>
|
||||
) : null}
|
||||
{thinking ? <div style={{ font: 'var(--text-agent)', color: 'var(--ink-3)' }}>considering…</div> : null}
|
||||
{!thinking && steps.length ? (
|
||||
<div style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<Icon name="eye" size={11} /> consulted {Array.from(new Set(steps.map((s) => s.replace('query_project', 'the project')))).join(', ')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 14, borderTop: '1px solid var(--line-1)', flexShrink: 0 }}>
|
||||
|
||||
14
apps/desktop/src/renderer/src/global.d.ts
vendored
14
apps/desktop/src/renderer/src/global.d.ts
vendored
@@ -1,4 +1,6 @@
|
||||
import type {
|
||||
AgentStep,
|
||||
ChatMessage,
|
||||
DependencyEdge,
|
||||
GiteaIssue,
|
||||
GiteaMilestone,
|
||||
@@ -27,11 +29,23 @@ export interface GiteaBridge {
|
||||
applyChange(change: IssueChange): Promise<ApplyChangeResult>
|
||||
}
|
||||
|
||||
/** One agent turn's result. */
|
||||
export type ChatResult =
|
||||
| { ok: false; reason: 'unconfigured' | 'error'; message?: string }
|
||||
| { ok: true; content: string; steps: AgentStep[] }
|
||||
|
||||
/** The model bridge (Reginald) exposed by the preload over IPC. */
|
||||
export interface ModelBridge {
|
||||
status(): Promise<{ configured: boolean; model: string | null }>
|
||||
chat(messages: ChatMessage[]): Promise<ChatResult>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
commitea: {
|
||||
platform: string
|
||||
gitea: GiteaBridge
|
||||
model: ModelBridge
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
101
apps/desktop/src/renderer/src/lib/use-chat.ts
Normal file
101
apps/desktop/src/renderer/src/lib/use-chat.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { ChatMessage as WireMessage } from '@commitea/core'
|
||||
|
||||
import { CANNED_REPLY, CHAT, type ChatMessage } from '../data/fixtures.js'
|
||||
|
||||
const LIVE_GREETING: ChatMessage = {
|
||||
from: 'agent',
|
||||
text: 'Morning. Ask me anything about the project — I check the real board before I answer.',
|
||||
}
|
||||
|
||||
export interface ChatState {
|
||||
msgs: ChatMessage[]
|
||||
thinking: boolean
|
||||
/** true once a model endpoint is confirmed; otherwise the panel echoes the demo reply. */
|
||||
live: boolean
|
||||
/** Tools Reginald consulted on the last turn (for a subtle activity line). */
|
||||
steps: string[]
|
||||
send: (text: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Reginald's conversation. When a model is configured, `send` drives one agent
|
||||
* turn through the main-process bridge (which runs the tool loop). Otherwise it
|
||||
* echoes the scripted fixture reply, so the layout stays real with no model and
|
||||
* fixture e2e is unaffected. The fixture greeting is display-only — only real
|
||||
* turns (`convo`) are sent to the model as history.
|
||||
*/
|
||||
export function useChat(): ChatState {
|
||||
const [seed, setSeed] = useState<ChatMessage[]>(CHAT)
|
||||
const [convo, setConvo] = useState<ChatMessage[]>([])
|
||||
const [thinking, setThinking] = useState(false)
|
||||
const [live, setLive] = useState(false)
|
||||
const [steps, setSteps] = useState<string[]>([])
|
||||
const convoRef = useRef(convo)
|
||||
convoRef.current = convo
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
window.commitea.model
|
||||
.status()
|
||||
.then((s) => {
|
||||
if (alive && s.configured) {
|
||||
setLive(true)
|
||||
setSeed([LIVE_GREETING])
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const send = useCallback(
|
||||
(raw: string) => {
|
||||
const text = raw.trim()
|
||||
if (!text) return
|
||||
const nextConvo: ChatMessage[] = [...convoRef.current, { from: 'user', text }]
|
||||
setConvo(nextConvo)
|
||||
setThinking(true)
|
||||
setSteps([])
|
||||
|
||||
if (!live) {
|
||||
window.setTimeout(() => {
|
||||
setThinking(false)
|
||||
setConvo((c) => [...c, { from: 'agent', text: CANNED_REPLY }])
|
||||
}, 900)
|
||||
return
|
||||
}
|
||||
|
||||
const wire: WireMessage[] = nextConvo.map((m) => ({
|
||||
role: m.from === 'user' ? 'user' : 'assistant',
|
||||
content: m.text,
|
||||
}))
|
||||
window.commitea.model
|
||||
.chat(wire)
|
||||
.then((res) => {
|
||||
setThinking(false)
|
||||
if (res.ok) {
|
||||
setSteps(res.steps.map((s) => s.tool))
|
||||
setConvo((c) => [...c, { from: 'agent', text: res.content || '…' }])
|
||||
} else {
|
||||
setConvo((c) => [
|
||||
...c,
|
||||
{
|
||||
from: 'agent',
|
||||
text: res.reason === 'error' ? `I hit a snag: ${res.message ?? 'unknown error'}` : 'No model is configured.',
|
||||
},
|
||||
])
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setThinking(false)
|
||||
setConvo((c) => [...c, { from: 'agent', text: 'I could not reach the model.' }])
|
||||
})
|
||||
},
|
||||
[live],
|
||||
)
|
||||
|
||||
return { msgs: [...seed, ...convo], thinking, live, steps, send }
|
||||
}
|
||||
Reference in New Issue
Block a user