Files
agent-workbench/assets/dashboard/src/pages/communication.tsx
T

87 lines
5.6 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import type { FormEvent } from 'react'
import { Send, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { PageHeading } from '@/components/shared'
import { useWorkbench } from '@/lib/context'
import { request, errorText, messageKey } from '@/lib/api'
import type { Message } from '@/lib/types'
export default function Communication() {
const { state, token, refresh } = useWorkbench()
const primary = state.primary_session_id || 'human-progress'
const selected = primary
const session = state.sessions.find(s => s.id === selected)
const [messages, setMessages] = useState<{ session: string; items: Message[] }>({ session: '', items: [] })
const [drafts, setDrafts] = useState<Record<string, string>>({})
const [error, setError] = useState('')
const [sending, setSending] = useState(false)
const [refreshVersion, setRefreshVersion] = useState(0)
const pending = useRef<{ session_id: string; text: string; idempotency_key: string } | null>(null)
const scroll = useRef<HTMLDivElement>(null)
const loaded = useRef('')
const known = Boolean(session)
useEffect(() => {
if (!known) return
const controller = new AbortController()
let inFlight = false
const poll = async () => {
if (inFlight) return
inFlight = true
try {
const result = await request<{ messages: Message[] }>(token, `/api/conversation?session_id=${encodeURIComponent(selected)}`, undefined, controller.signal)
if (!controller.signal.aborted) { setMessages({ session: selected, items: result.messages }); setError('') }
} catch (e) { if (!controller.signal.aborted) setError(errorText(e)) }
finally { inFlight = false }
}
void poll()
const timer = setInterval(() => void poll(), 3000)
return () => { controller.abort(); clearInterval(timer) }
}, [selected, token, known, refreshVersion])
const items = messages.session === selected ? messages.items : []
const lastId = items.at(-1)?.id
useEffect(() => {
const box = scroll.current
if (!box || messages.session !== selected) return
if (loaded.current !== selected || box.scrollHeight - box.scrollTop - box.clientHeight < 300) box.scrollTop = box.scrollHeight
loaded.current = selected
}, [lastId, selected, messages.session])
const text = drafts[selected] || ''
async function send(event: FormEvent) {
event.preventDefault()
if (!session || !text.trim() || sending) return
const sid = selected, body = text
if (!pending.current || pending.current.session_id !== sid || pending.current.text !== body) pending.current = { session_id: sid, text: body, idempotency_key: messageKey() }
setSending(true); setError('')
try {
await request(token, '/api/messages', { ...pending.current, source: 'human' })
pending.current = null
setDrafts(old => old[sid] === body ? { ...old, [sid]: '' } : old)
setRefreshVersion(n => n + 1)
await refresh()
} catch (e) { setError(errorText(e)) }
finally { setSending(false) }
}
return <div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
<PageHeading title="项目沟通" description="询问进度、讨论方案,或告诉我你的新想法。" action={<Button variant="ghost" size="sm" onClick={() => { setRefreshVersion(n => n + 1); void refresh() }}><RefreshCw />刷新</Button>} />
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
<section className="overflow-hidden rounded-xl border bg-card">
<div ref={scroll} role="log" aria-label="对话" aria-live="polite" className="h-[min(60vh,660px)] min-h-72 space-y-6 overflow-y-auto p-5 sm:p-7">
{items.length ? items.map(m => <article key={m.id} className={`flex ${m.source === 'human' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[92%] rounded-2xl px-4 py-3 sm:max-w-[85%] ${m.source === 'human' ? 'bg-primary text-primary-foreground' : 'bg-muted/60'}`}>
<p className="mb-1 text-xs opacity-65">{m.source === 'human' ? '你' : '项目助手'}</p>
<p className="whitespace-pre-wrap wrap-anywhere text-sm leading-7">{m.text}</p>
{m.source === 'human' && ['queued', 'claimed'].includes(m.status) && <p className="mt-2 text-xs opacity-70">正在等待回复…</p>}
{m.source === 'human' && ['failed', 'uncertain'].includes(m.status) && <p className="mt-2 text-xs opacity-70">暂未确认回复,请稍后查看;无需重复发送。</p>}
</div>
</article>) : <div className="flex h-full items-center justify-center text-sm text-muted-foreground">{known ? '有什么想了解的?从这里开始交流。' : '会话准备好后,即可在这里交流。'}</div>}
</div>
<form onSubmit={send} className="border-t p-4">
<Textarea id="message" aria-label="输入消息" value={text} onChange={e => setDrafts(old => ({ ...old, [selected]: e.target.value }))} placeholder="输入你的问题或想法…" maxLength={32000} rows={3} required className="resize-none border-0 shadow-none focus-visible:ring-0" />
<div className="mt-2 flex items-center justify-between gap-3"><p className="text-xs text-muted-foreground">{!known ? '正在准备会话' : session?.connection === 'connected' ? '' : '暂时离线,消息会保存并等待回复。'}</p><Button type="submit" disabled={!known || sending || !text.trim()}><Send />{sending ? '发送中…' : '发送'}</Button></div>
</form>
</section>
</div>
}