Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a36a49290 | ||
|
|
9d69629bc8 |
@@ -169,3 +169,9 @@ description: 组织项目的多个 AI 会话并行工作,将进度、证据和
|
|||||||
主控检查后记录结论、交接及证据才完成;完成后由获授权总控调用宿主真实归档工具,再保存回执,服务记录不冒充归档操作。隔离测试不能代替真实会话绑定与往返,尚未验证必须明示;没有真实待决定事项时不得为验收创建生产决策或会话。
|
主控检查后记录结论、交接及证据才完成;完成后由获授权总控调用宿主真实归档工具,再保存回执,服务记录不冒充归档操作。隔离测试不能代替真实会话绑定与往返,尚未验证必须明示;没有真实待决定事项时不得为验收创建生产决策或会话。
|
||||||
|
|
||||||
已有专门人类入口时,主控、副主控和执行者不得直接向用户提问;问题、待选择、待授权及关键结论定向交唯一入口,由入口理解并与人类交互,再整理给主控,不经过底层事件队列。需选项先建真实决策卡,入口简短链接。决策页内嵌咨询只回答该决策范围问题,不另设一般项目入口;人类决定和审批仍走决策卡与唯一入口。
|
已有专门人类入口时,主控、副主控和执行者不得直接向用户提问;问题、待选择、待授权及关键结论定向交唯一入口,由入口理解并与人类交互,再整理给主控,不经过底层事件队列。需选项先建真实决策卡,入口简短链接。决策页内嵌咨询只回答该决策范围问题,不另设一般项目入口;人类决定和审批仍走决策卡与唯一入口。
|
||||||
|
|
||||||
|
## 网页草稿持久边界
|
||||||
|
|
||||||
|
沟通输入、决策意见与选择、决策咨询输入应在浏览器本地持久保存,按实例、目标会话、决策与内容版本隔离;退出页面、刷新及重新登录后恢复同范围草稿。草稿不是已提交事实,不上传草稿,不将token写入草稿记录。仅收到成功确认后清除对应提交的草稿,或由用户明确清除单项;失败和结果未知保留正文与幂等重试信息,禁止自动重发。内容版本变化保留旧稿供明确重确认,不能静默覆盖新版本或沿用旧选择作为批准。
|
||||||
|
|
||||||
|
本地存储不可用时如实提示,不能宣称已保存;本地浏览器数据被清理、设备更换或旧版未持久化的已丢稿不承诺恢复。提供明确的单项草稿清除入口。已有唯一人类入口时,相关限制与所需用户操作仍由该入口沟通。
|
||||||
|
|||||||
@@ -231,6 +231,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
state['primary_session_id'] = self.server.primary_session_id
|
state['primary_session_id'] = self.server.primary_session_id
|
||||||
state['metrics'] = self.server.metrics.snapshot()
|
state['metrics'] = self.server.metrics.snapshot()
|
||||||
state['eta_clock'] = dict(self.server.eta_clock.status)
|
state['eta_clock'] = dict(self.server.eta_clock.status)
|
||||||
|
try:
|
||||||
|
entry = re.search(r'<script\b[^>]*\bsrc="(/assets/[^"/]+\.js)"', (self.server.web_root/'index.html').read_text(encoding='utf-8'))
|
||||||
|
state['frontend_entrypoint'] = entry.group(1) if entry else None
|
||||||
|
except OSError:
|
||||||
|
state['frontend_entrypoint'] = None
|
||||||
self.output(200, state)
|
self.output(200, state)
|
||||||
elif parts.path == '/api/decision-bridge-bindings' and not parts.query:
|
elif parts.path == '/api/decision-bridge-bindings' and not parts.query:
|
||||||
self.output(200,self.server.store.decision_bindings())
|
self.output(200,self.server.store.decision_bindings())
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Workbench } from '@/lib/context'
|
|||||||
import { request, errorText } from '@/lib/api'
|
import { request, errorText } from '@/lib/api'
|
||||||
import type { State, AttentionItem } from '@/lib/types'
|
import type { State, AttentionItem } from '@/lib/types'
|
||||||
import { projectTitle, date } from '@/lib/types'
|
import { projectTitle, date } from '@/lib/types'
|
||||||
|
import VersionNotice from '@/components/version-notice'
|
||||||
import Overview from '@/pages/overview'
|
import Overview from '@/pages/overview'
|
||||||
import Tasks from '@/pages/tasks'
|
import Tasks from '@/pages/tasks'
|
||||||
import Cluster from '@/pages/cluster'
|
import Cluster from '@/pages/cluster'
|
||||||
@@ -44,7 +45,7 @@ function Shell({ token, initial, logout }: { token: string; initial: State; logo
|
|||||||
<header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b px-4 md:px-6"><div className="flex min-w-0 items-center gap-3"><SidebarTrigger aria-label="切换导航" /><Separator orientation="vertical" className="h-4" />{isChat ? <div className="min-w-0"><h1 className="font-semibold">项目沟通</h1><p className="truncate text-xs text-muted-foreground">询问进度、讨论方案,或告诉我你的新想法。</p></div> : <Breadcrumb><BreadcrumbList><BreadcrumbItem><BreadcrumbPage>{navigation.find(n => n.url === location.pathname)?.title || '总览'}</BreadcrumbPage></BreadcrumbItem></BreadcrumbList></Breadcrumb>}</div>
|
<header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b px-4 md:px-6"><div className="flex min-w-0 items-center gap-3"><SidebarTrigger aria-label="切换导航" /><Separator orientation="vertical" className="h-4" />{isChat ? <div className="min-w-0"><h1 className="font-semibold">项目沟通</h1><p className="truncate text-xs text-muted-foreground">询问进度、讨论方案,或告诉我你的新想法。</p></div> : <Breadcrumb><BreadcrumbList><BreadcrumbItem><BreadcrumbPage>{navigation.find(n => n.url === location.pathname)?.title || '总览'}</BreadcrumbPage></BreadcrumbItem></BreadcrumbList></Breadcrumb>}</div>
|
||||||
{isChat ? <Button variant="ghost" size="sm" onClick={() => window.dispatchEvent(new Event('workbench-refresh-conversation'))}><RefreshCw />刷新</Button> : <div className="flex items-center gap-2 text-xs text-muted-foreground"><Circle className={`size-2 fill-current ${error ? 'text-destructive' : 'text-primary'}`} /><span>{error ? '连接中断' : '实时同步'}</span><span className="hidden lg:inline">{date(state.updated)}</span></div>}
|
{isChat ? <Button variant="ghost" size="sm" onClick={() => window.dispatchEvent(new Event('workbench-refresh-conversation'))}><RefreshCw />刷新</Button> : <div className="flex items-center gap-2 text-xs text-muted-foreground"><Circle className={`size-2 fill-current ${error ? 'text-destructive' : 'text-primary'}`} /><span>{error ? '连接中断' : '实时同步'}</span><span className="hidden lg:inline">{date(state.updated)}</span></div>}
|
||||||
</header>
|
</header>
|
||||||
<main className={isChat ? 'flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background' : 'min-w-0 flex-1 space-y-5 bg-muted/25 p-4 md:p-6 lg:p-8'}>{error && <Alert variant="destructive"><AlertDescription>{error} 当前显示上次同步的数据。</AlertDescription></Alert>}<AttentionNotice state={state} pathname={location.pathname} /><Routes><Route path="/overview" element={<Overview />} /><Route path="/tasks" element={<Tasks />} /><Route path="/cluster" element={<Cluster />} /><Route path="/communication" element={<Communication />} /><Route path="/decisions" element={<Decisions />} /><Route path="/logs" element={<Logs />} /><Route path="*" element={<Navigate to="/overview" replace />} /></Routes></main>
|
<main className={isChat ? 'flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background' : 'min-w-0 flex-1 space-y-5 bg-muted/25 p-4 md:p-6 lg:p-8'}>{error && <Alert variant="destructive"><AlertDescription>{error} 当前显示上次同步的数据。</AlertDescription></Alert>}<VersionNotice /><AttentionNotice state={state} pathname={location.pathname} /><Routes><Route path="/overview" element={<Overview />} /><Route path="/tasks" element={<Tasks />} /><Route path="/cluster" element={<Cluster />} /><Route path="/communication" element={<Communication />} /><Route path="/decisions" element={<Decisions />} /><Route path="/logs" element={<Logs />} /><Route path="*" element={<Navigate to="/overview" replace />} /></Routes></main>
|
||||||
{!isChat && <footer className="border-t px-6 py-4 text-xs text-muted-foreground">Agent Workbench · 每 3 秒同步 · AI 接入状态以会话显示为准</footer>}
|
{!isChat && <footer className="border-t px-6 py-4 text-xs text-muted-foreground">Agent Workbench · 每 3 秒同步 · AI 接入状态以会话显示为准</footer>}
|
||||||
</SidebarInset></SidebarProvider></Workbench.Provider>
|
</SidebarInset></SidebarProvider></Workbench.Provider>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,21 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { request, errorText, messageKey } from '@/lib/api'
|
import { request, errorText, messageKey } from '@/lib/api'
|
||||||
import type { Decision, Message } from '@/lib/types'
|
import type { Decision, Message } from '@/lib/types'
|
||||||
|
import { useLocalDraft } from '@/lib/use-local-draft'
|
||||||
|
import { useWorkbench } from '@/lib/context'
|
||||||
import type { ChatDraft } from '@/pages/decision-ui'
|
import type { ChatDraft } from '@/pages/decision-ui'
|
||||||
export default function DecisionChat({ decision, token, drafts }: { decision: Decision; token: string; drafts: Map<string, ChatDraft> }) {
|
export default function DecisionChat({ decision, token }: { decision: Decision; token: string }) {
|
||||||
const id = decision.id, bound = Boolean(decision.session_id && decision.session_thread_id)
|
const id = decision.id, bound = Boolean(decision.session_id && decision.session_thread_id)
|
||||||
const readOnly = decision.status === 'completed' || decision.archive_status === 'archived'
|
const readOnly = decision.status === 'completed' || decision.archive_status === 'archived'
|
||||||
const [messages, setMessages] = useState<Message[]>([]), [error, setError] = useState(''), [loaded, setLoaded] = useState(false), [, redraw] = useState(0)
|
const [messages, setMessages] = useState<Message[]>([]), [error, setError] = useState(''), [loaded, setLoaded] = useState(false), [, redraw] = useState(0)
|
||||||
const refresh = useRef<() => Promise<void>>(async () => {})
|
const refresh = useRef<() => Promise<void>>(async () => {})
|
||||||
const scroll = useRef<HTMLDivElement>(null)
|
const scroll = useRef<HTMLDivElement>(null)
|
||||||
if (!drafts.has(id)) drafts.set(id, { text: '', sending: false, error: '' })
|
const { state } = useWorkbench()
|
||||||
const draft = drafts.get(id)!
|
const revision = decision.revision ?? 1
|
||||||
|
const local = useLocalDraft<ChatDraft & {revision:number}>(state.instance_id && Number.isInteger(decision.revision) && (decision.revision ?? 0) > 0 ? [state.instance_id, 'consultation', id, decision.session_id ?? 'unbound', String(revision)] : null, {text:'',sending:false,error:'',revision}, (v): v is ChatDraft & {revision:number} => Boolean(v && typeof v === 'object' && typeof (v as ChatDraft).text === 'string'), true)
|
||||||
|
const draft = local.value
|
||||||
|
const sending = useRef(false)
|
||||||
|
function persist() {local.save(draft);redraw(n => n+1)}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bound) return
|
if (!bound) return
|
||||||
const controller = new AbortController(); let busy = false
|
const controller = new AbortController(); let busy = false
|
||||||
@@ -26,18 +32,21 @@ export default function DecisionChat({ decision, token, drafts }: { decision: De
|
|||||||
return () => { clearInterval(timer); controller.abort() }
|
return () => { clearInterval(timer); controller.abort() }
|
||||||
}, [id, token, bound])
|
}, [id, token, bound])
|
||||||
async function send() {
|
async function send() {
|
||||||
if (!bound || readOnly || draft.sending || !draft.text.trim()) return
|
if (!bound || readOnly || !local.ready || draft.revision !== revision || sending.current || draft.sending || !draft.text.trim()) return
|
||||||
const text = draft.text
|
const text = draft.text
|
||||||
if (!draft.attempt || draft.attempt.text !== text) draft.attempt = { text, key: messageKey() }
|
if (!draft.attempt || draft.attempt.text !== text) draft.attempt = { text, key: messageKey() }
|
||||||
|
if (!local.save(draft)) return
|
||||||
|
sending.current=true
|
||||||
draft.sending = true; draft.error = ''; redraw(n => n + 1)
|
draft.sending = true; draft.error = ''; redraw(n => n + 1)
|
||||||
try { await request(token, `/api/decisions/${encodeURIComponent(id)}/consultation`, { text, client_key: draft.attempt.key }); if (draft.text === text) draft.text = ''; draft.attempt = undefined; await refresh.current() }
|
try { await request(token, `/api/decisions/${encodeURIComponent(id)}/consultation`, { text, client_key: draft.attempt.key }); local.clear(); await refresh.current() }
|
||||||
catch (cause) { draft.error = `${errorText(cause)} 草稿已保留,可重试同一消息。` }
|
catch (cause) { draft.error = `${errorText(cause)} 草稿已保留,可重试同一消息。` }
|
||||||
finally { draft.sending = false; redraw(n => n + 1) }
|
finally { sending.current=false; draft.sending = false; redraw(n => n + 1) }
|
||||||
}
|
}
|
||||||
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>
|
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>
|
||||||
<p className="px-3 pt-3 text-xs text-muted-foreground">{!bound ? '咨询会话待接通,暂不能发送。' : readOnly ? '本决策已完成,咨询历史只读。' : '消息仅发送到本决策绑定的咨询会话。绑定不代表回复链路已验收。'}</p>
|
<p className="px-3 pt-3 text-xs text-muted-foreground">{!bound ? '咨询会话待接通,暂不能发送。' : readOnly ? '本决策已完成,咨询历史只读。' : '消息仅发送到本决策绑定的咨询会话。绑定不代表回复链路已验收。'}</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>{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>
|
||||||
{(error || draft.error) && <p role="status" className="px-3 pb-3 text-sm text-destructive">{draft.error || error}</p>}
|
{(error || draft.error) && <p role="status" className="px-3 pb-3 text-sm text-destructive">{draft.error || error}</p>}
|
||||||
{!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; redraw(n => n + 1) }} placeholder="询问方案、依据或影响…" /><Button type="submit" disabled={!bound || draft.sending || !draft.text.trim()}>{draft.sending ? '正在发送…' : '发送咨询'}</Button></form>}
|
<div className="space-y-2 px-3 pb-3"><p role="status" className="text-xs text-muted-foreground">{local.error || '咨询草稿单独保存在此浏览器。'}</p>{local.error && <Button variant="outline" size="sm" disabled={draft.sending} onClick={() => persist()}>重试保存草稿</Button>}<Button variant="ghost" size="sm" disabled={draft.sending} onClick={() => local.clear(true)}>清除本咨询本地草稿</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>}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { useWorkbench } from '@/lib/context'
|
||||||
|
|
||||||
|
export default function VersionNotice() {
|
||||||
|
const { state } = useWorkbench()
|
||||||
|
const entry = document.querySelector<HTMLScriptElement>('script[type="module"][src]')?.getAttribute('src')
|
||||||
|
if (!entry || !state.frontend_entrypoint || entry !== state.frontend_entrypoint) {
|
||||||
|
if (!entry || !state.frontend_entrypoint) return null
|
||||||
|
return <Alert role="status"><AlertDescription>页面已有新版。输入处会显示本地保存状态。若提示保存失败,请先复制草稿并记下选择;确认已保存后可重新加载并登录恢复。仅刷新消息或状态不会更新页面;不会自动重载或重发你的输入。</AlertDescription></Alert>
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export type DraftStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem' | 'key' | 'length'>
|
||||||
|
const prefix = 'workbench-draft-v1:'
|
||||||
|
export function draftKey(scope: string[]) { return prefix + JSON.stringify(scope) }
|
||||||
|
export function readDraft<T>(storage: DraftStorage, scope: string[], empty: T, valid: (value: unknown) => value is T, olderVersions = false): T {
|
||||||
|
const raw = storage.getItem(draftKey(scope))
|
||||||
|
if (raw !== null) { const record = JSON.parse(raw); if (record.cleared) return empty; if (!valid(record.value)) throw new Error('草稿格式无法读取'); return record.value }
|
||||||
|
if (olderVersions) {
|
||||||
|
const base = scope.slice(0,-1), current = Number(scope.at(-1)); let latest = -1, found: T | undefined
|
||||||
|
for (let i=0;i<storage.length;i++) { const key=storage.key(i); if (!key?.startsWith(prefix)) continue
|
||||||
|
const parts=JSON.parse(key.slice(prefix.length)); if (!Array.isArray(parts) || JSON.stringify(parts.slice(0,-1))!==JSON.stringify(base)) continue
|
||||||
|
const version=Number(parts.at(-1)); if (version>=current || version<=latest) continue
|
||||||
|
const record=JSON.parse(storage.getItem(key) ?? 'null'); if (record?.cleared) continue
|
||||||
|
if (valid(record?.value)) { latest=version; found=record.value }
|
||||||
|
}
|
||||||
|
if(found) return found
|
||||||
|
}
|
||||||
|
return empty
|
||||||
|
}
|
||||||
|
export function writeDraft<T>(storage: DraftStorage, scope: string[], value: T) { storage.setItem(draftKey(scope), JSON.stringify({ value }, (key, item) => key === 'sending' || key === 'error' ? undefined : item)) }
|
||||||
|
export function clearDraft(storage: DraftStorage, scope: string[], allVersions = false) {
|
||||||
|
if(allVersions) { const base=scope.slice(0,-1); const keys:string[]=[]; for(let i=0;i<storage.length;i++){const key=storage.key(i);if(!key?.startsWith(prefix))continue; const parts=JSON.parse(key.slice(prefix.length));if(JSON.stringify(parts.slice(0,-1))===JSON.stringify(base))keys.push(key)} for(const key of keys)storage.removeItem(key) }
|
||||||
|
storage.setItem(draftKey(scope), JSON.stringify({cleared:true}))
|
||||||
|
}
|
||||||
|
export function clearIfUnchanged(storage: DraftStorage, scope: string[], expected: string | null) {
|
||||||
|
if(storage.getItem(draftKey(scope))!==expected) return false
|
||||||
|
clearDraft(storage,scope)
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ export type NodeMetric = { memory_used_percent?: number; memory_total_bytes?: nu
|
|||||||
export type Mount = { mount_target: string; used_percent?: number; total_bytes?: number; available_bytes?: number }
|
export type Mount = { mount_target: string; used_percent?: number; total_bytes?: number; available_bytes?: number }
|
||||||
export type SchedulerData = { count?: number; by_state?: Record<string, number>; queues?: { name: string; state: string; pend?: number; run?: number }[] }
|
export type SchedulerData = { count?: number; by_state?: Record<string, number>; queues?: { name: string; state: string; pend?: number; run?: number }[] }
|
||||||
export type Metrics = { sampled_at?: number; status?: string; message?: string; data?: { status?: string; message?: string; collected_at?: string; nodes?: Sample<NodeMetric>[]; storage?: Sample<Mount[]>[]; schedulers?: Record<string, Record<string, Sample<SchedulerData>>> } }
|
export type Metrics = { sampled_at?: number; status?: string; message?: string; data?: { status?: string; message?: string; collected_at?: string; nodes?: Sample<NodeMetric>[]; storage?: Sample<Mount[]>[]; schedulers?: Record<string, Record<string, Sample<SchedulerData>>> } }
|
||||||
export type State = { attention?: Attention; decisions?: Decision[]; hooks?: { pending_notifications: number; active_firing: number; recent: { source: string; summary: string; status: string; severity: string }[] }; config?: { title?: string }; title?: string; primary_session_id?: string; tasks: Task[]; sessions: Session[]; events: Event[]; requests: { id: string; name: string; status: string }[]; metrics?: Metrics; updated?: number }
|
export type State = { instance_id?: string; frontend_entrypoint?: string | null; attention?: Attention; decisions?: Decision[]; hooks?: { pending_notifications: number; active_firing: number; recent: { source: string; summary: string; status: string; severity: string }[] }; config?: { title?: string }; title?: string; primary_session_id?: string; tasks: Task[]; sessions: Session[]; events: Event[]; requests: { id: string; name: string; status: string }[]; metrics?: Metrics; updated?: number }
|
||||||
export const statusLabels: Record<string, string> = { queued: '排队', running: '进行中', waiting: '等待', blocked: '受阻', completed: '已完成', failed: '失败', cancelled: '已取消', claimed: '处理中', delivered: '已送达', uncertain: '待人工核查' }
|
export const statusLabels: Record<string, string> = { queued: '排队', running: '进行中', waiting: '等待', blocked: '受阻', completed: '已完成', failed: '失败', cancelled: '已取消', claimed: '处理中', delivered: '已送达', uncertain: '待人工核查' }
|
||||||
export const phaseLabels: Record<string, string> = { planning: '规划', implementation: '实现', validation: '验证', integration: '集成', done: '完成' }
|
export const phaseLabels: Record<string, string> = { planning: '规划', implementation: '实现', validation: '验证', integration: '集成', done: '完成' }
|
||||||
export function date(value?: string | number) { return value == null ? '暂无' : new Date(typeof value === 'number' ? value * 1000 : value).toLocaleString('zh-CN') }
|
export function date(value?: string | number) { return value == null ? '暂无' : new Date(typeof value === 'number' ? value * 1000 : value).toLocaleString('zh-CN') }
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { clearDraft, clearIfUnchanged, draftKey, readDraft, writeDraft } from './draft-storage'
|
||||||
|
export function useLocalDraft<T>(scope: string[] | null, empty: T, valid: (value: unknown) => value is T, olderVersions = false) {
|
||||||
|
const identity=JSON.stringify(scope), ref=useRef<{identity:string;value:T;error:string;loaded:boolean;persisted:string|null} | null>(null)
|
||||||
|
const [, redraw]=useState(0)
|
||||||
|
if(ref.current?.identity!==identity){let value=empty,error='',persisted=null;try{if(scope){value=readDraft(window.localStorage,scope,empty,valid,olderVersions);persisted=window.localStorage.getItem(draftKey(scope))}else error='实例或版本信息尚未就绪,暂不能保存或发送。'}catch{error='本地草稿读取失败,请先复制输入;暂不能确认已保存。'}ref.current={identity,value,error,loaded:!error,persisted}}
|
||||||
|
const record=ref.current!
|
||||||
|
function save(value:T){record.value=value;try{if(!scope)throw Error();writeDraft(window.localStorage,scope,value);record.persisted=window.localStorage.getItem(draftKey(scope));record.error='';record.loaded=true;redraw(n=>n+1);return true}catch{record.error='本地保存失败,请复制草稿;刷新或退出可能丢失。';record.loaded=false;redraw(n=>n+1);return false}}
|
||||||
|
function clear(allVersions=false,userRequested=false){try{if(!scope)throw Error();if(allVersions||userRequested)clearDraft(window.localStorage,scope,allVersions);else if(!clearIfUnchanged(window.localStorage,scope,record.persisted)){record.error='已提交;保留了随后编辑的本地草稿。';redraw(n=>n+1);return false}record.value=empty;record.persisted=window.localStorage.getItem(draftKey(scope));record.error='';record.loaded=true;redraw(n=>n+1);return true}catch{record.error='本地草稿清除失败,旧草稿可能仍保留;请勿重复提交。';record.loaded=false;redraw(n=>n+1);return false}}
|
||||||
|
return {value:record.value,error:record.error,save,clear,ready:Boolean(scope)&&record.loaded}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Textarea } from '@/components/ui/textarea'
|
|||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { useWorkbench } from '@/lib/context'
|
import { useWorkbench } from '@/lib/context'
|
||||||
import { request, errorText, messageKey } from '@/lib/api'
|
import { request, errorText, messageKey } from '@/lib/api'
|
||||||
|
import { useLocalDraft } from '@/lib/use-local-draft'
|
||||||
import type { Message } from '@/lib/types'
|
import type { Message } from '@/lib/types'
|
||||||
export default function Communication() {
|
export default function Communication() {
|
||||||
const { state, token, refresh } = useWorkbench()
|
const { state, token, refresh } = useWorkbench()
|
||||||
@@ -14,11 +15,11 @@ export default function Communication() {
|
|||||||
const selected = primary
|
const selected = primary
|
||||||
const session = state.sessions.find(s => s.id === selected)
|
const session = state.sessions.find(s => s.id === selected)
|
||||||
const [messages, setMessages] = useState<{ session: string; items: Message[] }>({ session: '', items: [] })
|
const [messages, setMessages] = useState<{ session: string; items: Message[] }>({ session: '', items: [] })
|
||||||
const [drafts, setDrafts] = useState<Record<string, string>>({})
|
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 [error, setError] = useState('')
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
const [refreshVersion, setRefreshVersion] = useState(0)
|
const [refreshVersion, setRefreshVersion] = useState(0)
|
||||||
const pending = useRef<{ session_id: string; text: string; idempotency_key: string } | null>(null)
|
const sendingRef = useRef(false)
|
||||||
const scroll = useRef<HTMLDivElement>(null)
|
const scroll = useRef<HTMLDivElement>(null)
|
||||||
const loaded = useRef('')
|
const loaded = useRef('')
|
||||||
const known = Boolean(session)
|
const known = Boolean(session)
|
||||||
@@ -54,21 +55,22 @@ export default function Communication() {
|
|||||||
loaded.current = selected
|
loaded.current = selected
|
||||||
}, [lastId, selected, messages.session])
|
}, [lastId, selected, messages.session])
|
||||||
const decisions = (state as { decisions?: { id: string; status: string }[] }).decisions?.filter(d => ['open','needs_reconsideration'].includes(d.status)) || []
|
const decisions = (state as { decisions?: { id: string; status: string }[] }).decisions?.filter(d => ['open','needs_reconsideration'].includes(d.status)) || []
|
||||||
const text = drafts[selected] || ''
|
const text = local.value.text
|
||||||
async function send(event: FormEvent) {
|
async function send(event: FormEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!session || !text.trim() || sending) return
|
if (!session || !text.trim() || sendingRef.current || !local.ready) return
|
||||||
const sid = selected, body = text
|
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() }
|
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('')
|
setSending(true); setError('')
|
||||||
try {
|
try {
|
||||||
await request(token, '/api/messages', { ...pending.current, source: 'human' })
|
await request(token, '/api/messages', { session_id:sid, text:body, idempotency_key:attempt.key, source:'human' })
|
||||||
pending.current = null
|
local.clear()
|
||||||
setDrafts(old => old[sid] === body ? { ...old, [sid]: '' } : old)
|
|
||||||
setRefreshVersion(n => n + 1)
|
setRefreshVersion(n => n + 1)
|
||||||
await refresh()
|
await refresh()
|
||||||
} catch (e) { setError(errorText(e)) }
|
} catch (e) { setError(errorText(e)) }
|
||||||
finally { setSending(false) }
|
finally { sendingRef.current=false; setSending(false) }
|
||||||
}
|
}
|
||||||
return <div className="flex min-h-0 w-full flex-1 flex-col">
|
return <div className="flex min-h-0 w-full flex-1 flex-col">
|
||||||
|
|
||||||
@@ -87,8 +89,8 @@ export default function Communication() {
|
|||||||
</article>) : <div className="flex h-full items-center justify-center text-sm text-muted-foreground">{known ? '有什么想了解的?从这里开始交流。' : '会话准备好后,即可在这里交流。'}</div>}
|
</article>) : <div className="flex h-full items-center justify-center text-sm text-muted-foreground">{known ? '有什么想了解的?从这里开始交流。' : '会话准备好后,即可在这里交流。'}</div>}
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={send} className="shrink-0 border-t p-3 sm:px-8">
|
<form onSubmit={send} className="shrink-0 border-t p-3 sm:px-8">
|
||||||
<Textarea id="message" aria-label="输入消息" value={text} onChange={e => setDrafts(old => ({ ...old, [selected]: e.target.value }))} placeholder="输入你的问题或想法…" maxLength={32000} rows={2} required className="resize-none border-0 shadow-none focus-visible:ring-0" />
|
<p role="status" className="mb-2 text-xs text-muted-foreground">{local.error || '草稿保存在此浏览器,可在重新登录后恢复。'}</p><Button type="button" variant="ghost" size="sm" disabled={sending} onClick={() => local.clear(false,true)}>清除本地沟通草稿</Button><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 || 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>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export type DecisionDraft = { option: string | null; comment: string; revision:
|
|||||||
export type ChatDraft = { text: string; sending: boolean; error: string; attempt?: { text: string; key: string } }
|
export type ChatDraft = { text: string; sending: boolean; error: string; attempt?: { text: string; key: string } }
|
||||||
export function writable(decision: Decision) { return ['open', 'needs_reconsideration'].includes(decisionStatus(decision)) }
|
export function writable(decision: Decision) { return ['open', 'needs_reconsideration'].includes(decisionStatus(decision)) }
|
||||||
export function submissionBody(decision: Decision, draft: DecisionDraft) {
|
export function submissionBody(decision: Decision, draft: DecisionDraft) {
|
||||||
const revision = decision.revision ?? 1
|
const revision = decision.revision
|
||||||
|
if (!Number.isInteger(revision) || (revision ?? 0) < 1) return null
|
||||||
if (!writable(decision) || draft.sending || draft.conflict || draft.revision !== revision) return null
|
if (!writable(decision) || draft.sending || draft.conflict || draft.revision !== revision) return null
|
||||||
if (draft.option && !decision.options.some(option => option.id === draft.option)) return null
|
if (draft.option && !decision.options.some(option => option.id === draft.option)) return null
|
||||||
if (!draft.option && !draft.comment.trim()) return null
|
if (!draft.option && !draft.comment.trim()) return null
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import { Textarea } from '@/components/ui/textarea'
|
|||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'
|
||||||
import { PageHeading, Panel, Empty } from '@/components/shared'
|
import { PageHeading, Panel, Empty } from '@/components/shared'
|
||||||
import DecisionChat from '@/components/decision-chat'
|
import DecisionChat from '@/components/decision-chat'
|
||||||
|
import VersionNotice from '@/components/version-notice'
|
||||||
|
import { useLocalDraft } from '@/lib/use-local-draft'
|
||||||
import { useWorkbench } from '@/lib/context'
|
import { useWorkbench } from '@/lib/context'
|
||||||
import { ApiError, errorText, messageKey, request } from '@/lib/api'
|
import { ApiError, errorText, messageKey, request } from '@/lib/api'
|
||||||
import { date, type Decision } from '@/lib/types'
|
import { date, type Decision } from '@/lib/types'
|
||||||
import { decisionStatus, decisionLabels, writable, submissionBody, confirmDraft, type DecisionDraft, type ChatDraft } from './decision-ui'
|
import { decisionStatus, decisionLabels, writable, submissionBody, confirmDraft, type DecisionDraft } from './decision-ui'
|
||||||
|
|
||||||
type HistoryEntry = { id?: string | number; kind?: string; created?: number; revision?: number; data?: Record<string, unknown> }
|
type HistoryEntry = { id?: string | number; kind?: string; created?: number; revision?: number; data?: Record<string, unknown> }
|
||||||
function Status({ decision }: { decision: Decision }) { const status = decisionStatus(decision); return <Badge variant="outline" className={status === 'completed' ? 'status-success' : status === 'needs_reconsideration' ? 'status-danger' : 'status-neutral'}>{decisionLabels[status]}</Badge> }
|
function Status({ decision }: { decision: Decision }) { const status = decisionStatus(decision); return <Badge variant="outline" className={status === 'completed' ? 'status-success' : status === 'needs_reconsideration' ? 'status-danger' : 'status-neutral'}>{decisionLabels[status]}</Badge> }
|
||||||
@@ -29,7 +31,7 @@ function HistoryRecord({ entry }: { entry: HistoryEntry }) {
|
|||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
|
||||||
function Detail({ id, initial, drafts, chats }: { id: string; initial?: Decision; drafts: Map<string, DecisionDraft>; chats: Map<string, ChatDraft> }) {
|
function Detail({ id, initial }: { id: string; initial?: Decision }) {
|
||||||
const { token, refresh, state } = useWorkbench()
|
const { token, refresh, state } = useWorkbench()
|
||||||
const [decision, setDecision] = useState(initial), [history, setHistory] = useState<HistoryEntry[]>([]), [error, setError] = useState(''), [notice, setNotice] = useState(''), [, redraw] = useState(0)
|
const [decision, setDecision] = useState(initial), [history, setHistory] = useState<HistoryEntry[]>([]), [error, setError] = useState(''), [notice, setNotice] = useState(''), [, redraw] = useState(0)
|
||||||
const reload = useRef<() => Promise<void>>(async () => {}), generation = useRef(0)
|
const reload = useRef<() => Promise<void>>(async () => {}), generation = useRef(0)
|
||||||
@@ -45,37 +47,41 @@ function Detail({ id, initial, drafts, chats }: { id: string; initial?: Decision
|
|||||||
reload.current = poll; void poll(); const timer = setInterval(() => void poll(), 3000)
|
reload.current = poll; void poll(); const timer = setInterval(() => void poll(), 3000)
|
||||||
return () => { controller.abort(); clearInterval(timer) }
|
return () => { controller.abort(); clearInterval(timer) }
|
||||||
}, [id, token])
|
}, [id, token])
|
||||||
if (decision && !drafts.has(id)) drafts.set(id, { option: null, comment: '', revision: decision.revision ?? 1, conflict: false })
|
|
||||||
const draft = drafts.get(id)
|
|
||||||
const revision = decision?.revision ?? 1
|
const revision = decision?.revision ?? 1
|
||||||
|
const local = useLocalDraft<DecisionDraft>(state.instance_id && decision && Number.isInteger(decision.revision) && (decision.revision ?? 0) > 0 ? [state.instance_id, 'decision', id, String(revision)] : null, {option:null,comment:'',revision,conflict:false}, (v): v is DecisionDraft => Boolean(v && typeof v === 'object' && typeof (v as DecisionDraft).comment === 'string' && Number.isInteger((v as DecisionDraft).revision)), true)
|
||||||
|
const draft = local.value
|
||||||
|
const sending = useRef(false)
|
||||||
|
function persist() { local.save(draft); redraw(n => n + 1) }
|
||||||
const changed = Boolean(draft && (draft.revision !== revision || draft.conflict))
|
const changed = Boolean(draft && (draft.revision !== revision || draft.conflict))
|
||||||
const editable = Boolean(decision && writable(decision) && !draft?.sending)
|
const editable = Boolean(decision && writable(decision) && !draft?.sending && local.ready)
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!decision || !draft || !editable || changed || (!draft.option && !draft.comment.trim())) return
|
if (!decision || !draft || sending.current || !editable || changed || (!draft.option && !draft.comment.trim())) return
|
||||||
const body = submissionBody(decision, draft)
|
const body = submissionBody(decision, draft)
|
||||||
if (!body) return
|
if (!body) return
|
||||||
const signature = JSON.stringify(body)
|
const signature = JSON.stringify(body)
|
||||||
if (!draft.attempt || draft.attempt.signature !== signature) draft.attempt = { signature, key: messageKey() }
|
if (!draft.attempt || draft.attempt.signature !== signature) draft.attempt = { signature, key: messageKey() }
|
||||||
|
if (!local.save(draft)) return
|
||||||
|
sending.current = true
|
||||||
draft.sending = true; redraw(n => n + 1); setNotice('')
|
draft.sending = true; redraw(n => n + 1); setNotice('')
|
||||||
try {
|
try {
|
||||||
const result = await request<Decision>(token, `/api/decisions/${encodeURIComponent(id)}/respond`, { ...body, idempotency_key: draft.attempt.key })
|
const result = await request<Decision>(token, `/api/decisions/${encodeURIComponent(id)}/respond`, { ...body, idempotency_key: draft.attempt.key })
|
||||||
++generation.current; setDecision(result); setNotice(''); draft.option = null; draft.comment = ''; draft.attempt = undefined
|
++generation.current; setDecision(result); setNotice(''); local.clear()
|
||||||
await refresh().catch(() => {}); await reload.current()
|
await refresh().catch(() => {}); await reload.current()
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (cause instanceof ApiError && cause.status === 409) { draft.conflict = true; setNotice('内容或状态已变化,草稿已保留。请阅读最新内容并重新确认,未自动重交。'); await reload.current() }
|
if (cause instanceof ApiError && cause.status === 409) { draft.conflict = true; persist(); setNotice('内容或状态已变化,草稿已保留。请阅读最新内容并重新确认,未自动重交。'); await reload.current() }
|
||||||
else setNotice(`${errorText(cause)} 草稿已保留,相同内容重试复用原提交记录。`)
|
else setNotice(`${errorText(cause)} 草稿已保留,相同内容重试复用原提交记录。`)
|
||||||
} finally { draft.sending = false; redraw(n => n + 1) }
|
} finally { sending.current = false; draft.sending = false; redraw(n => n + 1) }
|
||||||
}
|
}
|
||||||
return <><SheetHeader className="pr-12"><SheetTitle>{decision?.title ?? '读取决策…'}</SheetTitle><SheetDescription>{decision?.summary || decision?.question || '查看方案、检查结论与本决策咨询。'}</SheetDescription></SheetHeader>
|
return <><SheetHeader className="pr-12"><SheetTitle>{decision?.title ?? '读取决策…'}</SheetTitle><SheetDescription>{decision?.summary || decision?.question || '查看方案、检查结论与本决策咨询。'}</SheetDescription></SheetHeader>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-5">{error && <p role="status" className="mb-4 text-sm text-destructive">{error} 保留已读取内容。<Button variant="link" onClick={() => void reload.current()}>重试</Button></p>}
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-5"><VersionNotice />{error && <p role="status" className="mb-4 text-sm text-destructive">{error} 保留已读取内容。<Button variant="link" onClick={() => void reload.current()}>重试</Button></p>}
|
||||||
{decision && draft ? <div className="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]"><div className="min-w-0 space-y-5">
|
{decision && draft ? <div className="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]"><div className="min-w-0 space-y-5">
|
||||||
<div className="flex flex-wrap gap-2"><Status decision={decision} /><Badge variant="outline">版本 {revision}</Badge>{decision.task_id && <Link to={`/tasks?task=${encodeURIComponent(decision.task_id)}`} className="text-sm text-primary underline">{state.tasks.find(task => task.id === decision.task_id)?.name || decision.task_id}</Link>}</div>
|
<p role="status" className="text-xs text-muted-foreground">{local.error || '本决策草稿保存在此浏览器,提交成功才清除。'}</p>{local.error && <Button variant="outline" size="sm" disabled={draft.sending} onClick={() => persist()}>重试保存草稿</Button>}<Button variant="ghost" size="sm" disabled={draft.sending} onClick={() => local.clear(true)}>清除本决策本地草稿(含旧版本)</Button>{!writable(decision) && (draft.comment || draft.option) && <section className="rounded-lg border p-3 text-sm"><h3>保留的本地草稿(只读,未自动提交)</h3><p>版本 {draft.revision} · 选项 {draft.option || '无'}</p><p className="whitespace-pre-wrap">{draft.comment}</p></section>}<div className="flex flex-wrap gap-2"><Status decision={decision} /><Badge variant="outline">版本 {revision}</Badge>{decision.task_id && <Link to={`/tasks?task=${encodeURIComponent(decision.task_id)}`} className="text-sm text-primary underline">{state.tasks.find(task => task.id === decision.task_id)?.name || decision.task_id}</Link>}</div>
|
||||||
<Text label="需要决定的问题" text={decision.question} /><Text label="背景与依据" text={decision.details} /><Text label="影响" text={decision.impact} /><Text label="推荐与理由(不会替你选择)" text={decision.recommendation} />
|
<Text label="需要决定的问题" text={decision.question} /><Text label="背景与依据" text={decision.details} /><Text label="影响" text={decision.impact} /><Text label="推荐与理由(不会替你选择)" text={decision.recommendation} />
|
||||||
{decision.deadline && Number.isFinite(Date.parse(decision.deadline)) && <p className="text-sm">截止时间:{date(decision.deadline)}</p>}
|
{decision.deadline && Number.isFinite(Date.parse(decision.deadline)) && <p className="text-sm">截止时间:{date(decision.deadline)}</p>}
|
||||||
<p className="text-xs text-muted-foreground">选择提交后需检查;接受方案不代表权限授权,也不代表已经完成。</p>
|
<p className="text-xs text-muted-foreground">选择提交后需检查;接受方案不代表权限授权,也不代表已经完成。</p>
|
||||||
{decision.options.length > 0 && <fieldset disabled={!editable || changed} className="space-y-2"><legend className="mb-2 font-medium">全部方案</legend>{decision.options.map(option => <label key={option.id} className="flex gap-3 rounded-lg border p-3"><input type="radio" name={`decision-${id}`} checked={draft.option === option.id} onChange={() => { draft.option = option.id; redraw(n => n + 1) }} className="mt-1 size-4 shrink-0 accent-primary" /><span><span className="font-medium">{option.label}</span><span className="mt-1 block whitespace-pre-wrap text-sm text-muted-foreground">{option.description}</span></span></label>)}</fieldset>}
|
{decision.options.length > 0 && <fieldset disabled={!editable || changed} className="space-y-2"><legend className="mb-2 font-medium">全部方案</legend>{decision.options.map(option => <label key={option.id} className="flex gap-3 rounded-lg border p-3"><input type="radio" name={`decision-${id}`} checked={draft.option === option.id} onChange={() => { draft.option = option.id; persist() }} className="mt-1 size-4 shrink-0 accent-primary" /><span><span className="font-medium">{option.label}</span><span className="mt-1 block whitespace-pre-wrap text-sm text-muted-foreground">{option.description}</span></span></label>)}</fieldset>}
|
||||||
{writable(decision) && <div className="space-y-3"><Button variant="ghost" size="sm" disabled={!editable || changed || !draft.option} onClick={() => { draft.option = null; redraw(n => n + 1) }}>清除选项,仅填写意见</Button><label className="block text-sm font-medium" htmlFor={`opinion-${id}`}>你的意见(可不选方案,直接填写)</label><Textarea id={`opinion-${id}`} disabled={!editable} value={draft.comment} onChange={event => { draft.comment = event.target.value; redraw(n => n + 1) }} />
|
{writable(decision) && <div className="space-y-3"><Button variant="ghost" size="sm" disabled={!editable || changed || !draft.option} onClick={() => { draft.option = null; persist() }}>清除选项,仅填写意见</Button><label className="block text-sm font-medium" htmlFor={`opinion-${id}`}>你的意见(可不选方案,直接填写)</label><Textarea id={`opinion-${id}`} disabled={!editable} value={draft.comment} onChange={event => { draft.comment = event.target.value; persist() }} />
|
||||||
{changed && <div className="space-y-2 rounded-lg border p-3"><p className="text-sm">请重新阅读当前版本;之前的选择不会自动作为本次确认。</p><Button variant="outline" disabled={draft.sending || Boolean(error)} onClick={() => { confirmDraft(decision, draft); setNotice('已确认当前版本,请重新选择方案或检查保留的意见后提交。'); redraw(n => n + 1) }}>我已阅读版本 {revision},重新确认</Button></div>}
|
{changed && <div className="space-y-2 rounded-lg border p-3"><p className="text-sm">请重新阅读当前版本;之前的选择不会自动作为本次确认。</p><Button variant="outline" disabled={draft.sending || Boolean(error)} onClick={() => { confirmDraft(decision, draft); persist(); setNotice('已确认当前版本,请重新选择方案或检查保留的意见后提交。'); redraw(n => n + 1) }}>我已阅读版本 {revision},重新确认</Button></div>}
|
||||||
<Button disabled={!editable || changed || (!draft.option && !draft.comment.trim())} onClick={() => void submit()}>{draft.sending ? '正在提交…' : '提交本次选择或意见'}</Button>
|
<Button disabled={!editable || changed || (!draft.option && !draft.comment.trim())} onClick={() => void submit()}>{draft.sending ? '正在提交…' : '提交本次选择或意见'}</Button>
|
||||||
</div>}
|
</div>}
|
||||||
{notice && <p role="status" className="text-sm">{notice}</p>}
|
{notice && <p role="status" className="text-sm">{notice}</p>}
|
||||||
@@ -85,16 +91,16 @@ function Detail({ id, initial, drafts, chats }: { id: string; initial?: Decision
|
|||||||
{decision.status === 'completed' && <p className="text-sm">{decision.archive_status === 'archived' ? '咨询会话已确认归档,历史只读。' : '决策已完成,咨询会话待归档确认。'}</p>}
|
{decision.status === 'completed' && <p className="text-sm">{decision.archive_status === 'archived' ? '咨询会话已确认归档,历史只读。' : '决策已完成,咨询会话待归档确认。'}</p>}
|
||||||
{Boolean(decision.supersedes_id || decision.related_decision_ids?.length) && <section><h3 className="mb-2 font-medium">关联决策</h3>{[...new Set([...(decision.supersedes_id ? [decision.supersedes_id] : []), ...(decision.related_decision_ids ?? [])])].map(other => <Link key={other} className="mr-3 text-sm text-primary underline" to={`/decisions?decision=${encodeURIComponent(other)}`}>{other}{other === decision.supersedes_id ? '(被此决策替代)' : ''}</Link>)}</section>}
|
{Boolean(decision.supersedes_id || decision.related_decision_ids?.length) && <section><h3 className="mb-2 font-medium">关联决策</h3>{[...new Set([...(decision.supersedes_id ? [decision.supersedes_id] : []), ...(decision.related_decision_ids ?? [])])].map(other => <Link key={other} className="mr-3 text-sm text-primary underline" to={`/decisions?decision=${encodeURIComponent(other)}`}>{other}{other === decision.supersedes_id ? '(被此决策替代)' : ''}</Link>)}</section>}
|
||||||
<section><h3 className="mb-2 font-medium">版本与处理历史</h3>{history.length ? <ol className="space-y-3">{history.map((entry,index) => <HistoryRecord key={entry.id ?? index} entry={entry} />)}</ol> : <p className="text-sm text-muted-foreground">暂无已读取的历史记录。</p>}</section>
|
<section><h3 className="mb-2 font-medium">版本与处理历史</h3>{history.length ? <ol className="space-y-3">{history.map((entry,index) => <HistoryRecord key={entry.id ?? index} entry={entry} />)}</ol> : <p className="text-sm text-muted-foreground">暂无已读取的历史记录。</p>}</section>
|
||||||
</div><div className="min-w-0"><DecisionChat key={id} decision={decision} token={token} drafts={chats} /></div></div> : !error && <Empty>正在读取当前决策。</Empty>}
|
</div><div className="min-w-0"><DecisionChat key={id} decision={decision} token={token} /></div></div> : !error && <Empty>正在读取当前决策。</Empty>}
|
||||||
</div></>
|
</div></>
|
||||||
}
|
}
|
||||||
export default function Decisions() {
|
export default function Decisions() {
|
||||||
const { state } = useWorkbench(), [params, setParams] = useSearchParams()
|
const { state } = useWorkbench(), [params, setParams] = useSearchParams()
|
||||||
const id = params.get('decision'), drafts = useRef(new Map<string, DecisionDraft>()), chats = useRef(new Map<string, ChatDraft>())
|
const id = params.get('decision')
|
||||||
const decisions = state.decisions ?? []
|
const decisions = state.decisions ?? []
|
||||||
return <div className="space-y-6"><PageHeading title="方案与决策" description="查看方案、咨询依据并明确提交,跟踪检查与落实结果。" />
|
return <div className="space-y-6"><PageHeading title="方案与决策" description="查看方案、咨询依据并明确提交,跟踪检查与落实结果。" />
|
||||||
{decisions.length ? <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{decisions.map(decision => <Card key={decision.id} className="p-0"><button type="button" onClick={() => setParams(previous => { const next = new URLSearchParams(previous); next.set('decision',decision.id); return next })} className="flex h-full w-full flex-col gap-3 rounded-xl p-6 text-left hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring"><div className="flex flex-wrap gap-2"><Status decision={decision} /><Badge variant="outline">{decision.task_id ? state.tasks.find(task => task.id === decision.task_id)?.name || decision.task_id : '未关联任务'}</Badge></div><h2 className="font-semibold">{decision.title}</h2><p className="whitespace-pre-wrap break-words text-sm text-muted-foreground">{decision.summary || decision.question}</p><span className="mt-auto text-sm text-primary">查看方案与咨询</span></button></Card>)}</div> : <Panel title="决策展板"><Empty>当前没有登记决策事项。</Empty></Panel>}
|
{decisions.length ? <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{decisions.map(decision => <Card key={decision.id} className="p-0"><button type="button" onClick={() => setParams(previous => { const next = new URLSearchParams(previous); next.set('decision',decision.id); return next })} className="flex h-full w-full flex-col gap-3 rounded-xl p-6 text-left hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring"><div className="flex flex-wrap gap-2"><Status decision={decision} /><Badge variant="outline">{decision.task_id ? state.tasks.find(task => task.id === decision.task_id)?.name || decision.task_id : '未关联任务'}</Badge></div><h2 className="font-semibold">{decision.title}</h2><p className="whitespace-pre-wrap break-words text-sm text-muted-foreground">{decision.summary || decision.question}</p><span className="mt-auto text-sm text-primary">查看方案与咨询</span></button></Card>)}</div> : <Panel title="决策展板"><Empty>当前没有登记决策事项。</Empty></Panel>}
|
||||||
{state.tasks.some(task => task.decision) && <Panel title="旧版文字条目(只读)">{state.tasks.filter(task => task.decision).map(task => <p key={task.id} className="mb-3 whitespace-pre-wrap text-sm">{task.name}:{task.decision}</p>)}</Panel>}
|
{state.tasks.some(task => task.decision) && <Panel title="旧版文字条目(只读)">{state.tasks.filter(task => task.decision).map(task => <p key={task.id} className="mb-3 whitespace-pre-wrap text-sm">{task.name}:{task.decision}</p>)}</Panel>}
|
||||||
<Sheet open={id !== null} onOpenChange={value => { if (!value) setParams(previous => { const next = new URLSearchParams(previous); next.delete('decision'); return next }) }}><SheetContent className="w-full sm:max-w-6xl">{id && <Detail key={id} id={id} initial={decisions.find(decision => decision.id === id)} drafts={drafts.current} chats={chats.current} />}</SheetContent></Sheet>
|
<Sheet open={id !== null} onOpenChange={value => { if (!value) setParams(previous => { const next = new URLSearchParams(previous); next.delete('decision'); return next }) }}><SheetContent className="w-full sm:max-w-6xl">{id && <Detail key={id} id={id} initial={decisions.find(decision => decision.id === id)} />}</SheetContent></Sheet>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ test('free text allowed with enumerated options; empty and invalid options rejec
|
|||||||
test('old revision or conflict cannot resubmit without explicit confirmation',()=>{const d={...draft(),revision:1,option:'a',comment:'保留意见',conflict:true,attempt:{key:'old',signature:'old'}};assert.equal(submissionBody(decision,d),null);confirmDraft(decision,d);assert.equal(d.option,null);assert.equal(d.comment,'保留意见');assert.equal(d.attempt,undefined);assert.equal(submissionBody(decision,d).expected_revision,2)})
|
test('old revision or conflict cannot resubmit without explicit confirmation',()=>{const d={...draft(),revision:1,option:'a',comment:'保留意见',conflict:true,attempt:{key:'old',signature:'old'}};assert.equal(submissionBody(decision,d),null);confirmDraft(decision,d);assert.equal(d.option,null);assert.equal(d.comment,'保留意见');assert.equal(d.attempt,undefined);assert.equal(submissionBody(decision,d).expected_revision,2)})
|
||||||
test('pending accepted completed read only; reconsideration permits new submission',()=>{for(const status of ['pending_review','accepted','completed'])assert.equal(submissionBody({...decision,status},{...draft(),option:'a'}),null);assert.ok(submissionBody({...decision,status:'needs_reconsideration'},{...draft(),option:'a'}));assert.equal(submissionBody(decision,{...draft(),option:'a',sending:true}),null)})
|
test('pending accepted completed read only; reconsideration permits new submission',()=>{for(const status of ['pending_review','accepted','completed'])assert.equal(submissionBody({...decision,status},{...draft(),option:'a'}),null);assert.ok(submissionBody({...decision,status:'needs_reconsideration'},{...draft(),option:'a'}));assert.equal(submissionBody(decision,{...draft(),option:'a',sending:true}),null)})
|
||||||
test('legacy response maps to pending review, never acceptance',()=>{for(const status of ['decided','pending_verification'])assert.equal(decisionStatus({status,response:{id:'r'}}),'pending_review')})
|
test('legacy response maps to pending review, never acceptance',()=>{for(const status of ['decided','pending_verification'])assert.equal(decisionStatus({status,response:{id:'r'}}),'pending_review')})
|
||||||
|
test('missing revision cannot be guessed for a legacy request',()=>{assert.equal(submissionBody({...decision,revision:undefined},{...draft(),revision:1,option:'a'}),null)})
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { draftKey,readDraft,writeDraft,clearDraft,clearIfUnchanged } from '../src/lib/draft-storage.ts'
|
||||||
|
class Memory { data=new Map();get length(){return this.data.size}key(i){return [...this.data.keys()][i]??null}getItem(k){return this.data.get(k)??null}setItem(k,v){this.data.set(k,v)}removeItem(k){this.data.delete(k)} }
|
||||||
|
const valid=v=>!!v&&typeof v.text==='string';const empty={text:''}
|
||||||
|
test('remount/reload recovers text and same unknown-delivery attempt key',()=>{const s=new Memory(),scope=['instance','communication','human'];writeDraft(s,scope,{text:'hello',attempt:{key:'stable',text:'hello'}});assert.deepEqual(readDraft(s,scope,empty,valid),{text:'hello',attempt:{key:'stable',text:'hello'}})})
|
||||||
|
test('instance session decision channel and version isolate records',()=>{const s=new Memory(),a=['i','decision','d1','1'];writeDraft(s,a,{text:'private'});for(const b of [['other','decision','d1','1'],['i','decision','d2','1'],['i','consultation','d1','1'],['i','decision','d1','2']])assert.deepEqual(readDraft(s,b,empty,valid),empty)})
|
||||||
|
test('new revision recovers old content without changing its revision or attempt',()=>{const s=new Memory();writeDraft(s,['i','decision','d','1'],{text:'old',revision:1,attempt:{key:'same'}});assert.equal(readDraft(s,['i','decision','d','2'],empty,valid,true).revision,1);assert.equal(s.length,1)})
|
||||||
|
test('successful clearing does not clear consultation or restore old selection',()=>{const s=new Memory(),scope=['i','decision','d','2'];writeDraft(s,['i','decision','d','1'],{text:'old'});writeDraft(s,scope,{text:'sent'});writeDraft(s,['i','consultation','d','2'],{text:'chat'});clearIfUnchanged(s,scope,s.getItem(draftKey(scope)));assert.deepEqual(readDraft(s,scope,empty,valid,true),empty);assert.equal(readDraft(s,['i','consultation','d','2'],empty,valid).text,'chat')})
|
||||||
|
test('late success cannot remove a newer draft',()=>{const s=new Memory(),scope=['i','c','s'];writeDraft(s,scope,{text:'sent'});const expected=s.getItem(draftKey(scope));writeDraft(s,scope,{text:'new'});assert.equal(clearIfUnchanged(s,scope,expected),false);assert.equal(readDraft(s,scope,empty,valid).text,'new')})
|
||||||
|
test('explicit clear removes only selected decision versions',()=>{const s=new Memory();for(const id of ['a','b'])for(const version of ['1','2'])writeDraft(s,['i','decision',id,version],{text:id});clearDraft(s,['i','decision','a','2'],true);assert.equal(readDraft(s,['i','decision','b','1'],empty,valid).text,'b');assert.deepEqual(readDraft(s,['i','decision','a','2'],empty,valid,true),empty);assert.equal(s.getItem(draftKey(['i','decision','a','1'])),null)})
|
||||||
|
test('unavailable storage and corrupt data throw instead of claiming saved',()=>{const s=new Memory();s.setItem=()=>{throw Error('quota')};assert.throws(()=>writeDraft(s,['i'],{text:'x'}));s.getItem=()=>'{broken';assert.throws(()=>readDraft(s,['i'],empty,valid))})
|
||||||
|
test('transient sending/errors are not revived as a stuck pending UI',()=>{const s=new Memory();writeDraft(s,['i'],{text:'retry',sending:true,error:'offline',attempt:{key:'stable'}});const value=readDraft(s,['i'],empty,valid);assert.equal(value.sending,undefined);assert.equal(value.error,undefined);assert.equal(value.attempt.key,'stable')})
|
||||||
@@ -80,3 +80,11 @@
|
|||||||
卡片展示关联任务标签、状态与summary简介。宽详情展示背景、完整候选方案、自由填写、主控检查结论及版本历史;有候选方案仍允许只填写意见,推荐不预选,显式提交。提交携当前revision,失败保留草稿和幂等键;版本冲突刷新并要求重新确认,不能偷偷提交到新版本。需要重新考虑时保留旧快照供对照。
|
卡片展示关联任务标签、状态与summary简介。宽详情展示背景、完整候选方案、自由填写、主控检查结论及版本历史;有候选方案仍允许只填写意见,推荐不预选,显式提交。提交携当前revision,失败保留草稿和幂等键;版本冲突刷新并要求重新确认,不能偷偷提交到新版本。需要重新考虑时保留旧快照供对照。
|
||||||
|
|
||||||
详情内独立咨询按decision_id隔离轮询、消息、草稿及client_key,发送显示pending并防重;未绑定显示待接通,不模拟回复。完成归档后只读历史。组件不修改普通沟通页,用户选择仍在卡片;事实核实回复、接受结论与实际授权分别呈现。键盘/窄屏可访问,不新增依赖。隔离验收覆盖切换决策不串消息、失败草稿、版本冲突、只填意见、历史及只读状态;真实会话未接通不显示已验证。
|
详情内独立咨询按decision_id隔离轮询、消息、草稿及client_key,发送显示pending并防重;未绑定显示待接通,不模拟回复。完成归档后只读历史。组件不修改普通沟通页,用户选择仍在卡片;事实核实回复、接受结论与实际授权分别呈现。键盘/窄屏可访问,不新增依赖。隔离验收覆盖切换决策不串消息、失败草稿、版本冲突、只填意见、历史及只读状态;真实会话未接通不显示已验证。
|
||||||
|
|
||||||
|
## 草稿保存与恢复反馈
|
||||||
|
|
||||||
|
沟通、决策提交和独立咨询分别保存草稿,按实例与对应目标隔离;切页、刷新、重新登录后恢复。草稿状态与发送状态分开,失败或结果未知保留输入,不自动重发;明确成功后才清对应内容。提供当前单项“清除草稿”,不能清除其他决策/会话的输入。存储不可用明确提示尚未持久保存,不显示虚假的已保存状态。
|
||||||
|
|
||||||
|
决策版本改变显示旧稿与重确认提示,重新阅读后才允许使用新版本提交,不静默把旧选项视为批准。已完成决策只读历史与草稿处理不得触发提交。页面升级提示不得自动重载丢失正在编辑内容;无本地数据的旧丢稿不声称可恢复。草稿只在网页本地,不上传,不包含token。
|
||||||
|
|
||||||
|
单项清除按钮须说明范围:决策/咨询包含对应范围旧版本,沟通仅当前入口session;两种决策输入互不清除。存储失败时允许继续保留当前输入和重试保存,禁止发送并提示刷新/退出可能丢失。成功但发现新编辑时保留新稿并说明,不显示全部已清。恢复旧版本内容必须保留版本提示与显式重确认;本地保存提示不代表已发送、模型已回复或决策已完成。
|
||||||
|
|||||||
@@ -168,3 +168,14 @@ POST /consultation-credential由普通凭据签发专用token,数据库只存h
|
|||||||
完成与归档前须检查绑定咨询session消息:有queued/claimed/uncertain未完成输入时返回409,保持可交流;completed立即拒绝新咨询POST,历史GET保留。fleet仅在archive_status=archived且本机worker无inflight/pending_completion/needs_reconciliation时停止自有子进程,不终止模型writer。已入队旧通知接收者fresh GET核对revision,过期静默停止,不能由快照直接执行。有限CLI及fleet负责已有绑定的桥接,不自行创建或归档宿主会话。
|
完成与归档前须检查绑定咨询session消息:有queued/claimed/uncertain未完成输入时返回409,保持可交流;completed立即拒绝新咨询POST,历史GET保留。fleet仅在archive_status=archived且本机worker无inflight/pending_completion/needs_reconciliation时停止自有子进程,不终止模型writer。已入队旧通知接收者fresh GET核对revision,过期静默停止,不能由快照直接执行。有限CLI及fleet负责已有绑定的桥接,不自行创建或归档宿主会话。
|
||||||
|
|
||||||
真实决策独立会话的创建、绑定后签发有限凭据、桥接、事实核实和完成归档步骤见 [独立决策会话接入](decision-sessions.md)。notifier调用决策通知submitting接口后才向宿主队列外发,保留不可变快照与未知结果保护;不能仅清应用队列冒充取消宿主输入。
|
真实决策独立会话的创建、绑定后签发有限凭据、桥接、事实核实和完成归档步骤见 [独立决策会话接入](decision-sessions.md)。notifier调用决策通知submitting接口后才向宿主队列外发,保留不可变快照与未知结果保护;不能仅清应用队列冒充取消宿主输入。
|
||||||
|
|
||||||
|
|
||||||
|
页面升级与旧草稿:状态同步不会替换已加载的网页代码。服务公布当前前端入口文件,版本变化只提示,不自动整页重载或重发。新版草稿保存机制应在同浏览器、同实例及对应会话/决策范围内恢复输入与重试信息;存储失败或旧页面尚未支持持久保存时明确提示先自行保留内容再重载,不能把复制作为正常持久化方案。旧提交缺expected_revision仍拒绝写入,不猜版本;核对response/history和咨询消息确认落库结果,再由人类入口说明操作。旧版已丢稿无服务证据不得承诺恢复。
|
||||||
|
|
||||||
|
## 本地草稿实际存储契约
|
||||||
|
|
||||||
|
同源localStorage使用workbench-draft-v1:前缀加JSON数组作用域。沟通为[instance_id, communication, 入口session];决策为[instance_id, decision, decision_id, revision];咨询为[instance_id, consultation, decision_id, session_id或unbound, revision]。不包含token,不上传未提交正文。实例或有效版本尚未就绪时禁发,不猜版本;新浏览器、不同源/实例或清理浏览器数据不保证恢复。重新登录不以token作为草稿身份,因此同浏览器同范围可恢复,并非按用户账号隔离。
|
||||||
|
|
||||||
|
每次输入写入,POST前先持久保存attempt幂等信息;失败或结果未知保留,刷新后可由用户明确重试,不自动发送。读取/保存失败显示具体风险并阻止发送,可重试保存;清除失败提示旧稿可能残留,不应重复提交。成功只清对应当前版本草稿,比较已存值防迟到响应删掉随后编辑;咨询成功不清决策选择。只确认接口成功不表示模型已回复或业务验收完成。
|
||||||
|
|
||||||
|
当前版本无记录时可读取同作用域最近旧版本草稿,保留旧revision,须用户明确重确认,决策旧选项清除后重新选择/检查意见。已完成决策的残留草稿只读,不自动重交。显式单项清除:沟通仅当前session;决策仅该决策所有版本;咨询仅该决策与咨询session范围所有版本,互不影响。清除留下无正文cleared标记防旧版本草稿重新出现。绑定session变化属于不同范围,不承诺自动跨绑定迁移。
|
||||||
|
|||||||
@@ -240,6 +240,28 @@ class WorkflowHTTPTests(unittest.TestCase):
|
|||||||
setUp=fixtures.DecisionHTTPTests.setUp
|
setUp=fixtures.DecisionHTTPTests.setUp
|
||||||
tearDown=fixtures.DecisionHTTPTests.tearDown
|
tearDown=fixtures.DecisionHTTPTests.tearDown
|
||||||
request=fixtures.DecisionHTTPTests.request
|
request=fixtures.DecisionHTTPTests.request
|
||||||
|
def test_old_page_submission_without_revision_is_rejected_before_write(self):
|
||||||
|
self.request('/api/decisions','POST',definition())
|
||||||
|
old=answer();old.pop('expected_revision')
|
||||||
|
self.assertEqual(self.request('/api/decisions/choice/respond','POST',old)[0],400)
|
||||||
|
self.assertIsNone(self.server.store.decision_get('choice')['response'])
|
||||||
|
self.assertFalse(any(e['kind']=='submission' for e in self.server.store.decision_history('choice')['history']))
|
||||||
|
first=self.request('/api/decisions/choice/respond','POST',answer())
|
||||||
|
retry=self.request('/api/decisions/choice/respond','POST',answer())
|
||||||
|
self.assertEqual(first,retry)
|
||||||
|
self.assertEqual(first[0],200)
|
||||||
|
self.assertEqual(sum(e['kind']=='submission' for e in self.server.store.decision_history('choice')['history']),1)
|
||||||
|
|
||||||
|
def test_state_reports_current_page_asset_without_changing_decisions(self):
|
||||||
|
root=Path(self.tmp.name)/'frontend';root.mkdir()
|
||||||
|
self.server.web_root=root
|
||||||
|
(root/'index.html').write_text('<script type="module" crossorigin src="/assets/index-first.js"></script>')
|
||||||
|
self.assertEqual(self.request('/api/state')[1]['frontend_entrypoint'],'/assets/index-first.js')
|
||||||
|
(root/'index.html').write_text('<script type="module" src="/assets/index-next.js"></script>')
|
||||||
|
self.assertEqual(self.request('/api/state')[1]['frontend_entrypoint'],'/assets/index-next.js')
|
||||||
|
(root/'index.html').unlink()
|
||||||
|
self.assertIsNone(self.request('/api/state')[1]['frontend_entrypoint'])
|
||||||
|
|
||||||
def test_limited_credential_route_matrix_and_closed_writes(self):
|
def test_limited_credential_route_matrix_and_closed_writes(self):
|
||||||
store=self.server.store
|
store=self.server.store
|
||||||
store.decision(definition());bind(store)
|
store.decision(definition());bind(store)
|
||||||
|
|||||||
Reference in New Issue
Block a user