98 lines
6.4 KiB
TypeScript
98 lines
6.4 KiB
TypeScript
import { MessageDelivery } from '@/components/message-delivery'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import type { FormEvent } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import { Send } from 'lucide-react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
|
import { useWorkbench } from '@/lib/context'
|
|
import { request, errorText, messageKey } from '@/lib/api'
|
|
import { useLocalDraft } from '@/lib/use-local-draft'
|
|
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 local = useLocalDraft(state.instance_id ? [state.instance_id, 'communication', selected] : null, { text: '', attempt: undefined as {text:string;key:string}|undefined }, (v): v is {text:string;attempt?:{text:string;key:string}} => Boolean(v && typeof v === 'object' && typeof (v as {text?:unknown}).text === 'string'))
|
|
const [error, setError] = useState('')
|
|
const [sending, setSending] = useState(false)
|
|
const [refreshVersion, setRefreshVersion] = useState(0)
|
|
const sendingRef = useRef(false)
|
|
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])
|
|
useEffect(() => {
|
|
const reload = () => { setRefreshVersion(n => n + 1); void refresh().catch(e => setError(errorText(e))) }
|
|
window.addEventListener('workbench-refresh-conversation', reload)
|
|
return () => window.removeEventListener('workbench-refresh-conversation', reload)
|
|
}, [refresh])
|
|
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 decisions = (state as { decisions?: { id: string; status: string }[] }).decisions?.filter(d => ['open','needs_reconsideration'].includes(d.status)) || []
|
|
const text = local.value.text
|
|
async function send(event: FormEvent) {
|
|
event.preventDefault()
|
|
if (!session || !text.trim() || sendingRef.current || !local.ready) return
|
|
const sid = selected, body = text
|
|
const attempt = local.value.attempt?.text === body ? local.value.attempt : {text:body,key:messageKey()}
|
|
if (!local.save({text:body,attempt})) return
|
|
sendingRef.current = true
|
|
setSending(true); setError('')
|
|
try {
|
|
await request(token, '/api/messages', { session_id:sid, text:body, idempotency_key:attempt.key, source:'human' })
|
|
local.clear()
|
|
setRefreshVersion(n => n + 1)
|
|
await refresh()
|
|
} catch (e) { setError(errorText(e)) }
|
|
finally { sendingRef.current=false; setSending(false) }
|
|
}
|
|
return <div className="flex min-h-0 w-full flex-1 flex-col">
|
|
|
|
{decisions.length > 0 && <p className="shrink-0 border-b px-4 py-3 text-sm"><Link className="text-primary underline underline-offset-4" to={`/decisions?decision=${encodeURIComponent(decisions[0].id)}`}>有 {decisions.length} 项需要你决定,查看决策</Link></p>}
|
|
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
|
|
<section className="flex min-h-0 flex-1 flex-col bg-card">
|
|
<div ref={scroll} role="log" aria-label="对话" aria-live="polite" className="min-h-0 flex-1 space-y-6 overflow-y-auto px-4 py-5 sm:px-8">
|
|
{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.log_id && <Link className="mt-2 inline-block text-sm text-primary underline underline-offset-4" to={`/logs?incident=${encodeURIComponent(m.log_id)}`}>查看日志</Link>}
|
|
<MessageDelivery message={m} messages={items} />
|
|
</div>
|
|
</article>) : <div className="flex h-full items-center justify-center text-sm text-muted-foreground">{known ? '有什么想了解的?从这里开始交流。' : '会话准备好后,即可在这里交流。'}</div>}
|
|
</div>
|
|
<form onSubmit={send} className="shrink-0 border-t p-3 sm:px-8">
|
|
{local.error && <p role="status" className="mb-2 text-xs text-destructive">{local.error}</p>}<Textarea id="message" aria-label="输入消息" value={text} disabled={sending} onChange={e => local.save({...local.value,text:e.target.value})} placeholder="输入你的问题或想法…" maxLength={32000} rows={2} 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 || !local.ready || sending || !text.trim()}><Send />{sending ? '发送中…' : '发送'}</Button></div>
|
|
</form>
|
|
</section>
|
|
</div>
|
|
}
|