Schedule consultation bridges on demand and preserve delivery evidence

This commit is contained in:
aiagent
2026-10-20 00:39:12 -07:00
parent 557f1ee03c
commit 2f633869fe
18 changed files with 483 additions and 56 deletions
@@ -1,3 +1,4 @@
import { MessageDelivery } from '@/components/message-delivery'
import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
@@ -44,7 +45,7 @@ export default function DecisionChat({ decision, token }: { decision: Decision;
}
return <section className="flex min-w-0 flex-col rounded-xl border bg-card" aria-label="本决策咨询聊天"><div className="flex items-center justify-between border-b p-3"><h3 className="font-medium">本决策咨询</h3><Button size="sm" variant="ghost" disabled={!bound} onClick={() => void refresh.current()}>刷新</Button></div>
{(!bound || readOnly) && <p className="px-3 pt-3 text-xs text-muted-foreground">{!bound ? '咨询暂不可用。' : '已完成,只读。'}</p>}
<div ref={scroll} role="log" aria-label="本决策消息" className="max-h-[45vh] min-h-40 space-y-3 overflow-y-auto p-3">{messages.map(message => <article key={message.id} className={`rounded-lg p-3 text-sm ${message.source === 'human' ? 'ml-6 bg-primary text-primary-foreground' : 'mr-6 bg-muted'}`}><p className="mb-1 text-xs opacity-70">{message.source === 'human' ? '你' : '咨询助手'}</p><p className="whitespace-pre-wrap break-words">{message.text}</p>{message.source === 'human' && ['queued','claimed'].includes(message.status) && <p className="mt-1 text-xs">等待回复…</p>}{['failed','uncertain'].includes(message.status) && <p className="mt-1 text-xs">回复尚未确认,请勿重复发送。</p>}</article>)}{!messages.length && <p className="text-sm text-muted-foreground">{!bound ? '暂无已接通的咨询会话。' : loaded ? '暂无咨询消息。' : '正在读取咨询历史…'}</p>}</div>
<div ref={scroll} role="log" aria-label="本决策消息" className="max-h-[45vh] min-h-40 space-y-3 overflow-y-auto p-3">{messages.map(message => <article key={message.id} className={`rounded-lg p-3 text-sm ${message.source === 'human' ? 'ml-6 bg-primary text-primary-foreground' : 'mr-6 bg-muted'}`}><p className="mb-1 text-xs opacity-70">{message.source === 'human' ? '你' : '咨询助手'}</p><p className="whitespace-pre-wrap break-words">{message.text}</p><MessageDelivery message={message} messages={messages} /></article>)}{!messages.length && <p className="text-sm text-muted-foreground">{!bound ? '暂无已接通的咨询会话。' : loaded ? '暂无咨询消息。' : '正在读取咨询历史…'}</p>}</div>
{(error || draft.error) && <p role="status" className="px-3 pb-3 text-sm text-destructive">{draft.error || error}</p>}
<div className="space-y-2 px-3 pb-3">{local.error && <p role="status" className="text-xs text-destructive">{local.error}</p>}{local.error && <Button variant="outline" size="sm" disabled={draft.sending} onClick={() => persist()}>重试保存草稿</Button>}{draft.revision !== revision && <div className="text-sm"><p>这是版本 {draft.revision} 的咨询草稿,请重新确认当前问题。</p>{!readOnly && <Button variant="outline" size="sm" onClick={() => {draft.revision=revision;draft.attempt=undefined;persist()}}>确认用于当前版本</Button>}</div>}{readOnly && draft.text && <p className="whitespace-pre-wrap text-sm">保留的未发送草稿(只读):{draft.text}</p>}</div>
{!readOnly && <form className="space-y-2 border-t p-3" onSubmit={event => { event.preventDefault(); void send() }}><Textarea aria-label="本决策咨询问题" value={draft.text} disabled={!bound || draft.sending} maxLength={32000} onChange={event => { draft.text = event.target.value; persist() }} placeholder="询问方案、依据或影响…" /><Button type="submit" disabled={!bound || !local.ready || draft.revision !== revision || draft.sending || !draft.text.trim()}>{draft.sending ? '正在发送…' : '发送咨询'}</Button></form>}
@@ -0,0 +1,10 @@
import type { Message } from '@/lib/types'
import { messageDelivery } from '@/lib/message-delivery'
export function MessageDelivery({ message, messages }: { message: Message; messages: Message[] }) {
if (message.source !== 'human') return null
const delivery = messageDelivery(message, messages)
return <details className="mt-2 text-xs opacity-80" open={delivery.waiting || undefined}>
<summary className="cursor-pointer select-none">{delivery.label}</summary>
<p className="mt-1 leading-5">{delivery.reason || (delivery.stage === 'replied' ? '已收到此消息的回复。' : delivery.stage === 'delivered' ? '已进入会话,等待处理与回复。' : delivery.stage === 'processing' ? '会话正在处理,尚未收到回复。' : delivery.stage === 'queued' ? '已在服务端保存,等待会话处理。' : '已保存,尚未确认送达。')}</p>
</details>
}
@@ -0,0 +1,17 @@
import type { Message } from './types'
export type DeliveryStage = 'saved' | 'queued' | 'delivered' | 'processing' | 'replied' | 'blocked' | 'unknown'
export const deliveryLabels: Record<DeliveryStage, string> = { saved:'已保存', queued:'排队中', delivered:'已送达', processing:'处理中', replied:'已回复', blocked:'受阻', unknown:'待核查' }
export function messageDelivery(message: Message, messages: Message[]) {
const replied = messages.some(row => row.source === 'agent' && row.reply_to === message.id && row.session_id === message.session_id)
let stage: DeliveryStage
if (replied) stage = 'replied'
else if (message.delivery_stage && message.delivery_stage in deliveryLabels) stage = message.delivery_stage
else if (['failed','uncertain','blocked'].includes(message.status)) stage = 'blocked'
else if (message.status === 'queued') stage = 'queued'
else if (message.status === 'delivered' || message.submission?.state === 'accepted') stage = 'delivered'
else if (message.status === 'claimed') stage = 'queued' // A lease alone is not a running native turn.
else if (message.status === 'saved') stage = 'saved'
else stage = 'unknown' // Transport completion alone does not prove a visible reply.
const reason = stage === 'blocked' ? (message.delivery_reason || '结果尚未确认,正在核查;请勿重复发送。') : stage === 'unknown' ? '暂无法确认消息阶段,请刷新查看。' : ''
return {stage, label:deliveryLabels[stage], reason, waiting:['queued','processing','blocked','unknown'].includes(stage)}
}
+1 -1
View File
@@ -8,7 +8,7 @@ export type Task = {
integration_status?: 'not_started' | 'waiting' | 'integrating' | 'integrated' | 'verified'; id: string; name: string; status: string; phase: string; eta_min_minutes?: number | null; eta_max_minutes?: number | null; eta_basis?: string; eta_estimated_at?: number | null; eta_revision?: number; eta_change_reason?: string; eta_last_reviewed_at?: number | null; eta_review_status?: string; current_progress?: string; progress_updated?: number | null; decision?: string; updated?: number }
export type Session = { id: string; name: string; wp?: string; role?: string; thread_id?: string; thread_url?: string; connection?: string; history_synced_at?: number }
export type Event = { id: number; kind: string; text: string; created: number; session_id?: string }
export type Message = { log_id?: string | null; id: string; session_id: string; text: string; source: 'human' | 'agent'; status: string; created: number; origin?: string; reply_to?: string }
export type Message = { delivery_stage?: 'saved' | 'queued' | 'delivered' | 'processing' | 'replied' | 'blocked'; delivery_reason?: string; submission?: {state?: string}; log_id?: string | null; id: string; session_id: string; text: string; source: 'human' | 'agent'; status: string; created: number; origin?: string; reply_to?: string }
export type Sample<T> = { host?: string; status?: string; error?: string; collected_at?: string; data?: T | null }
export type NodeMetric = { memory_used_percent?: number; memory_total_bytes?: number; memory_available_bytes?: number; load_1m?: number; load_5m?: number; load_15m?: number }
export type Mount = { mount_target: string; used_percent?: number; total_bytes?: number; available_bytes?: number }
+3 -3
View File
@@ -1,3 +1,4 @@
import { MessageDelivery } from '@/components/message-delivery'
import { useEffect, useRef, useState } from 'react'
import type { FormEvent } from 'react'
import { Link } from 'react-router-dom'
@@ -83,14 +84,13 @@ export default function Communication() {
<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>}
{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>}
<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>
<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>
@@ -0,0 +1,9 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { messageDelivery } from '../src/lib/message-delivery.ts'
const m={id:'m',session_id:'s',source:'human',text:'question',status:'queued'}
test('each explicit transport stage remains separate',()=>{for(const stage of ['saved','queued','delivered','processing','replied','blocked'])assert.equal(messageDelivery({...m,delivery_stage:stage},[]).stage,stage)})
test('receipt accepted proves delivery but never processing or reply',()=>{assert.equal(messageDelivery({...m,status:'claimed',submission:{state:'accepted'}},[]).stage,'delivered');assert.equal(messageDelivery({...m,status:'completed'},[]).stage,'unknown')})
test('only real correlated same-session reply closes pending state',()=>{const reply={...m,id:'a',source:'agent',reply_to:'m'};assert.equal(messageDelivery(m,[reply]).stage,'replied');assert.equal(messageDelivery(m,[{...reply,session_id:'other'}]).stage,'queued');assert.equal(messageDelivery(m,[{...reply,reply_to:'other'}]).stage,'queued')})
test('unknown failed and uncertain outcomes never invite replay',()=>{for(const status of ['failed','uncertain']){const result=messageDelivery({...m,status},[]);assert.equal(result.stage,'blocked');assert.match(result.reason,/请勿重复发送/)}assert.equal(messageDelivery({...m,status:'future'},[]).stage,'unknown')})
test('delayed blocked receipt yields to a correlated reply',()=>{assert.equal(messageDelivery({...m,status:'uncertain',delivery_stage:'blocked'},[{...m,id:'a',source:'agent',reply_to:'m'}]).stage,'replied')})