From ccb5a8638dfffab8c2ab432173fd7aaf046eae82 Mon Sep 17 00:00:00 2001 From: host Date: Fri, 30 Oct 2026 12:40:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=BB=E5=8A=A1=E8=AF=A6=E6=83=85=20?= =?UTF-8?q?LLM=20=E8=AF=B7=E6=B1=82=E6=95=B0=EF=BC=88cost=5Frecords=20?= =?UTF-8?q?=E5=AD=98=20completions=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cloud/console-react/src/api/types.ts | 2 +- .../src/sections/TaskDetailPanel.tsx | 20 +-- cloud/console.html | 49 ++++---- cloud/cost.py | 27 +++- cloud/db.py | 1 + cloud/playwright_mcp.py | 118 +++++++++++++----- cloud/tests/test_models_cost.py | 14 ++- cloud/tests/test_playwright.py | 58 +++++++++ cloud/worker.py | 3 +- 9 files changed, 215 insertions(+), 77 deletions(-) diff --git a/cloud/console-react/src/api/types.ts b/cloud/console-react/src/api/types.ts index 5c89833..d8dc130 100644 --- a/cloud/console-react/src/api/types.ts +++ b/cloud/console-react/src/api/types.ts @@ -24,7 +24,7 @@ export interface Task { child_model?: string; child_effort?: string; approval?: string; result_text?: string; error_text?: string; } -export interface TaskCost { task_id?: string; project_id?: string; total_tokens?: number; usd_micros?: number; turns?: number; fallback_count?: number; actual_tokens?: number; estimated_tokens?: number; cached_input_tokens?: number; unpriced_actual_tokens?: number } +export interface TaskCost { task_id?: string; project_id?: string; total_tokens?: number; usd_micros?: number; turns?: number; fallback_count?: number; actual_tokens?: number; estimated_tokens?: number; cached_input_tokens?: number; unpriced_actual_tokens?: number; llm_requests?: number } export interface Review { id: string; target_type?: string; branch?: string; sha?: string; instructions?: string; status?: string; created_at?: number; findings?: string } export interface Approval { id: string; kind?: string; status?: string; summary?: string; decision?: string; decided_by?: string; decided_at?: number } export interface Followup { mode?: string; status?: string; sender_task_id?: string; body?: string } diff --git a/cloud/console-react/src/sections/TaskDetailPanel.tsx b/cloud/console-react/src/sections/TaskDetailPanel.tsx index c5b2bc3..d25fdb8 100644 --- a/cloud/console-react/src/sections/TaskDetailPanel.tsx +++ b/cloud/console-react/src/sections/TaskDetailPanel.tsx @@ -5,7 +5,6 @@ import { shortId, fmt } from "../api/client"; import { STATUS_EXPLAIN } from "../api/types"; import { firstChar, durationOf, tokenOf, fmtCostUsd } from "../api/taskMeta"; import DetailActions from "./detail/DetailActions"; -import BrowserPanel from "./detail/BrowserPanel"; import ReviewPanel from "./detail/ReviewPanel"; import { CoordPanel, AgentsPanel, ApprovalPanel, PeerPanel, ThreadPanel } from "./detail/DetailLists"; @@ -14,15 +13,15 @@ import { CoordPanel, AgentsPanel, ApprovalPanel, PeerPanel, ThreadPanel } from " // 审批 + 任务间消息 + 线程, all stacked under the conversation — // is gone. The same panels now live in four inspector tabs so // only one group is visible at a time: -// 概览:状态/耗时/token/成本 + 基本信息 + prompt + 操作与结果 +// 概览:状态/耗时/token/LLM 请求数/成本 + 基本信息 + prompt + 操作与结果 // 协作:协调子任务 / 子代理链路 / 给其他任务发消息 // 评审与审批:评审队列 / 运行时审批队列 // 线程:线程管理 // Data, actions and sub-sections are unchanged; only grouping // and visibility changed. -type DetailTab = "overview" | "collab" | "review" | "threads" | "browser"; +type DetailTab = "overview" | "collab" | "review" | "threads"; const DETAIL_TABS: Array<[DetailTab, string]> = [ - ["overview", "概览"], ["collab", "协作"], ["review", "评审与审批"], ["threads", "线程"], ["browser", "浏览器"], + ["overview", "概览"], ["collab", "协作"], ["review", "评审与审批"], ["threads", "线程"], ]; export default function TaskDetailPanel() { const ctx = useConsole(); @@ -59,6 +58,15 @@ export default function TaskDetailPanel() {
token
{tok}
+
+
LLM 请求数
+
+ {cost === undefined ? "加载中…" : cost === null ? "—" : (cost.llm_requests ?? 0).toLocaleString()} +
+ {cost === undefined ?
成本加载中…
+ : cost === null ?
成本明细加载失败,LLM 请求数不可用
+ :
上游补全次数之和| 轮数 {cost.turns ?? 0} | fallback {cost.fallback_count ?? 0}
} +
成本
@@ -66,7 +74,7 @@ export default function TaskDetailPanel() {
{cost === undefined ?
成本加载中…
: cost === null ?
成本明细加载失败(“查成本”按钮仍可手动拉取原始 JSON)
- :
token {(cost.total_tokens ?? 0).toLocaleString()}(实际 {(cost.actual_tokens ?? 0).toLocaleString()} / 估算 {(cost.estimated_tokens ?? 0).toLocaleString()},缓存 {(cost.cached_input_tokens ?? 0).toLocaleString()},无价 {(cost.unpriced_actual_tokens ?? 0).toLocaleString()})| 轮数 {cost.turns ?? 0} | fallback {cost.fallback_count ?? 0}
} + :
token {(cost.total_tokens ?? 0).toLocaleString()}(实际 {(cost.actual_tokens ?? 0).toLocaleString()} / 估算 {(cost.estimated_tokens ?? 0).toLocaleString()},缓存 {(cost.cached_input_tokens ?? 0).toLocaleString()},无价 {(cost.unpriced_actual_tokens ?? 0).toLocaleString()})| fallback {cost.fallback_count ?? 0}
}
@@ -105,8 +113,6 @@ export default function TaskDetailPanel() { ) : tab === "review" ? (<> - ) : tab === "browser" ? (<> - ) : (<> )} diff --git a/cloud/console.html b/cloud/console.html index bf2a4fa..97a5df0 100644 --- a/cloud/console.html +++ b/cloud/console.html @@ -4,7 +4,7 @@ Codex 命令台 - +`),C(V||"(暂无 result/error 输出)")}catch{}te(w.id),Pe(w.id),qe(w.id),_l(w.id),da(w),sl(w.id),rl(w.id),Jt(w.id),Ul(w.id),Dl(w.id)}},[te,Pe,qe,_l,da,sl,rl,Jt,Ul,Dl]),ma=m.useCallback(async w=>{const U=se.current;if(!U)return;const V={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!(V[w]&&!window.confirm(V[w])))try{const $=await et(P.taskAction(U.id,w),"POST",{});b(w+" 已提交:"+JSON.stringify($).slice(0,300)),r(),await Se(U.id)}catch($){b(w+" 失败:"+$.message)}},[Se,r]),su=m.useCallback(async()=>{const w=se.current;if(w)try{const U=await et(P.taskCost(w.id));j(JSON.stringify(U,null,2).slice(0,4e3))}catch(U){j("查成本失败:"+U.message)}},[]),ru=m.useCallback(async w=>{const U=se.current;if(U){if(!w.trim()){gt("分支消息必填");return}if(!uu.includes(U.status)){gt("仅终态任务可分支");return}try{const V=await et(P.taskFork(U.id),"POST",{message:w.trim()});gt("分支任务已建:"+V.id.slice(0,8)),r()}catch(V){gt("分支失败:"+V.message)}}},[r]),Fl=m.useCallback(async w=>{const U=se.current;if(U)try{await et(P.taskReviews(U.id),"POST",w),Bt("评审已登记(requested)"),await qe(U.id)}catch(V){Bt("登记评审需求失败:"+V.message)}},[qe]),Ae=m.useCallback(w=>{const U=se.current;if(!U)return;const V=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");V!==null&&et(P.taskReviewFindings(U.id,w),"POST",{findings:V}).then(()=>void qe(U.id)).catch($=>Bt("写 findings 失败:"+$.message))},[qe]),fl=m.useCallback((w,U)=>{const V=se.current;if(!V)return;const $=U==="approved"?"approve":"deny";et(P.taskApprovalDecision(V.id,w,$),"POST",{}).then(()=>{St("已记录"+(U==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),_l(V.id)}).catch(M=>St("决定失败:"+M.message))},[_l]),Me=m.useCallback(async(w,U,V)=>{const $=se.current;if($){if(!U.trim()){ce("目标任务 id 不能为空");return}if(!V.trim()){ce("消息正文不能为空");return}try{const M=await et(P.taskMessagesTo(U.trim()),"POST",{from_task_id:$.id,body:V.trim(),mode:w});ce("已发送("+M.delivery+")"),await rl($.id)}catch(M){ce("发送失败:"+M.message)}}},[rl]),fu=m.useCallback((w,U)=>{const V=se.current;if(!V)return;if(!["completed","failed","interrupted"].includes(V.status)){he("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const $={};if(w==="rename"){if(!U.trim()){he("新线程名不能为空");return}$.name=U.trim()}et(P.taskThreadAction(V.id,w),"POST",$).then(M=>{he(w+" 已提交:"+JSON.stringify(M).slice(0,300)),Jt(V.id),Ul(V.id)}).catch(M=>he(w+" 失败:"+M.message))},[Jt,Ul]),ou=m.useCallback(async w=>{const U=se.current;if(!U)return;const V=w.trim();if(!V){Ve("消息不能为空");return}try{uu.includes(U.status)&&U.status!=="merged"?(await et(P.taskContinue(U.id),"POST",{message:V}),Ve("已重新排队,同一会话继续"),r(),await Se(U.id)):["queued","running"].includes(U.status)?(await et(P.taskFollowups(U.id),"POST",{body:V,mode:"direct"}),Ve("已直接发送"),await te(U.id),await Pe(U.id)):Ve("merged/merge-failed 任务不可继续,也不可发后续消息")}catch($){Ve("发送失败:"+$.message)}},[Se,r,te,Pe]),du=m.useCallback(async()=>{const w=se.current;if(!w){Ce("先选一个任务");return}try{const U=await et(P.taskTrace(w.id)),V=new Blob([JSON.stringify({task_id:w.id,status:U.status,note:U.note||"",count:(U.entries||[]).length,entries:U.entries||[]},null,2)],{type:"application/json"}),$=document.createElement("a");$.href=URL.createObjectURL(V),$.download="trace-"+w.id.slice(0,8)+".json",document.body.appendChild($),$.click(),$.remove(),setTimeout(()=>URL.revokeObjectURL($.href),1e3),Ce("已下载完整链路 "+(U.entries||[]).length+" 条")}catch(U){Ce("下载失败:"+U.message)}},[]);return m.useEffect(()=>()=>re(),[re]),{currentTask:h,detailBody:T,actionMsg:N,actionReasons:H,costBox:Y,costSummary:q,forkVisible:J,setForkVisible:lt,forkMsg:Q,coordChildren:jt,agents:W,agentsNote:Zt,reviews:ht,reviewMsg:Pt,reviewError:Kt,traceEntries:rt,traceNote:B,traceDone:ut,traceKind:mt,setTraceKind:ge,traceMsg:we,followups:y,approvals:at,approvalMsg:xt,peerMessages:Ct,peerMsg:ft,threadText:Ga,threadGateNote:Le,threadMsg:wt,threadList:Kl,chatMsg:oa,chatHistory:zl,showDetail:Se,refreshDetailLists:Xa,doAction:ma,taskCost:su,forkTask:ru,postReview:Fl,submitFindings:Ae,decideApproval:fl,postPeer:Me,threadAction:fu,sendChatMessage:ou,downloadTrace:du,refreshReviews:()=>void qe(),refreshApprovals:()=>void _l()}}function ng(i){const[r,h]=m.useState([]),[o,T]=m.useState(""),[C,N]=m.useState(null),[b,H]=m.useState(""),O=m.useCallback(async K=>{const J=K!==void 0?K:i();if(!J){h([]);return}try{const lt=await et(P.projectAutomations(J));h(lt.automations||[])}catch(lt){T("自动化加载失败:"+lt.message)}},[i]),Y=m.useCallback(async(K,J,lt,Q)=>{const gt=i();if(!gt){T("先选项目");return}if(!K.trim()){T("自动化名称必填");return}if(!J.trim()){T("定时任务内容必填");return}const jt={name:K.trim(),prompt:J.trim()};lt.trim()&&(jt.interval_seconds=parseInt(lt.trim(),10)),Q.trim()&&(jt.repo_url=Q.trim());try{await et(P.projectAutomations(gt),"POST",jt),T("自动化已创建(登记定义,到期后由执行器认领)"),await O(gt)}catch(Ot){T("创建失败:"+Ot.message)}},[i,O]),j=m.useCallback(K=>{const J=i();J&&et(P.projectAutomationCancel(J,K),"POST",{}).then(()=>{T("自动化已取消"),O(J)}).catch(lt=>T("取消失败:"+lt.message))},[i,O]),q=m.useCallback(async K=>{const J=K!==void 0?K:i();if(!J){N(null);return}try{const lt=await et(P.projectTriage(J));N(lt),H("")}catch(lt){H("分诊加载失败:"+lt.message)}},[i]);return{automations:r,autoMsg:o,triage:C,triageError:b,refreshAutomations:O,createAutomation:Y,cancelAutomation:j,refreshTriage:q}}function ug(i,r){const h=m.useRef(i);h.current=i,m.useEffect(()=>{const o=setInterval(()=>{const T=document.activeElement;h.current(T?T.tagName==="SELECT":!1)},r);return()=>clearInterval(o)},[r])}function ig(i,r){const h=m.useRef(i);h.current=i,m.useEffect(()=>{const o=setInterval(()=>h.current(),r);return()=>clearInterval(o)},[r])}function cg(i){const r=h=>String(h).padStart(2,"0");return r(i.getHours())+":"+r(i.getMinutes())+":"+r(i.getSeconds())}function sg(){var lt;const i=eg(),r=lg(),h=m.useRef("");h.current=i.projectId;const o=m.useCallback(()=>h.current,[]),T=ng(o),C=m.useCallback(Q=>{const gt=i.projects.find(jt=>jt.id===Q);return gt&>.my_role||null},[i.projects]),N=m.useCallback(()=>{r.refreshTasks(void 0,h.current)},[r]),b=ag(C,N),[H,O]=m.useState("--:--:--"),Y=m.useCallback(Q=>{i.setProjectId(Q),h.current=Q,r.refreshTasksFirstPage(Q),i.refreshEnvs(Q),i.refreshMembers(Q),i.refreshSkills(Q),T.refreshAutomations(Q),T.refreshTriage(Q)},[i,r,T]),j=(lt=i.projects[0])==null?void 0:lt.id;m.useEffect(()=>{!i.projectId&&j&&Y(j)},[i.projectId,j,Y]);const q=m.useCallback(Q=>{i.saveKeyInput(Q),Q.trim()&&(i.refreshHealth(),i.refreshProjects(),i.refreshWorkers(),h.current&&(r.refreshTasks(h.current),i.refreshEnvs(h.current),i.refreshMembers(h.current)),i.loadRelayModels(),i.loadQuota(),i.loadGithubRepos(),i.probeAdmin())},[i,r]),K=m.useCallback(Q=>{if(O(cg(new Date)),!bl()){i.refreshHealth();return}r.refreshTasks(void 0,h.current),i.refreshHealth(),i.loadQuota(),i.refreshWorkers(),i.refreshMembers(),i.refreshSkills(),T.refreshAutomations(),T.refreshTriage(),b.refreshDetailLists(),Q||(i.refreshProjects(),i.refreshEnvs())},[i,r,T,b]),J=m.useCallback(()=>{bl()&&(i.loadRelayModels(),i.loadGithubRepos())},[i]);return m.useEffect(()=>{i.refreshHealth(),i.refreshProjects(),i.refreshWorkers(),i.loadQuota(),i.loadGithubRepos(),bl()&&i.loadRelayModels(),i.probeAdmin()},[]),{keySaved:i.keySaved,keyState:i.keyState,saveKeyInput:q,clearKeyInput:i.clearKeyInput,quotaText:i.quotaText,healthText:i.healthText,refreshHealth:i.refreshHealth,adminGate:i.adminGate,adminBody:i.adminBody,adminResult:i.adminResult,adminUserId:i.adminUserId,setAdminUserId:i.setAdminUserId,adminQuota:i.adminQuota,adminSweep:i.adminSweep,adminBackup:i.adminBackup,projects:i.projects,projectId:i.projectId,projectMsg:i.projectMsg,selectProject:Y,refreshProjects:i.refreshProjects,createProject:i.createProject,githubRepos:i.githubRepos,workers:i.workers,workersSummary:i.workersSummary,workersError:i.workersError,refreshWorkers:i.refreshWorkers,members:i.members,memberMsg:i.memberMsg,memberError:i.memberError,refreshMembers:()=>void i.refreshMembers(),inviteMember:i.inviteMember,changeMemberRole:i.changeMemberRole,skills:i.skills,apps:i.apps,mcpServers:i.mcpServers,skillMsg:i.skillMsg,skillError:i.skillError,mcpMsg:i.mcpMsg,mcpOauthMsg:i.mcpOauthMsg,mcpOauthBox:i.mcpOauthBox,refreshSkills:()=>void i.refreshSkills(),reloadMcp:i.reloadMcp,mcpOauth:i.mcpOauth,envs:i.envs,envCount:i.envCount,envMsg:i.envMsg,envRepoLine:i.envRepoLine,envRepoBar:i.envRepoBar,envRepoMsg:i.envRepoMsg,envSelect:i.envSelect,setEnvSelect:i.setEnvSelect,refreshEnvs:()=>void i.refreshEnvs(),createEnv:i.createEnv,archiveEnv:i.archiveEnv,saveEnvRepo:i.saveEnvRepo,models:i.models,modelCustomVisible:i.modelCustomVisible,tasks:r.tasks,taskCountText:r.taskCountText,taskError:r.taskError,refreshTasks:()=>void r.refreshTasks(void 0,h.current),statusFilter:r.statusFilter,setStatusFilter:r.setStatusFilter,executorFilter:r.executorFilter,setExecutorFilter:r.setExecutorFilter,searchText:r.searchText,setSearchText:r.setSearchText,filteredTasks:r.filteredTasks,taskPage:r.page,taskTotalPages:r.totalPages,taskTotal:r.taskTotal,taskStatusCounts:r.taskStatusCounts,goToTaskPage:r.goToPage,currentTask:b.currentTask,detailBody:b.detailBody,actionMsg:b.actionMsg,actionReasons:b.actionReasons,showDetail:Q=>void b.showDetail(Q),doAction:b.doAction,taskCost:b.taskCost,costBox:b.costBox,costSummary:b.costSummary,forkVisible:b.forkVisible,setForkVisible:b.setForkVisible,forkTask:b.forkTask,forkMsg:b.forkMsg,coordChildren:b.coordChildren,coordText:"",agents:b.agents,agentsNote:b.agentsNote,reviews:b.reviews,reviewMsg:b.reviewMsg,reviewError:b.reviewError,refreshReviews:b.refreshReviews,postReview:b.postReview,submitFindings:b.submitFindings,traceEntries:b.traceEntries,traceNote:b.traceNote,traceDone:b.traceDone,traceKind:b.traceKind,setTraceKind:b.setTraceKind,downloadTrace:b.downloadTrace,traceMsg:b.traceMsg,sendChatMessage:b.sendChatMessage,chatMsg:b.chatMsg,chatHistory:b.chatHistory,followups:b.followups,approvals:b.approvals,approvalMsg:b.approvalMsg,refreshApprovals:b.refreshApprovals,decideApproval:b.decideApproval,peerMessages:b.peerMessages,peerMsg:b.peerMsg,postPeer:b.postPeer,automations:T.automations,autoMsg:T.autoMsg,refreshAutomations:()=>void T.refreshAutomations(),createAutomation:T.createAutomation,cancelAutomation:T.cancelAutomation,triage:T.triage,triageError:T.triageError,refreshTriage:()=>void T.refreshTriage(),threadText:b.threadText,threadGateNote:b.threadGateNote,threadMsg:b.threadMsg,threadList:b.threadList,threadAction:b.threadAction,dispatch:async(Q,gt)=>{const jt=await r.dispatch(Q,gt);return r.refreshTasksFirstPage(h.current),jt},dispatchMsg:"",refreshAll:K,refreshSlow:J,refreshStamp:H}}function nu({label:i,value:r,sub:h}){return c.jsxs("div",{className:"dash-card data-card",children:[c.jsx("div",{className:"dash-card-label",children:i}),c.jsx("div",{className:"dash-card-value",children:r}),c.jsx("div",{className:"muted",children:h})]})}function rg(){const i=Lt(),r=i.healthText==="未加载"?"未加载":i.healthText.includes("worker_ok=true")?"正常":i.healthText.includes("worker_ok=false")?"异常":i.healthText.includes("加载失败")?"加载失败":"未知",h=i.quotaText.match(/剩余 \$([0-9.]+)/),o=i.quotaText.includes("加载失败")?"加载失败":i.quotaText.includes("未填 key")||i.quotaText==="未加载"?"未加载":h?"$"+h[1]:"配额",T=["running","completed","failed","queued","merged","interrupted"].map(j=>j+" "+(i.taskStatusCounts[j]||0)).join(" | "),C=i.projects.find(j=>j.id===i.projectId),N=i.taskError?{value:"加载失败",sub:i.taskError}:i.projectId?{value:String(i.taskTotal),sub:i.taskCountText+" | "+T+(C?" | "+C.name:"")}:{value:"先选项目",sub:i.projectMsg||"项目加载后自动选第一个"};let b;if(i.workersError)b={value:"加载失败",sub:i.workersError};else if(i.workers===null)b={value:i.workersSummary||"未加载",sub:"完整 worker 清单仅全局管理员可见"};else{const j=i.workers.filter(q=>{if(q.alive!==void 0&&q.alive!==null)return!!q.alive;const K=Jh(q).age;return K!==null&&K<=120}).length;b={value:j+" 在线 / "+i.workers.length,sub:i.workers.length-j+" 离线"}}const H=i.projectMsg.includes("加载失败")?{value:"加载失败",sub:i.projectMsg}:{value:i.projects.length+" 个",sub:C?"当前项目:"+C.name:"未选项目(加载后自动选第一个)"},O=[i.envMsg,i.memberError,i.skillError].filter(j=>j&&j.includes("失败")),Y=i.projectId?O.length?{value:"部分加载失败",sub:O.join(" | ")}:{value:i.envs.length+" 个环境",sub:i.members.length+" 成员 | "+i.skills.length+" 技能 | "+i.apps.length+" 应用 | "+i.mcpServers.length+" MCP"}:{value:"先选项目",sub:"选项目后显示环境 / 成员 / 技能计数"};return c.jsxs("section",{className:"dash-cards","aria-label":"数据卡片",children:[c.jsx(nu,{label:"健康",value:r,sub:i.healthText}),c.jsx(nu,{label:"配额",value:o,sub:i.quotaText}),c.jsx(nu,{label:"任务",value:N.value,sub:N.sub}),c.jsx(nu,{label:"Workers",value:b.value,sub:b.sub}),c.jsx(nu,{label:"项目",value:H.value,sub:H.sub}),c.jsx(nu,{label:"项目数据",value:Y.value,sub:Y.sub})]})}function $t({id:i,className:r,title:h,children:o}){return c.jsxs("section",{id:i,className:"card"+(r?" "+r:""),children:[h?c.jsx("h2",{children:h}):null,o]})}function Rt({children:i}){return c.jsx("div",{className:"bar",children:i})}function jl({items:i,empty:r}){return i.length?c.jsx("ul",{className:"data-list",children:i.map((h,o)=>c.jsx("li",{children:h},o))}):c.jsx("div",{className:"muted",children:r||"(暂无)"})}function cu({children:i}){return c.jsx("ul",{className:"data-list",children:i})}function fg(){const i=Lt(),[r,h]=m.useState(""),[o,T]=m.useState(""),[C,N]=m.useState(!1),[b,H]=m.useState(!1),[O,Y]=m.useState(!1),[j,q]=m.useState("docker"),[K,J]=m.useState(""),[lt,Q]=m.useState(""),[gt,jt]=m.useState(""),[Ot,W]=m.useState(""),[Ht,Zt]=m.useState(""),[it,ht]=m.useState(""),[Qt,Pt]=m.useState(""),Bt=m.useRef(null),Kt=m.useRef(null),k=m.useRef(null),[rt,ct]=m.useState("");async function B(){var we,Ce,y;if(ct(""),!i.projectId){ct("先选项目");return}if(!r.trim()){ct("prompt 必填");return}const A={project_id:i.projectId,prompt:r.trim()};i.envSelect&&(A.environment_id=i.envSelect),j&&(A.executor=j);const ut=lt.trim()||K;ut&&(A.model=ut),gt&&(A.effort=gt);const pt=it.trim()||Ht;if(pt&&(A.child_model=pt),Qt&&(A.child_effort=Qt),Ot&&(A.approval=Ot),O){if(Ot==="full"){ct("计划模式不能与完全访问同用");return}if(C||b){ct("计划模式不能与 goal 模式同用");return}A.plan=1}b?(A.goal_mode=1,A.no_time_limit=1):C&&(A.goal_mode=1);const mt=o.trim();if(mt){try{A.coordinator_parts=JSON.parse(mt)}catch{ct("协调员子任务 JSON 解析失败");return}if(!Array.isArray(A.coordinator_parts)){ct("协调员子任务必须是数组");return}if(C||b||O){ct("协调员任务不能与 goal/计划模式同用:协调员任务本身不跑模型回合,请取消 goal/计划勾选,或清空协调员 JSON");return}}const ge=tg(((we=Bt.current)==null?void 0:we.files)||null,((Ce=Kt.current)==null?void 0:Ce.files)||null,((y=k.current)==null?void 0:y.files)||null);try{const D=await i.dispatch(A,ge);ct(D),h(""),T(""),Bt.current&&(Bt.current.value=""),Kt.current&&(Kt.current.value=""),k.current&&(k.current.value="")}catch(D){ct("提交失败:"+((D==null?void 0:D.message)||String(D)))}}return c.jsxs($t,{id:"nav-newtask",className:"composer",title:"新建任务",children:[c.jsxs(Rt,{children:[c.jsxs("select",{"aria-label":"环境",value:i.envSelect,onChange:A=>i.setEnvSelect(A.target.value),children:[c.jsx("option",{value:"",children:"环境:不指定(走项目仓库)"}),i.envs.map(A=>c.jsxs("option",{value:A.id,disabled:A.status!=="ready",children:[A.name,"(",A.status,")"]},A.id))]}),c.jsxs("select",{"aria-label":"执行器",value:j,onChange:A=>q(A.target.value),children:[c.jsx("option",{value:"docker",children:"docker(默认,多用户下隔离最强)"}),c.jsx("option",{value:"local",children:"local"}),c.jsx("option",{value:"cloud",children:"cloud"})]}),c.jsxs("label",{children:[c.jsx("input",{type:"checkbox",checked:C,onChange:A=>N(A.target.checked)})," goal 模式"]}),c.jsxs("label",{children:[c.jsx("input",{type:"checkbox",checked:b,onChange:A=>H(A.target.checked)})," goal 模式无敌版"]}),c.jsxs("label",{children:[c.jsx("input",{type:"checkbox",checked:O,onChange:A=>Y(A.target.checked)})," 计划模式(只读)"]})]}),c.jsx("textarea",{className:"prompt stacked",placeholder:"任务 prompt(必填)",value:r,onChange:A=>h(A.target.value)}),c.jsx("textarea",{className:"coord stacked",placeholder:"协调员子任务 JSON(可选),留空为普通任务",value:o,onChange:A=>T(A.target.value)}),c.jsxs("div",{className:"muted",children:["协调员格式:[","{",'"name":"后端","prompt":"做后端"',"}",",","{",'"name":"前端","prompt":"做前端"',"}","];提交后 worker 并行建子任务、等齐、合流汇总。不能与 goal/计划模式同用。协调员与 goal/计划互斥:勾了 goal 或计划模式时不要填协调员 JSON,提交前会校验并拦截。子任务费用记父任务创建人账;父任务创建时按分工数预留配额,余额不足时整个父任务被挡下。"]}),c.jsxs(Rt,{children:[c.jsxs("select",{"aria-label":"模型",value:K,onChange:A=>J(A.target.value),children:[c.jsx("option",{value:"",children:"模型:默认(跟随配置)"}),i.models.map(A=>c.jsx("option",{value:A,children:A},A))]}),i.modelCustomVisible?c.jsx("input",{type:"text",placeholder:"手动输入模型(中转列表取不到时用)",value:lt,onChange:A=>Q(A.target.value)}):null,c.jsxs("select",{"aria-label":"推理强度",value:gt,onChange:A=>jt(A.target.value),children:[c.jsx("option",{value:"",children:"推理强度:默认(跟随配置)"}),Qh.map(A=>c.jsx("option",{value:A,children:A},A))]}),c.jsxs("select",{"aria-label":"权限",value:Ot,onChange:A=>W(A.target.value),children:[c.jsx("option",{value:"",children:"权限:默认(跟随配置)"}),c.jsx("option",{value:"ask",children:"请求批准"}),c.jsx("option",{value:"auto",children:"帮我批准"}),c.jsx("option",{value:"full",children:"完全访问"})]})]}),c.jsxs(Rt,{children:[c.jsxs("select",{"aria-label":"子任务模型",value:Ht,onChange:A=>Zt(A.target.value),children:[c.jsx("option",{value:"",children:"子任务模型:跟随本任务模型"}),i.models.map(A=>c.jsx("option",{value:A,children:A},A))]}),c.jsx("input",{type:"text",placeholder:"子任务模型手动输入(中转列表里没有时填,如 gpt-6-luna;留空=跟随本任务模型)",value:it,onChange:A=>ht(A.target.value)}),c.jsxs("select",{"aria-label":"子任务强度",value:Qt,onChange:A=>Pt(A.target.value),children:[c.jsx("option",{value:"",children:"子任务强度:跟随本任务强度"}),Qh.map(A=>c.jsx("option",{value:A,children:A},A))]})]}),c.jsx("div",{className:"muted",children:"子任务模型/强度是派生默认:本任务用 marker task-create 或协调员分工派生的子任务,未显式写模型/强度时用它;块/分工里显式写了则覆盖它。也可以不在这里填、直接在 prompt 里写“子任务用 gpt-6-luna max”,建任务时服务端会解析成同一对字段(任务详情里可核对)。"}),C?c.jsx("div",{className:"muted",children:"goal 模式:输入框内容即目标,不需要专用 prompt 与验收命令;不限轮次,模型确认达成后在回复末尾输出“目标已达成”即结束,未达成会一直运行,可用取消停止。"}):null,b?c.jsx("div",{className:"muted",children:"goal 模式无敌版:goal 模式 + 任务不限时(去掉 worker/docker 总超时;单轮 600 秒护栏还在,不限的是任务总时长与总轮次)。服务端默认的自动续跑与断点镜像同时生效。"}):null,c.jsxs(Rt,{children:[c.jsx("span",{className:"muted",children:"附件(放进任务工作区,图片与普通文件不区分):"}),c.jsx("input",{ref:Bt,type:"file",multiple:!0,title:"文件"}),c.jsx("input",{ref:Kt,type:"file",multiple:!0,title:"文件夹",webkitdirectory:""}),c.jsx("input",{ref:k,type:"file",accept:"image/*",multiple:!0,title:"图片"})]}),c.jsxs(Rt,{children:[c.jsx("button",{className:"seal",onClick:B,children:"提交任务"}),c.jsx("span",{className:"muted",children:rt||i.dispatchMsg})]})]})}function Fh(i){const r=(i.prompt||"").trim();return r?[...r][0]:i.id?[...i.id][0]:"?"}function zc(i){const r=Math.max(0,Math.floor(i));if(r<60)return r+"秒";if(r<3600){const T=Math.floor(r/60),C=r%60;return C?T+"分"+C+"秒":T+"分"}if(r<86400){const T=Math.floor(r/3600),C=Math.floor(r%3600/60);return C?T+"小时"+C+"分":T+"小时"}const h=Math.floor(r/86400),o=Math.floor(r%86400/3600);return o?h+"天"+o+"小时":h+"天"}function iu(i){if(i==null)return null;const r=Number(i);return Number.isFinite(r)?r:null}function Wh(i){const r=Date.now()/1e3,h=iu(i.started_at),o=iu(i.finished_at),T=iu(i.created_at);return h!==null&&o!==null&&o>=h?[zc(o-h),"总耗时:开始→结束(末次认领的执行时长)"]:h!==null&&i.status==="running"?["进行中 "+zc(r-h),"总耗时:进行中,已用时长从开始算起,随刷新更新"]:T!==null&&o!==null&&o>=T?[zc(o-T),"总耗时:创建→完成(含排队等待;无开始时间时的兜底口径)"]:T!==null&&(i.status==="queued"||i.status==="running")?[(i.status==="queued"?"排队中 ":"进行中 ")+zc(r-T),"总耗时:从创建算起的已用时长(无开始时间时的兜底口径)"]:["—","总耗时:无可用时间戳"]}function og(i){if(i==null||!Number.isFinite(Number(i)))return"—";const r=Number(i)/1e6;return"$"+(r>0&&r<.01?r.toFixed(4):r.toFixed(2))}function $h(i){const r=iu(i.total_tokens);if(r!==null)return[r.toLocaleString()+" token","token:服务端 total_tokens"];const h=iu(i.actual_tokens),o=iu(i.estimated_tokens);return h!==null||o!==null?[((h||0)+(o||0)).toLocaleString()+" token","token:actual + estimated 分项加总"]:["token —","token:本任务对象未带 token 字段;单任务真数在详情的成本里可查,不逐个调成本接口、不编造"]}function dg({onNew:i}){const r=Lt();return c.jsxs("div",{children:[c.jsxs("div",{className:"search-box",children:[c.jsxs("svg",{viewBox:"0 0 24 24",width:"18",height:"18",fill:"none",stroke:"currentColor",strokeWidth:"1.8",strokeLinecap:"round","aria-hidden":"true",children:[c.jsx("circle",{cx:"11",cy:"11",r:"7"}),c.jsx("path",{d:"M21 21l-4.3-4.3"})]}),c.jsx("input",{type:"text",placeholder:"搜索","aria-label":"搜索任务",value:r.searchText,onChange:h=>r.setSearchText(h.target.value)})]}),c.jsx("div",{className:"bar",children:c.jsxs("select",{"aria-label":"项目",value:r.projectId,onChange:h=>r.selectProject(h.target.value),children:[r.projects.length===0?c.jsx("option",{value:"",children:"(先加载项目)"}):null,r.projects.map(h=>c.jsxs("option",{value:h.id,children:[h.name," (",Ne(h.id),")"]},h.id))]})}),c.jsxs("div",{className:"list-section-head",children:[c.jsx("span",{children:"主要聊天"}),c.jsx("button",{type:"button","aria-label":"派发新任务",title:"派发新任务",onClick:i,children:"+"})]}),c.jsxs("div",{className:"bar",children:[c.jsxs("select",{"aria-label":"状态筛选",value:r.statusFilter,onChange:h=>r.setStatusFilter(h.target.value),children:[c.jsx("option",{value:"",children:"状态:全部"}),["queued","running","completed","failed","interrupted","merged","merge-failed"].map(h=>c.jsx("option",{value:h,children:h},h))]}),c.jsxs("select",{"aria-label":"执行器筛选",value:r.executorFilter,onChange:h=>r.setExecutorFilter(h.target.value),children:[c.jsx("option",{value:"",children:"执行器:全部"}),c.jsx("option",{value:"local",children:"local"}),c.jsx("option",{value:"docker",children:"docker"}),c.jsx("option",{value:"cloud",children:"cloud"})]})]}),c.jsxs("div",{className:"bar",children:[c.jsx("button",{onClick:r.refreshTasks,children:"刷新"}),c.jsx("span",{className:"muted",children:r.taskCountText})]}),c.jsxs("div",{className:"bar",children:[c.jsx("button",{disabled:r.taskPage<=0,onClick:()=>r.goToTaskPage(r.taskPage-1),children:"上一页"}),c.jsxs("span",{className:"muted",children:["第 ",r.taskPage+1," / ",r.taskTotalPages," 页(每页 20 条,最新在前)"]}),c.jsx("button",{disabled:r.taskPage+1>=r.taskTotalPages,onClick:()=>r.goToTaskPage(r.taskPage+1),children:"下一页"})]}),r.taskError?c.jsx("div",{className:"err",children:r.taskError}):r.projectId?r.filteredTasks.length===0?c.jsx("div",{className:"muted",children:r.taskTotal?"没有符合筛选条件的任务":"该项目暂无任务"}):c.jsx("ul",{className:"issue-list",children:r.filteredTasks.map(h=>{var H;const[o,T]=Wh(h),[C,N]=$h(h),b=Fh(h);return c.jsxs("li",{className:"issue-item"+(((H=r.currentTask)==null?void 0:H.id)===h.id?" selected":""),onClick:()=>r.showDetail(h.id),children:[c.jsx("span",{className:"issue-state st-"+h.status,"aria-hidden":"true"}),c.jsx("span",{className:"issue-first",title:"任务首字:"+b,"aria-hidden":"true",children:b}),c.jsxs("span",{style:{flex:1,minWidth:0},children:[c.jsxs("div",{className:"issue-title",children:[h.plan?"[计划] ":"",h.forked_from?"[分支] ":"",h.coordinator_parts?"[协调] ":"",h.parent_task_id?"[协调子任务] ":"",String(h.prompt||Ne(h.id)).slice(0,60)]}),c.jsxs("div",{className:"issue-meta",children:[Ne(h.id)," · ",c.jsx("span",{className:"st-"+h.status,children:h.status})," · ",h.executor||"",h.updated_at?" · "+new Date(h.updated_at*1e3).toLocaleString():""," · ",c.jsxs("span",{title:T,children:["耗时 ",o]})," · ",c.jsx("span",{title:N,children:C})]})]})]},h.id)})}):c.jsx("div",{className:"muted",children:"先选项目"})]})}function mg(i){const r=m.useRef(null),[h,o]=m.useState(!0),T=m.useRef(!0),C=()=>{const N=r.current;if(!N)return;const b=N.scrollHeight-N.scrollTop-N.clientHeight<80;T.current=b,o(b)};return m.useEffect(()=>{T.current&&r.current&&(r.current.scrollTop=r.current.scrollHeight)},[i]),{ref:r,following:h,onScroll:C}}function hg({msg:i}){if(i.role==="status")return c.jsx("div",{className:"chat-status",children:i.text});const r=i.label||(i.role==="user"?"用户":i.role==="assistant"?"助手":"工具");return c.jsxs("div",{className:"gh-comment "+i.role,children:[c.jsx("div",{className:"gh-comment-header",children:c.jsx("span",{className:"gh-comment-author",children:r})}),c.jsx("div",{className:"gh-comment-body",children:i.text})]})}function vg({messages:i,empty:r}){const{ref:h,following:o,onScroll:T}=mg(i.length);return c.jsxs("div",{className:"chat-list",ref:h,onScroll:T,children:[i.length===0?c.jsx("div",{className:"muted",children:r||"(暂无消息)"}):i.map(C=>c.jsx(hg,{msg:C},C.id)),!o&&i.length>0?c.jsx("button",{className:"chat-follow",onClick:()=>{h.current&&(h.current.scrollTop=h.current.scrollHeight)},children:"回到最新 ↓"}):null]})}function yg({onSend:i,disabled:r,placeholder:h}){const[o,T]=m.useState(""),C=()=>{const N=o.trim();!N||r||(i(N),T(""))};return c.jsxs("div",{className:"gh-comment-box",children:[c.jsx("textarea",{"aria-label":"会话输入",rows:3,placeholder:h||"消息",value:o,disabled:r,onChange:N=>T(N.target.value),onKeyDown:N=>{N.key==="Enter"&&!N.shiftKey&&(N.preventDefault(),C())}}),c.jsx("div",{className:"gh-comment-footer",children:c.jsx("button",{type:"button",className:"primary","aria-label":"发送",disabled:r||!o.trim(),onClick:C,children:"发送"})})]})}function Xh(i,r){const h=i.id||"trace-"+r;if(i.kind==="message"){const o=(i.phase?"["+i.phase+"] ":"")+(i.text||"");return{id:h,role:"assistant",kind:i.kind,text:i.truncated?o+` +(链路摘要已截断至 300 字,完整回复见右侧任务结果)`:o,label:"助手"}}if(i.kind==="exec"||i.kind==="file"||i.kind==="tool"||i.kind==="call"){const o=i.kind==="exec"?"命令":i.kind==="file"?"文件":i.kind==="tool"?"工具":"调用";return{id:h,role:"tool",kind:i.kind,text:vf(i),label:o}}return i.kind==="plan"?{id:h,role:"status",kind:i.kind,text:i.text||""}:i.kind==="reasoning"?{id:h,role:"status",kind:i.kind,text:"思考:"+(i.summary||"")}:i.kind==="agent-start"?{id:h,role:"status",kind:i.kind,text:"子代理开始:"+(i.name||"")+(i.prompt?"("+i.prompt+")":"")}:i.kind==="agent-done"?{id:h,role:"status",kind:i.kind,text:"子代理结束:"+(i.name||"")+" "+(i.status||"")}:{id:h,role:"status",kind:i.kind,text:vf(i)}}function gg(){const i=Lt(),r=i.currentTask,h=m.useMemo(()=>{if(!r)return[];let H;if(i.chatHistory){H=i.chatHistory.messages.map(J=>({id:J.id,role:J.role,text:J.text,label:J.label,kind:J.kind}));const q=new Set(H.map(J=>J.id)),K=i.traceEntries.filter(J=>J.id&&!q.has(J.id)&&!String(J.id).startsWith("fu-")).map((J,lt)=>Xh(J,lt));H=[...H,...K]}else{const q=r.prompt?[{id:"task-prompt",role:"user",text:r.prompt}]:[],K=i.traceEntries.filter(lt=>!String(lt.id||"").startsWith("fu-")).map((lt,Q)=>Xh(lt,Q)),J=i.followups.map((lt,Q)=>({id:"followup-"+Q,role:"user",text:String(lt.body||""),label:"后续消息"}));H=[...q,...K,...J]}const O=i.traceKind,Y=O?H.filter(q=>q.role==="user"||!q.kind||q.kind===O):H,j=i.traceDone?[{id:"trace-done",role:"status",text:"链路已结束("+i.traceDone+")"}]:[];return[...Y,...j]},[r,i.chatHistory,i.traceEntries,i.followups,i.traceKind,i.traceDone]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示继续会话"});const o=uu.includes(r.status),T=["queued","running"].includes(r.status),C=o||T,N=o?"终态任务:发送走继续接口(同一会话再跑一轮)":T?"运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)":"merged/merge-failed 任务不可继续,也不可发后续消息",b=i.chatHistory?i.chatHistory.legacy_continue_count??null:null;return c.jsxs("div",{className:"chat-column",children:[c.jsx("div",{className:"muted",children:"实时对话"}),c.jsxs(Rt,{children:[c.jsxs("select",{"aria-label":"链路类型",value:i.traceKind,onChange:H=>i.setTraceKind(H.target.value),children:[c.jsx("option",{value:"",children:"链路类型:全部"}),["exec","file","call","tool","message","reasoning","plan","agent-start","agent-done"].map(H=>c.jsx("option",{value:H,children:H==="exec"?"命令":H==="file"?"文件":H==="call"?"调用":H==="tool"?"工具":H==="message"?"消息":H==="reasoning"?"思考":H==="plan"?"计划":"子代理"},H))]}),c.jsx("button",{onClick:i.downloadTrace,children:"下载完整链路"}),c.jsx("span",{className:"muted",children:i.traceMsg})]}),i.traceNote?c.jsx("div",{children:c.jsx("span",{className:"err",children:i.traceNote})}):null,b!==null&&b>0?c.jsxs("div",{className:"muted",children:["此前有 ",b," 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。"]}):null,i.chatHistory&&i.chatHistory.trace_note?c.jsx("div",{className:"muted",children:i.chatHistory.trace_note}):null,c.jsx(vg,{messages:h,empty:"(暂无会话消息)"}),c.jsx(yg,{disabled:!C,placeholder:"消息",onSend:H=>void i.sendChatMessage(H)}),c.jsxs("div",{className:"muted",children:[N,"。",i.chatMsg]}),c.jsx("div",{className:"muted",children:"对话时间线:首条是本任务 prompt(用户);助手回复、命令/文件/工具/调用、计划/思考/子代理开始结束都在同一列里,不再另起一份链路名单。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。"})]})}function pg(){return Lt().currentTask?c.jsx("div",{className:"conversation",children:c.jsx(gg,{})}):c.jsx("div",{className:"conversation",children:c.jsx("div",{className:"muted",children:"在任务列表里点一条任务,对话和详情就切到该任务;也可以在上面派发一个新任务。"})})}function Sg(){const i=Lt(),r=i.currentTask,[h,o]=m.useState("");if(!r)return null;const T=uu.includes(r.status),C=["failed","interrupted","merge-failed"].includes(r.status);return c.jsxs(c.Fragment,{children:[c.jsxs(Rt,{children:[c.jsx("button",{disabled:!C,title:C?"":"仅 failed/interrupted/merge-failed 可重试",onClick:()=>i.doAction("retry"),children:"重试"}),c.jsx("button",{className:"danger",disabled:T,title:T?"终态任务不可取消":"",onClick:()=>i.doAction("cancel"),children:"取消"}),c.jsx("button",{disabled:r.status!=="completed",title:r.status!=="completed"?"仅 completed 可合并":"",onClick:()=>i.doAction("merge"),children:"合并(completed → merged)"}),c.jsx("button",{disabled:r.status!=="merged",title:r.status!=="merged"?"仅 merged 可提 PR":"",onClick:()=>i.doAction("pr"),children:"提 PR(须 merged)"}),T?c.jsx("button",{onClick:()=>i.setForkVisible(!0),children:"分支"}):null,c.jsx("button",{onClick:i.taskCost,children:"查成本"}),c.jsxs("span",{className:"muted",children:[i.actionMsg," ",i.actionReasons]})]}),i.forkVisible?c.jsxs("div",{children:[c.jsx("input",{type:"text",placeholder:"分支首轮消息(必填,带历史的新会话)",style:{width:"100%",boxSizing:"border-box",margin:"6px 0"},value:h,onChange:N=>o(N.target.value)}),c.jsxs(Rt,{children:[c.jsx("button",{className:"seal",onClick:async()=>{await i.forkTask(h),o("")},children:"创建分支任务"}),c.jsx("span",{className:"muted",children:i.forkMsg})]})]}):null]})}function bg(){const i=Lt(),[r,h]=m.useState("uncommittedChanges"),[o,T]=m.useState(""),[C,N]=m.useState(""),[b,H]=m.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:O=>h(O.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:o,onChange:O=>T(O.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:C,onChange:O=>N(O.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:b,onChange:O=>H(O.target.value)}),c.jsxs(Rt,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:o.trim()||void 0,sha:C.trim()||void 0,instructions:b.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(cu,{children:i.reviews.map(O=>{let Y=O.target_type||"";return O.branch&&(Y+=":"+O.branch),O.sha&&(Y+=":"+String(O.sha).slice(0,12)),O.instructions&&(Y+=":"+String(O.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",Ne(O.id)," | ",Y," | ",c.jsx("span",{className:"st-"+(O.status||""),children:O.status}),O.created_at?" | "+Ya(O.created_at):"",c.jsx("br",{}),O.findings?String(O.findings).slice(0,1e3):c.jsx("span",{className:"muted",children:"(暂无 findings:评审尚未执行或尚未写回)"})]}),c.jsx("button",{onClick:()=>i.submitFindings(O.id),children:"写 findings"})]},O.id)})})]})}function jg(){const i=Lt();return i.currentTask?i.currentTask.coordinator_parts?i.coordChildren.length===0?c.jsx("div",{className:"muted",children:"(子任务尚未创建)"}):c.jsx(cu,{children:i.coordChildren.map(r=>c.jsx("li",{children:c.jsxs("span",{className:"row-main",children:["分工 ",c.jsx("b",{children:r.coordinator_part||""})," | 状态 ",c.jsx("span",{className:"st-"+r.status,children:r.status})," | ",Ne(r.id)," | ",String(r.prompt||"").slice(0,60)]})},r.id))}):c.jsx("div",{className:"muted",children:"(非协调员任务)"}):c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示协调员的子任务"})}function xg(){const i=Lt();if(!i.currentTask)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示子代理链路"});if(i.agents.length===0)return c.jsxs("div",{className:"muted",children:["(暂无子代理)",i.agentsNote?c.jsx("span",{className:"err",children:i.agentsNote}):null]});const r={main:"主任务",delegate:"委派子任务",model:"模型子代理"},h={running:"进行中",completed:"已完成",failed:"失败",interrupted:"已中断",unknown:"未知"};return c.jsx(cu,{children:i.agents.map((o,T)=>c.jsx("li",{children:c.jsxs("span",{className:"row-main",children:[c.jsxs("b",{children:[r[o.kind||""]||o.kind||"",":"]}),o.name||""," ",c.jsx("span",{className:"st-"+(o.status||""),children:h[o.status||""]||o.status||"?"})," | 过程调用 "+(o.calls?o.calls.length:0)+" 条",o.updated_ts?" | 最近活动 "+Ya(o.updated_ts/1e3):"",o.prompt?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsxs("span",{className:"muted",children:["任务:",o.prompt]})]}):null,o.calls&&o.calls.length?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),o.calls.slice(-50).map((C,N)=>c.jsx("div",{children:vf(C)},N))]}):c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsx("span",{className:"muted",children:"(暂无过程调用)"})]})]})},T))})}function Tg(){const i=Lt();return c.jsxs(c.Fragment,{children:[c.jsxs(Rt,{children:[c.jsx("button",{onClick:i.refreshApprovals,children:"刷新审批队列"}),c.jsx("span",{className:"muted",children:i.approvalMsg})]}),c.jsx("div",{className:"muted",children:"待审批项需人工点记录允许或记录拒绝;超时默认拒绝。当前版本只留回执,不拦截实时行为(实时请求的事前拦截待 host 接入后生效,在此之前实时行为不变)。"}),c.jsx(jl,{empty:"(暂无审批项)",items:i.approvals.map(r=>c.jsxs("span",{className:"row-main",children:["[",r.kind,"|",r.status,"] ",r.summary,["approved","denied","expired"].includes(r.status||"")?"|回执:"+(r.decision||r.status)+" by "+String(r.decided_by||"").slice(0,8)+" "+Ya(r.decided_at):"",r.status==="pending"?c.jsxs(c.Fragment,{children:[" ",c.jsx("button",{onClick:()=>i.decideApproval(r.id,"approved"),children:"记录允许"})," ",c.jsx("button",{onClick:()=>i.decideApproval(r.id,"denied"),children:"记录拒绝"})]}):null]},r.id))})]})}function _g(){const i=Lt(),[r,h]=m.useState(""),[o,T]=m.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("input",{placeholder:"目标任务 id(与本任务同项目;对方运行中排队执行、已终态则续跑复活,绝不打断当前轮)",style:{width:"100%"},value:r,onChange:C=>h(C.target.value)}),c.jsx("textarea",{placeholder:"消息正文(以本任务名义发出;对方 agent 会看到发送方任务 id,可照此回消息)",value:o,onChange:C=>T(C.target.value)}),c.jsxs(Rt,{children:[c.jsx("button",{onClick:async()=>{await i.postPeer("queued",r,o),T("")},children:"排队发给对方"}),c.jsx("button",{className:"primary",onClick:async()=>{await i.postPeer("direct",r,o),T("")},children:"直接发送"}),c.jsx("span",{className:"muted",children:i.peerMsg})]}),c.jsx(jl,{empty:"(本任务尚未发出任务间消息)",items:i.peerMessages.map((C,N)=>c.jsxs("span",{className:"row-main",children:["[发给 ",String(C.task_id).slice(0,8),"|",C.mode,"|",C.status,"] ",String(C.body||"").slice(0,120)]},N))})]})}function Eg(){const i=Lt(),[r,h]=m.useState(""),o=i.currentTask?["completed","failed","interrupted"].includes(i.currentTask.status):!1;return c.jsxs(c.Fragment,{children:[c.jsx("div",{className:"muted",children:i.threadText||"点任务行查看详情后,这里显示线程元数据"}),c.jsxs(Rt,{children:[c.jsx("input",{type:"text",placeholder:"新线程名",value:r,onChange:T=>h(T.target.value)}),c.jsx("button",{disabled:!o,onClick:()=>i.threadAction("rename",r),children:"重命名"}),c.jsx("button",{disabled:!o,onClick:()=>i.threadAction("archive",""),children:"归档"}),c.jsx("button",{disabled:!o,onClick:()=>i.threadAction("unarchive",""),children:"取消归档"}),c.jsx("button",{disabled:!o,onClick:()=>i.threadAction("compact",""),children:"压缩"}),c.jsx("span",{className:"muted",children:i.threadGateNote}),c.jsx("span",{className:"muted",children:i.threadMsg})]}),c.jsx(jl,{empty:"暂无其他线程",items:i.threadList.map((T,C)=>c.jsxs("span",{className:"row-main",children:[T.name||T.thread_name||T.id||""," [",T.id||"","]",T.archived?" [已归档]":"",T.cwd?" "+T.cwd:""]},C))}),c.jsx("div",{className:"muted",children:"限制:Docker/异机任务线程操作一律 409;merged/merge-failed 不可改;turn/steer 与 review/start 不在本批。"})]})}const Ng=[["overview","概览"],["collab","协作"],["review","评审与审批"],["threads","线程"]];function Cg(){const i=Lt(),r=i.currentTask,[h,o]=m.useState("overview");if(!r)return c.jsx($t,{id:"nav-detail",title:"任务详情",children:c.jsx("div",{className:"muted",children:"点任务行查看详情"})});const[T,C]=Wh(r),[N,b]=$h(r),H=Fh(r),O=i.costSummary,Y=r.plan?"计划模式(只读)":r.goal_mode?r.no_time_limit?"goal 模式无敌版(不限时)":"goal 模式(不限轮次,模型宣告完成)":r.verify_command?"goal 模式(旧语义,验收命令)":null;return c.jsxs("div",{className:"detail-stack",id:"nav-detail",children:[c.jsx("nav",{className:"detail-tabs","aria-label":"任务详情页签",children:Ng.map(([j,q])=>c.jsx("button",{type:"button",className:h===j?"active":"",onClick:()=>o(j),children:q},j))}),h==="overview"?c.jsxs(c.Fragment,{children:[c.jsxs($t,{title:"任务详情",children:[c.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8,flexWrap:"wrap"},children:[c.jsx("span",{className:"issue-first",title:"任务首字:"+H,"aria-hidden":"true",children:H}),c.jsxs("span",{children:["状态 ",c.jsx("b",{className:"st-"+r.status,children:r.status}),"(",Fy[r.status]||"未知状态",")"]}),c.jsxs("span",{className:"muted",children:["更新 ",Ya(r.updated_at)]})]}),c.jsxs("div",{className:"detail-cards",children:[c.jsxs("div",{className:"detail-card",title:C,children:[c.jsx("div",{className:"detail-card-label",children:"耗时"}),c.jsx("div",{className:"detail-card-value",children:T})]}),c.jsxs("div",{className:"detail-card",title:b,children:[c.jsx("div",{className:"detail-card-label",children:"token"}),c.jsx("div",{className:"detail-card-value",children:N})]}),c.jsxs("div",{className:"detail-card",title:"LLM 请求数 = GET /tasks/{id}/cost 的 llm_requests,即 cost_records 的 completions 之和:host.py 每收到一次 thread/tokenUsage/updated(一次上游补全)计 1,worker 每轮终局记账时累加;估算行(无真实用量)计 0;部署前已记账的老任务没有 completions 列,显示 0 而非回填数",children:[c.jsx("div",{className:"detail-card-label",children:"LLM 请求数"}),c.jsx("div",{className:"detail-card-value",children:O===void 0?"加载中…":O===null?"—":(O.llm_requests??0).toLocaleString()}),O===void 0?c.jsx("div",{className:"muted",children:"成本加载中…"}):O===null?c.jsx("div",{className:"muted",children:"成本明细加载失败,LLM 请求数不可用"}):c.jsxs("div",{className:"muted",children:["上游补全次数之和| 轮数 ",O.turns??0," | fallback ",O.fallback_count??0]})]}),c.jsxs("div",{className:"detail-card",title:"成本明细:GET /tasks/{id}/cost 聚合;美元只计有价行,无价 token 单列;缓存是输入 token 的子集,不另计",children:[c.jsx("div",{className:"detail-card-label",children:"成本"}),c.jsx("div",{className:"detail-card-value",children:O===void 0?"加载中…":O===null?"—":og(O.usd_micros)}),O===void 0?c.jsx("div",{className:"muted",children:"成本加载中…"}):O===null?c.jsx("div",{className:"muted",children:"成本明细加载失败(“查成本”按钮仍可手动拉取原始 JSON)"}):c.jsxs("div",{className:"muted",children:["token ",(O.total_tokens??0).toLocaleString(),"(实际 ",(O.actual_tokens??0).toLocaleString()," / 估算 ",(O.estimated_tokens??0).toLocaleString(),",缓存 ",(O.cached_input_tokens??0).toLocaleString(),",无价 ",(O.unpriced_actual_tokens??0).toLocaleString(),")| fallback ",O.fallback_count??0]})]})]})]}),c.jsx($t,{title:"基本信息",children:c.jsxs("dl",{className:"detail-kv",children:[c.jsxs("div",{children:[c.jsx("dt",{children:"任务"}),c.jsx("dd",{children:r.id})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"turn"}),c.jsx("dd",{children:Ne(r.turn_id)})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"root turn"}),c.jsx("dd",{children:Ne(r.root_turn_id)})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"executor"}),c.jsx("dd",{children:r.executor||""})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"主机"}),c.jsx("dd",{children:r.worktree_host||""})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"模型"}),c.jsx("dd",{children:r.model||"默认"})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"强度"}),c.jsx("dd",{children:r.effort||"默认"})]}),r.child_model||r.child_effort?c.jsxs("div",{children:[c.jsx("dt",{children:"子任务默认"}),c.jsx("dd",{children:(r.child_model||"跟随本任务")+" / "+(r.child_effort||"跟随本任务")})]}):null,c.jsxs("div",{children:[c.jsx("dt",{children:"权限"}),c.jsx("dd",{children:r.approval||"默认"})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"项目"}),c.jsx("dd",{children:Ne(r.project_id)})]}),Y?c.jsxs("div",{children:[c.jsx("dt",{children:"模式"}),c.jsx("dd",{children:Y})]}):null,r.forked_from?c.jsxs("div",{children:[c.jsx("dt",{children:"分支自"}),c.jsx("dd",{children:Ne(r.forked_from)})]}):null,r.coordinator_parts?c.jsxs("div",{children:[c.jsx("dt",{children:"协调员"}),c.jsxs("dd",{children:[r.coordinator_parts.length," 个子任务(",r.coordinator_parts.map(j=>j.name).join("、"),")"]})]}):null,r.parent_task_id?c.jsxs("div",{children:[c.jsx("dt",{children:"协调子任务"}),c.jsxs("dd",{children:["父任务 ",Ne(r.parent_task_id),r.coordinator_part?"("+r.coordinator_part+")":""]})]}):null,r.attachments&&r.attachments.length?c.jsxs("div",{children:[c.jsx("dt",{children:"附件"}),c.jsx("dd",{children:r.attachments.join("、")})]}):null,r.pr_url?c.jsxs("div",{children:[c.jsx("dt",{children:"PR"}),c.jsx("dd",{children:c.jsxs("a",{href:r.pr_url,target:"_blank",rel:"noopener noreferrer",children:["PR #",r.pr_number]})})]}):null]})}),c.jsx($t,{title:"prompt",children:c.jsx("div",{style:{whiteSpace:"pre-wrap",overflowWrap:"anywhere"},children:r.prompt||"(无 prompt)"})}),c.jsxs($t,{title:"操作与结果",children:[c.jsx(Sg,{}),c.jsx("pre",{children:i.detailBody||"(暂无 result/error 输出)"}),i.costBox?c.jsx("pre",{children:i.costBox}):null]})]}):h==="collab"?c.jsxs(c.Fragment,{children:[c.jsx($t,{title:"协调子任务",children:c.jsx(jg,{})}),c.jsx($t,{title:"子代理链路",children:c.jsx(xg,{})}),c.jsx($t,{title:"给其他任务发消息",children:c.jsx(_g,{})})]}):h==="review"?c.jsxs(c.Fragment,{children:[c.jsx($t,{title:"评审队列",children:c.jsx(bg,{})}),c.jsx($t,{title:"运行时审批队列",children:c.jsx(Tg,{})})]}):c.jsx(c.Fragment,{children:c.jsx($t,{title:"线程",children:c.jsx(Eg,{})})})]})}function Ag(){const i=Lt(),[r,h]=m.useState(""),[o,T]=m.useState(""),[C,N]=m.useState([]),[b,H]=m.useState(null),[O,Y]=m.useState(!1),[j,q]=m.useState(""),[K,J]=m.useState(""),lt=m.useCallback(async()=>{if(!i.keySaved){N([]);return}try{const W=await et(P.userKeys);N(W.keys||[]),J("")}catch(W){J(W instanceof Error?W.message:"加载 key 列表失败")}},[i.keySaved]);m.useEffect(()=>{lt()},[lt]);const Q=async()=>{q(""),J(""),Y(!1);try{const W=await et(P.userKeys,"POST",{label:o.trim()||"api-key"});H(W),T(""),q("新 key 已生成,只显示一次:复制后请妥善保存。"),await lt()}catch(W){J(W instanceof Error?W.message:"生成 key 失败")}},gt=async()=>{if(b)try{await navigator.clipboard.writeText(b.api_key),Y(!0)}catch{J("复制失败:请手动选中上方 key 文本复制")}},jt=()=>{b&&(i.saveKeyInput(b.api_key),H(null),q("已存为当前浏览器 key(原有 key 未被撤销,需要时可单独撤销)。"))},Ot=async W=>{const Ht=(()=>{try{return bl().slice(-4)}catch{return""}})(),Zt=W.id==="legacy"?"(主 key:撤销后当前浏览器保存的 key 也可能随之失效,需重新登录拿新 key)":W.hint&&W.hint===Ht?"(这就是本浏览器当前在用的 key,撤销后本页会立刻未连接)":"";if(window.confirm(`撤销 key「${W.label}」(后 4 位 ${W.hint||"无"})吗?${Zt}撤销不可恢复。`)){q(""),J("");try{await et(P.userKey(W.id),"DELETE"),q(`已撤销 key「${W.label}」。`),await lt()}catch(it){J(it instanceof Error?it.message:"撤销失败")}}};return c.jsxs($t,{id:"nav-key",title:"API Key",children:[c.jsxs(Rt,{children:[c.jsx("input",{type:"password",placeholder:"粘贴你的 X-API-Key(与调 API 用的同一个)",autoComplete:"off",value:r,onChange:W=>h(W.target.value)}),c.jsx("button",{className:"primary",onClick:()=>{i.saveKeyInput(r),h("")},children:"保存到本浏览器"}),c.jsx("button",{onClick:i.clearKeyInput,children:"清除"}),c.jsx("span",{className:"muted",children:i.keyState})]}),c.jsx("div",{children:c.jsx("span",{className:"muted",children:i.quotaText})}),!i.keySaved&&c.jsx("div",{children:c.jsx("span",{className:"muted",children:"先粘贴并保存已有 key,再生成或管理 key(生成接口本身也要用已有 key 鉴权)。"})}),i.keySaved&&c.jsxs(c.Fragment,{children:[c.jsxs(Rt,{children:[c.jsx("input",{placeholder:"新 key 备注(可选,默认 api-key,如 harness2-temp)",autoComplete:"off",value:o,onChange:W=>T(W.target.value)}),c.jsx("button",{className:"primary",onClick:()=>void Q(),children:"生成新 key"}),c.jsx("button",{onClick:()=>void lt(),children:"刷新列表"})]}),b&&c.jsxs("div",{children:[c.jsx("div",{children:c.jsxs("span",{className:"muted",children:["新 key(",b.label,",后 4 位 ",b.hint,"):只显示一次,关闭后无法再查看。"]})}),c.jsxs(Rt,{children:[c.jsx("input",{type:"text",readOnly:!0,value:b.api_key,onFocus:W=>W.target.select()}),c.jsx("button",{className:"primary",onClick:()=>void gt(),children:O?"已复制":"复制"}),c.jsx("button",{onClick:jt,children:"存为当前浏览器 key"}),c.jsx("button",{onClick:()=>H(null),children:"关闭(不再显示)"})]})]}),j&&c.jsx("div",{children:c.jsx("span",{className:"muted",children:j})}),K&&c.jsx("div",{children:c.jsx("span",{children:K})}),c.jsxs("div",{className:"api-key-list",children:[C.length===0&&c.jsx("span",{className:"muted",children:"暂无 key(或列表加载失败,见上方报错)。"}),C.map(W=>c.jsxs("div",{children:[c.jsx("span",{children:W.label})," ",c.jsxs("span",{className:"muted",children:["后 4 位 ",W.hint||"无"," · 创建 ",Ya(W.created_at)," · 最近使用 ",W.last_used_at?Ya(W.last_used_at):"未使用过"]})," ",W.revoked_at?c.jsxs("span",{className:"muted",children:["已撤销(",Ya(W.revoked_at),")"]}):c.jsx("button",{onClick:()=>void Ot(W),children:"撤销"})]},W.id))]})]})]})}function Mg(){const i=Lt();return c.jsxs($t,{id:"adminCard",title:c.jsxs(c.Fragment,{children:["管理区 ",c.jsx("span",{className:"muted",children:"(仅全局管理员)"})]}),children:[c.jsx("div",{className:"muted",children:i.adminGate}),i.adminBody?c.jsxs(c.Fragment,{children:[c.jsxs(Rt,{children:[c.jsx("input",{type:"text",placeholder:"要查配额的用户 id",value:i.adminUserId,onChange:r=>i.setAdminUserId(r.target.value)}),c.jsx("button",{onClick:i.adminQuota,children:"查该用户配额"})]}),c.jsxs(Rt,{children:[c.jsx("button",{onClick:i.adminSweep,children:"手动触发 sweep(清理陈旧 running 任务)"}),c.jsx("button",{onClick:i.adminBackup,children:"手动触发备份"})]}),c.jsx("div",{className:"muted",children:i.adminResult})]}):null]})}function Og(){const i=Lt(),[r,h]=m.useState(""),[o,T]=m.useState(""),[C,N]=m.useState("");return c.jsxs($t,{id:"nav-projects",title:"项目",children:[c.jsxs(Rt,{children:[c.jsxs("select",{"aria-label":"项目",value:i.projectId,onChange:b=>i.selectProject(b.target.value),children:[i.projects.length===0?c.jsx("option",{value:"",children:"(先加载项目)"}):null,i.projects.map(b=>c.jsxs("option",{value:b.id,children:[b.name," (",Ne(b.id),")"]},b.id))]}),c.jsx("button",{onClick:i.refreshProjects,children:"刷新"})]}),c.jsxs(Rt,{children:[c.jsx("input",{type:"text",placeholder:"新项目名",value:r,onChange:b=>h(b.target.value)}),c.jsxs("select",{"aria-label":"从 GitHub 仓库选",value:o,onChange:b=>{T(b.target.value),b.target.value&&N(b.target.value)},children:[c.jsx("option",{value:"",children:"(从 GitHub App 安装仓库里选,或手动输入)"}),i.githubRepos.map(b=>c.jsx("option",{value:b.clone_url,children:b.repo_full_name},b.clone_url))]}),c.jsx("input",{type:"text",placeholder:"repo_url(可选)",value:C,onChange:b=>N(b.target.value)}),c.jsx("button",{onClick:async()=>{await i.createProject(r,C),h(""),N(""),T("")},children:"新建项目"})]}),c.jsx("span",{className:"muted",children:i.projectMsg})]})}function zg(){const i=Lt(),[r,h]=m.useState(""),[o,T]=m.useState("contributor"),C=i.projects.find(N=>N.id===i.projectId);return c.jsxs($t,{title:"项目成员",children:[C?c.jsxs("div",{className:"muted",children:[C.name," | ",C.id," | 仓库 ",C.repo_url||"未绑定"," | 我的角色 ",C.my_role||"未知"]}):c.jsx("div",{className:"muted",children:"先选项目"}),c.jsxs(Rt,{children:[c.jsx("input",{type:"text",placeholder:"成员 user_id 或用户名",value:r,onChange:N=>h(N.target.value)}),c.jsxs("select",{"aria-label":"邀请角色",value:o,onChange:N=>T(N.target.value),children:[c.jsx("option",{value:"viewer",children:"viewer(只读)"}),c.jsx("option",{value:"contributor",children:"contributor(可建任务)"}),c.jsx("option",{value:"maintainer",children:"maintainer(可批合并)"}),c.jsx("option",{value:"owner",children:"owner(可管项目)"})]}),c.jsx("button",{onClick:async()=>{await i.inviteMember(r,o),h("")},children:"邀请成员"}),c.jsx("button",{onClick:i.refreshMembers,children:"刷新成员"})]}),i.memberError?c.jsx("div",{className:"muted",children:i.memberError}):i.members.length===0?c.jsx("div",{className:"muted",children:"先选项目"}):c.jsx(cu,{children:i.members.map(N=>{const b=(N.username||"")+(N.username?"("+String(N.user_id).slice(0,8)+")":String(N.user_id).slice(0,8));return c.jsxs("li",{children:[c.jsx("span",{className:"row-main",children:b}),c.jsx("select",{"aria-label":"成员角色",value:N.role||"viewer",onChange:H=>void i.changeMemberRole(N.user_id,H.target.value),children:Wy.map(H=>c.jsx("option",{value:H,children:H},H))})]},N.user_id)})}),c.jsx("span",{className:"muted",children:i.memberMsg})]})}function Rg(){const i=Lt(),[r,h]=m.useState(""),[o,T]=m.useState(""),[C,N]=m.useState("");return c.jsxs($t,{id:"nav-env",title:c.jsxs(c.Fragment,{children:["环境 ",c.jsx("span",{className:"muted",children:"(选、建、管:当前项目的基准检出)"})]}),children:[c.jsxs(Rt,{children:[c.jsx("button",{onClick:i.refreshEnvs,children:"刷新"}),c.jsx("span",{className:"muted",children:i.envCount})]}),i.envs.length===0?c.jsx("div",{className:"muted",children:"先选项目"}):c.jsx(cu,{children:i.envs.map(b=>c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[b.name,"(",Ne(b.id),") / 分支 ",b.branch||"默认分支"," / 执行器 ",b.executor||"不限"," / 状态 ",b.status,b.status==="failed"&&b.error_text?" / 失败原因:"+b.error_text:""]}),b.status==="archived"?null:c.jsx("button",{onClick:()=>void i.archiveEnv(b.id),children:"归档"})]},b.id))}),c.jsxs(Rt,{children:[c.jsx("input",{type:"text",placeholder:"新环境名",value:r,onChange:b=>h(b.target.value)}),c.jsx("input",{type:"text",placeholder:"分支(可选,空=默认分支)",value:o,onChange:b=>T(b.target.value)}),c.jsxs("select",{"aria-label":"新环境执行器",value:C,onChange:b=>N(b.target.value),children:[c.jsx("option",{value:"",children:"执行器:不限"}),c.jsx("option",{value:"local",children:"local"}),c.jsx("option",{value:"docker",children:"docker"})]}),c.jsx("button",{onClick:async()=>{await i.createEnv(r,o,C),h(""),T("")},children:"新建环境"})]}),c.jsx("span",{className:"muted",children:i.envMsg})]})}function Dg(){const i=Lt(),[r,h]=m.useState("");return c.jsxs($t,{title:"仓库与环境上下文",children:[c.jsx("div",{className:"muted",children:i.envRepoLine||"先选项目"}),c.jsx("div",{className:"muted",children:i.envCount}),i.envRepoBar?c.jsxs(Rt,{children:[c.jsxs("select",{"aria-label":"补仓库",value:r,onChange:o=>h(o.target.value),children:[c.jsx("option",{value:"",children:"(从 GitHub App 安装仓库里选)"}),i.githubRepos.map(o=>c.jsx("option",{value:o.clone_url,children:o.repo_full_name},o.clone_url))]}),c.jsx("button",{onClick:()=>void i.saveEnvRepo(r),children:"保存仓库"})]}):null,c.jsx("span",{className:"muted",children:i.envRepoMsg})]})}function Ug(){const i=Lt();return c.jsxs($t,{id:"nav-workers",title:"Workers",children:[c.jsx(Rt,{children:c.jsx("button",{onClick:i.refreshWorkers,children:"刷新"})}),i.workersError?c.jsx("div",{className:"muted",children:i.workersError}):i.workers===null?c.jsx("div",{className:"muted",children:i.workersSummary||"未加载"}):i.workers.length===0?c.jsx("div",{className:"muted",children:"无 worker 注册"}):c.jsx(cu,{children:i.workers.map((r,h)=>{const o=r.running===void 0||r.running===null?"?":r.running,T=r.capacity===void 0||r.capacity===null?"?":r.capacity,C=Jh(r),N=r.alive!==void 0&&r.alive!==null?!!r.alive:C.age!==null&&C.age<=120,b=N?" / 在线":" / 离线(占用为上次上报值)";return c.jsx("li",{className:N?"is-online":"is-offline",children:c.jsxs("span",{className:"row-main",children:[r.worker_id||r.id||"?"," / ",r.executor||""," / 占用 ",o,"/",T,b," / 最后心跳 ",C.text]})},h)})})]})}function Hg(){const i=Lt();return c.jsxs($t,{title:"健康与配额",children:[c.jsxs(Rt,{children:[c.jsx("span",{className:"muted",children:i.healthText}),c.jsx("button",{onClick:i.refreshHealth,children:"刷新"})]}),c.jsx("div",{children:c.jsx("span",{className:"muted",children:i.quotaText})}),c.jsx("div",{className:"muted",children:i.workersSummary})]})}function wg(){const i=Lt();return c.jsxs($t,{id:"nav-skills",title:"技能与 MCP",children:[c.jsx("div",{className:"muted",children:"反映宿主租户 home,容器内 home 可能不同;重载只在 schema 枚举里,当前 pin 的二进制拒绝时原样 502;OAuth 只发起登录,不代表登录完成。"}),c.jsxs(Rt,{children:[c.jsx("button",{onClick:i.refreshSkills,children:"刷新"}),c.jsx("span",{className:"muted",children:i.skillMsg})]}),c.jsx("h3",{children:"技能"}),i.skillError?c.jsx("div",{className:"muted",children:i.skillError}):c.jsx(jl,{empty:"暂无技能",items:i.skills.map((r,h)=>c.jsxs("span",{className:"row-main",children:[r.name||r.id||"",r.description?" - "+r.description:"",r.detail?" - "+r.detail:""]},h))}),c.jsx("h3",{children:"应用"}),c.jsx(jl,{empty:"暂无应用",items:i.apps.map((r,h)=>c.jsxs("span",{className:"row-main",children:[r.name||r.id||"",r.description?" - "+r.description:""]},h))}),c.jsx("h3",{children:"MCP 服务"}),c.jsx(jl,{empty:"暂无 MCP 服务",items:i.mcpServers.map((r,h)=>{const o=r.detail||r.details||r.state||"";return c.jsxs("span",{className:"row-main",children:[r.name||r.id||""," | 状态 ",r.status||"",o?" | "+o:""]},h)})})]})}function kg(){const i=Lt(),[r,h]=m.useState("");return c.jsxs($t,{title:"MCP 操作",children:[c.jsxs(Rt,{children:[c.jsx("button",{onClick:i.reloadMcp,children:"重载配置"}),c.jsx("span",{className:"muted",children:i.mcpMsg})]}),c.jsxs(Rt,{children:[c.jsx("input",{type:"text",placeholder:"MCP 服务名",value:r,onChange:o=>h(o.target.value)}),c.jsx("button",{onClick:()=>void i.mcpOauth(r),children:"发起 OAuth 登录"}),c.jsx("span",{className:"muted",children:i.mcpOauthMsg})]}),i.mcpOauthBox?c.jsx("pre",{children:i.mcpOauthBox}):null]})}function Bg(){const i=Lt(),[r,h]=m.useState(""),[o,T]=m.useState(""),[C,N]=m.useState(""),[b,H]=m.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("input",{placeholder:"自动化名称(同项目内启用中的名称唯一)",style:{width:"100%"},value:r,onChange:O=>h(O.target.value)}),c.jsx("textarea",{placeholder:"定时任务内容(创建只登记定义与到期状态;执行由后续执行器经 claim 抢单后建任务,不另起调度器)",value:o,onChange:O=>T(O.target.value)}),c.jsxs(Rt,{children:[c.jsx("input",{placeholder:"间隔秒数(默认 86400,最小 60)",style:{width:"40%"},value:C,onChange:O=>N(O.target.value)}),c.jsx("input",{placeholder:"仓库(可选)",style:{width:"40%"},value:b,onChange:O=>H(O.target.value)}),c.jsx("button",{className:"primary",onClick:async()=>{await i.createAutomation(r,o,C,b),h(""),T(""),N(""),H("")},children:"创建自动化"}),c.jsx("button",{onClick:i.refreshAutomations,children:"刷新自动化"}),c.jsx("span",{className:"muted",children:i.autoMsg})]}),c.jsx(jl,{empty:"(暂无自动化)",items:i.automations.map(O=>c.jsxs("span",{className:"row-main",children:["[",O.status,O.due?"|到期":"","] ",O.name,"|每 ",O.interval_seconds," 秒|下次 ",Ya(O.next_run_at),O.status==="active"?c.jsxs(c.Fragment,{children:[" ",c.jsx("button",{onClick:()=>i.cancelAutomation(O.id),children:"取消"})]}):null]},O.id))})]})}function qg(){var h,o,T,C;const i=Lt(),r=i.triage;return i.triageError?c.jsx("div",{className:"muted",children:i.triageError}):r?c.jsxs(c.Fragment,{children:[c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["到期自动化(",((h=r.counts)==null?void 0:h.automations_due)||0,")"]}),c.jsx(jl,{empty:"(无)",items:(r.automations_due||[]).map((N,b)=>c.jsxs("span",{className:"row-main",children:[N.name,"|每 ",N.interval_seconds," 秒"]},b))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["失败/中断任务(",((o=r.counts)==null?void 0:o.failed_tasks)||0,")"]}),c.jsx(jl,{empty:"(无)",items:(r.failed_tasks||[]).map((N,b)=>c.jsxs("span",{className:"row-main",children:[Ne(N.id),"|",N.status,"|",String(N.prompt||"").slice(0,80)]},b))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["待处理后续消息(",((T=r.counts)==null?void 0:T.pending_followups)||0,")"]}),c.jsx(jl,{empty:"(无)",items:(r.pending_followups||[]).map((N,b)=>c.jsxs("span",{className:"row-main",children:["任务 ",Ne(N.task_id),"|",String(N.body||"").slice(0,80)]},b))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["最近自动化产出(",((C=r.counts)==null?void 0:C.recent_runs)||0,")"]}),c.jsx(jl,{empty:"(无)",items:(r.recent_runs||[]).map((N,b)=>c.jsxs("span",{className:"row-main",children:[Ne(N.automation_id),"|",N.status,N.task_id?"|任务 "+Ne(N.task_id):"",N.task_status?"|"+N.task_status:""]},b))})]})]}):c.jsx("div",{className:"muted",children:"(先选项目)"})}function Yg(){return c.jsx($t,{id:"nav-automations",title:"自动化",children:c.jsx(Bg,{})})}function Gg(){const i=Lt();return c.jsxs($t,{id:"nav-triage",title:"分诊收件箱",children:[c.jsx(Rt,{children:c.jsx("button",{onClick:i.refreshTriage,children:"刷新分诊"})}),c.jsx(qg,{})]})}const hf=["pending","in_progress","blocked","completed","cancelled"],Rc={pending:"待办",in_progress:"进行中",blocked:"受阻",completed:"已完成",cancelled:"已取消"},Qg=["low","medium","high"],Lh={low:"低",medium:"中",high:"高"};function Xg(i){return i?new Date(i*1e3).toLocaleString():""}function Lg(){const i=Lt(),[r,h]=m.useState([]),[o,T]=m.useState({}),[C,N]=m.useState(0),[b,H]=m.useState(""),[O,Y]=m.useState(""),[j,q]=m.useState(""),[K,J]=m.useState(""),[lt,Q]=m.useState(""),[gt,jt]=m.useState(""),[Ot,W]=m.useState("medium"),[Ht,Zt]=m.useState(""),[it,ht]=m.useState(""),Qt=m.useCallback(async()=>{if(!i.projectId){h([]),T({}),N(0);return}try{let k=P.projectTodos(i.projectId);const rt=new URLSearchParams;j&&rt.set("status",j),K.trim()&&rt.set("q",K.trim());const ct=rt.toString();ct&&(k+="?"+ct);const B=await et(k);h(B.todos||[]),T(B.counts||{}),N(B.total||0),Y("")}catch(k){Y("待办加载失败:"+((k==null?void 0:k.message)||String(k)))}},[i.projectId,j,K]);m.useEffect(()=>{Qt()},[Qt]);const Pt=async()=>{if(!i.projectId){H("先选项目");return}if(!lt.trim()){H("标题必填");return}H("");try{const k={title:lt.trim(),description:gt,priority:Ot};Ht.trim()&&(k.task_id=Ht.trim()),it&&(k.due_at=Math.floor(new Date(it).getTime()/1e3)),await et(P.projectTodos(i.projectId),"POST",k),Q(""),jt(""),Zt(""),ht(""),H("已创建"),await Qt()}catch(k){H("创建失败:"+((k==null?void 0:k.message)||String(k)))}},Bt=async(k,rt)=>{if(i.projectId)try{await et(P.projectTodo(i.projectId,k.id),"PATCH",rt),await Qt()}catch(ct){Y("待办更新失败:"+((ct==null?void 0:ct.message)||String(ct)))}},Kt=async k=>{if(i.projectId&&window.confirm("删除待办“"+k.title+"”?删除后不可恢复(取消请改状态为已取消)。"))try{await et(P.projectTodo(i.projectId,k.id),"DELETE"),await Qt()}catch(rt){Y("待办删除失败:"+((rt==null?void 0:rt.message)||String(rt)))}};return c.jsxs($t,{id:"nav-todos",title:"项目待办",children:[c.jsx("div",{className:"muted",children:i.projectId?"共 "+C+" 项 | "+hf.map(k=>Rc[k]+" "+(o[k]||0)).join(" | "):"先选项目"}),O?c.jsx("div",{className:"err",children:O}):null,c.jsxs(Rt,{children:[c.jsxs("select",{"aria-label":"状态筛选",value:j,onChange:k=>q(k.target.value),children:[c.jsx("option",{value:"",children:"状态:全部"}),hf.map(k=>c.jsx("option",{value:k,children:Rc[k]},k))]}),c.jsx("input",{type:"text",placeholder:"搜索标题/说明",value:K,onChange:k=>J(k.target.value)}),c.jsx("button",{onClick:()=>void Qt(),children:"刷新"})]}),c.jsx("input",{type:"text",placeholder:"待办标题(必填)",style:{width:"100%"},value:lt,onChange:k=>Q(k.target.value)}),c.jsx("textarea",{placeholder:"说明(可选)",value:gt,onChange:k=>jt(k.target.value)}),c.jsxs(Rt,{children:[c.jsx("select",{"aria-label":"优先级",value:Ot,onChange:k=>W(k.target.value),children:Qg.map(k=>c.jsxs("option",{value:k,children:["优先级:",Lh[k]]},k))}),c.jsx("input",{type:"text",placeholder:"关联任务 id(可选)",value:Ht,onChange:k=>Zt(k.target.value)}),c.jsx("input",{type:"datetime-local","aria-label":"截止时间",value:it,onChange:k=>ht(k.target.value)}),c.jsx("button",{className:"primary",onClick:()=>void Pt(),children:"新建待办"}),c.jsx("span",{className:"muted",children:b})]}),c.jsx(jl,{empty:i.projectId?"(暂无待办)":"(先选项目)",items:r.map(k=>c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:k.title})," | ",c.jsx("span",{className:"st-"+k.status,children:Rc[k.status]||k.status})," | 优先级 "+(Lh[k.priority]||k.priority),k.task_id?" | 任务 "+Ne(k.task_id):"",k.due_at?" | 截止 "+Xg(k.due_at):"",k.completed_at?" | 完成于 "+new Date(k.completed_at*1e3).toLocaleString():"",k.description?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsx("span",{className:"muted",children:k.description})]}):null,c.jsx("br",{}),c.jsx("select",{"aria-label":"改状态",value:k.status,onChange:rt=>void Bt(k,{status:rt.target.value}),children:hf.map(rt=>c.jsx("option",{value:rt,children:Rc[rt]},rt))})," ",c.jsx("button",{onClick:()=>void Bt(k,{status:"completed"}),children:"完成"})," ",c.jsx("button",{onClick:()=>void Kt(k),children:"删除"})]},k.id))}),c.jsx("div",{className:"muted",children:"状态与 Muse 待办同款五态;完成时服务端记完成时间,改回其它状态时清掉;已取消是软关闭,删除才是硬删除。"})]})}const pf=[["tasks","任务工作台","任务"],["todos","项目待办","任务"],["projects","项目与成员","管理"],["resources","环境与 Workers","管理"],["automation","技能与自动化","管理"],["admin","系统与 Key","管理"]],Vg=Object.fromEntries(pf.map(([i,r])=>[i,r]));function Vh(){const i=window.location.hash.replace("#view-","");return pf.some(([r])=>r===i)?i:"tasks"}function Zg({view:i,setView:r}){return c.jsxs("aside",{className:"shell-side",children:[c.jsx("div",{className:"shell-brand",children:"Codex 命令台"}),c.jsx("nav",{className:"shell-nav","aria-label":"导航",children:["任务","管理"].map(h=>c.jsxs("div",{className:"shell-nav-group",children:[c.jsx("div",{className:"shell-nav-group-label",children:h}),pf.filter(([,,o])=>o===h).map(([o,T])=>c.jsx("button",{type:"button",className:i===o?"active":"",onClick:()=>r(o),children:T},o))]},h))}),c.jsxs("div",{className:"shell-stats",children:[c.jsx("div",{className:"shell-stats-label",children:"概览"}),c.jsx(rg,{})]})]})}function Kg(){const i=Lt();return c.jsx("span",{className:i.keySaved?"dash-status ok":"dash-status off",children:i.keySaved?"● 已连接":"○ 未连接"})}function Jg(){const i=sg();ug(b=>i.refreshAll(b),1e4),ig(()=>i.refreshSlow(),6e4);const[r,h]=m.useState(()=>Vh()),[o,T]=m.useState(!0),C=b=>{h(b);try{window.history.replaceState(null,"","#view-"+b)}catch{}};m.useEffect(()=>{const b=()=>h(Vh());return window.addEventListener("hashchange",b),()=>window.removeEventListener("hashchange",b)},[]);const N=()=>{C("tasks"),T(!0),setTimeout(()=>{var b;return(b=document.getElementById("sec-dispatch"))==null?void 0:b.scrollIntoView({behavior:"smooth"})},0)};return c.jsx(Kh.Provider,{value:i,children:c.jsxs("div",{className:"shell",children:[c.jsx(Zg,{view:r,setView:C}),c.jsxs("div",{className:"shell-main",children:[c.jsxs("header",{className:"shell-header",children:[c.jsx("h1",{children:Vg[r]}),c.jsx(Kg,{}),c.jsx("span",{className:"muted shell-hide-sm",children:i.healthText}),c.jsx("span",{className:"muted shell-hide-sm",children:i.quotaText}),c.jsxs("span",{className:"muted shell-stamp",children:["自动刷新 · 每 10 秒 · 已刷新 ",i.refreshStamp]})]}),r==="tasks"?c.jsxs("div",{className:"workspace",children:[c.jsxs("section",{className:"workspace-rail","aria-label":"任务列表",children:[c.jsx("h2",{children:"任务列表"}),c.jsx(dg,{onNew:N})]}),c.jsxs("section",{className:"workspace-center",children:[c.jsx("div",{className:"composer-toggle",children:c.jsx("button",{type:"button",onClick:()=>T(!o),children:o?"收起新建任务":"新建任务"})}),o?c.jsx("section",{id:"sec-dispatch",children:c.jsx(fg,{})}):null,c.jsxs("section",{className:"card workspace-conv",children:[c.jsx("h2",{children:"对话"}),c.jsx(pg,{})]})]}),c.jsx("section",{className:"workspace-inspector","aria-label":"任务详情",children:c.jsx(Cg,{})})]}):c.jsx("main",{className:"page",children:r==="todos"?c.jsx(c.Fragment,{children:c.jsx(Lg,{})}):r==="projects"?c.jsxs(c.Fragment,{children:[c.jsx(Og,{}),c.jsx(zg,{})]}):r==="resources"?c.jsxs(c.Fragment,{children:[c.jsx(Rg,{}),c.jsx(Dg,{}),c.jsx(Ug,{}),c.jsx(Hg,{})]}):r==="automation"?c.jsxs(c.Fragment,{children:[c.jsx(wg,{}),c.jsx(kg,{}),c.jsx(Yg,{}),c.jsx(Gg,{})]}):c.jsxs(c.Fragment,{children:[c.jsx(Mg,{}),c.jsx(Ag,{})]})})]})]})})}Ky.createRoot(document.getElementById("root")).render(c.jsx(qy.StrictMode,{children:c.jsx(Jg,{})})); diff --git a/cloud/cost.py b/cloud/cost.py index 1510a17..6b1fb24 100644 --- a/cloud/cost.py +++ b/cloud/cost.py @@ -223,6 +223,7 @@ class CostStore: usd_micros INTEGER, source TEXT, fallback_count INTEGER DEFAULT 0, + completions INTEGER DEFAULT 0, day TEXT, created_at REAL )""" @@ -246,6 +247,14 @@ class CostStore: conn.commit() except Exception: pass + if "completions" not in cols: + try: + conn.execute( + "ALTER TABLE cost_records" + " ADD COLUMN completions INTEGER DEFAULT 0") + conn.commit() + except Exception: + pass def __init__(self, db_path): self.db_path = db_path @@ -280,7 +289,7 @@ class CostStore: def record(self, task_id, project_id, model, input_tokens=0, output_tokens=0, cached_input_tokens=0, usd_micros=None, - source="actual", fallback_count=0): + source="actual", fallback_count=0, completions=0): """Insert one cost record; returns its row id. ``total_tokens`` is computed as input + output, and ``day`` is @@ -305,11 +314,12 @@ class CostStore: """INSERT INTO cost_records (task_id, project_id, model, input_tokens, output_tokens, cached_input_tokens, total_tokens, usd_micros, source, - fallback_count, day, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + fallback_count, completions, day, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (task_id, project_id, model, input_tokens, output_tokens, cached_input_tokens, total_tokens, usd_micros, source, - int(fallback_count or 0), day, created_at), + int(fallback_count or 0), int(completions or 0), day, + created_at), ) conn.commit() return cursor.lastrowid @@ -350,7 +360,8 @@ class CostStore: COALESCE(SUM(cached_input_tokens), 0), COALESCE(SUM(CASE WHEN source = 'actual' AND usd_micros IS NULL - THEN total_tokens ELSE 0 END), 0) + THEN total_tokens ELSE 0 END), 0), + COALESCE(SUM(completions), 0) FROM cost_records WHERE %s""" % where, params, ).fetchone() @@ -358,7 +369,7 @@ class CostStore: if conn is not self._conn: conn.close() (total_tokens, usd_micros, turns, fallback_count, actual, - estimated, cached, unpriced) = row + estimated, cached, unpriced, completions) = row return { "total_tokens": int(total_tokens), "usd_micros": int(usd_micros), @@ -370,6 +381,10 @@ class CostStore: # usd 求和只覆盖有价的行;无价 actual 行的 token 在此, # 不会被静默算成 0 美元。 "unpriced_actual_tokens": int(unpriced), + # LLM 请求数:host.py 每回合汇总的 completions 之和 + # (每个 thread/tokenUsage/updated 算一次上游补全); + # turns 只是记账行数,两者不是一回事。 + "llm_requests": int(completions), } def report_by_task(self, task_id) -> dict: diff --git a/cloud/db.py b/cloud/db.py index db6b565..31cc418 100644 --- a/cloud/db.py +++ b/cloud/db.py @@ -961,6 +961,7 @@ POSTGRES_SCHEMA = ( "usd_micros BIGINT, " "source TEXT, " "fallback_count INTEGER DEFAULT 0, " + "completions BIGINT DEFAULT 0, " "day TEXT, " "created_at DOUBLE PRECISION)", "CREATE TABLE IF NOT EXISTS oauth_states(" diff --git a/cloud/playwright_mcp.py b/cloud/playwright_mcp.py index cd31761..ae31a44 100644 --- a/cloud/playwright_mcp.py +++ b/cloud/playwright_mcp.py @@ -372,8 +372,56 @@ def _drain_remote(browser): file=sys.stderr) +class _LineReader: + """Non-blocking stdin line pump (starvation-free). + + The previous loop used select() + sys.stdin.readline(). That + starves on pipelined input: one readline() can buffer several + lines in userspace, after which select() sees an empty fd + forever and the buffered lines are never read. A client that + writes notifications/initialized and its next request + back-to-back (every MCP client does this after initialize) + hangs the server exactly this way. Reading the raw fd into + our own buffer delivers every line in arrival order. + """ + + def __init__(self, fd): + self._fd = fd + os.set_blocking(fd, False) + self._buf = b"" + self.eof = False + + def poll(self): + """Return (lines, drained). + + lines are decoded strings without the newline. A trailing + partial line is returned once at EOF. drained is True only + when EOF was reached and nothing is left buffered. + """ + if not self.eof: + try: + chunk = os.read(self._fd, 65536) + except BlockingIOError: + chunk = None + except OSError: + self.eof = True + chunk = None + if chunk == b"": + self.eof = True + elif chunk: + self._buf += chunk + lines = [] + while b"\n" in self._buf: + raw, self._buf = self._buf.split(b"\n", 1) + lines.append(raw.decode("utf-8", "replace")) + if self.eof and self._buf: + lines.append(self._buf.decode("utf-8", "replace")) + self._buf = b"" + return lines, (self.eof and not self._buf) + + def main(): - import select + import time browser = _get_browser() # Frame sink must be set before any navigate so pump_screencast # has frames for the bridge to publish. No bridge (missing @@ -381,42 +429,44 @@ def main(): # of accumulating unboundedly in a queue nobody drains. if _get_bridge(browser) is not None: browser.set_frame_sink(_frame_queue()) - buffer = "" + reader = _LineReader(sys.stdin.fileno()) while True: _drain_remote(browser) - try: - readable, _, _ = select.select([sys.stdin], [], [], 0.05) - except Exception: # noqa: BLE001 - stdin gone + lines, drained = reader.poll() + for line in lines: + line = line.strip() + if not line: + continue + _handle_line(line, browser) + if drained: break - if not readable: - continue - chunk = sys.stdin.readline() - if not chunk: - break - line = chunk.strip() - if not line: - continue - try: - request = json.loads(line) - except json.JSONDecodeError as exc: - sys.stdout.write(json.dumps( - {"jsonrpc": "2.0", "id": None, - "error": _err(-32700, "parse error: %s" % exc)}) + "\n") - sys.stdout.flush() - continue - try: - response = handle_request(request, browser) - except Exception as exc: # noqa: BLE001 - server must not die - print("playwright_mcp: handler error: %s" % exc, - file=sys.stderr) - if "id" in request: - response = {"jsonrpc": "2.0", "id": request["id"], - "error": _err(-32603, "internal error")} - else: - response = None - if response is not None: - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() + if not lines: + time.sleep(0.05) + + +def _handle_line(line, browser): + """Handle one JSON-RPC line; responses go to stdout.""" + try: + request = json.loads(line) + except json.JSONDecodeError as exc: + sys.stdout.write(json.dumps( + {"jsonrpc": "2.0", "id": None, + "error": _err(-32700, "parse error: %s" % exc)}) + "\n") + sys.stdout.flush() + return + try: + response = handle_request(request, browser) + except Exception as exc: # noqa: BLE001 - server must not die + print("playwright_mcp: handler error: %s" % exc, + file=sys.stderr) + if "id" in request: + response = {"jsonrpc": "2.0", "id": request["id"], + "error": _err(-32603, "internal error")} + else: + response = None + if response is not None: + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() def _frame_queue(): diff --git a/cloud/tests/test_models_cost.py b/cloud/tests/test_models_cost.py index 0338156..6eda9d4 100644 --- a/cloud/tests/test_models_cost.py +++ b/cloud/tests/test_models_cost.py @@ -140,7 +140,8 @@ class TestCostStore(unittest.TestCase): def test_record_computes_total_and_day(self): row_id = self.store.record( "t1", "p1", "gpt-6.1-sol", input_tokens=100, output_tokens=50, - usd_micros=2000, source="actual", fallback_count=1) + usd_micros=2000, source="actual", fallback_count=1, + completions=7) self.assertIsInstance(row_id, int) report = self.store.report_by_task("t1") self.assertEqual(report["total_tokens"], 150) @@ -149,6 +150,16 @@ class TestCostStore(unittest.TestCase): self.assertEqual(report["fallback_count"], 1) self.assertEqual(report["actual_tokens"], 150) self.assertEqual(report["estimated_tokens"], 0) + self.assertEqual(report["llm_requests"], 7) + + def test_llm_requests_sums_completions_not_turns(self): + self.store.record("t1", "p1", "m", input_tokens=10, + output_tokens=0, source="actual", completions=3) + self.store.record("t1", "p1", "m", input_tokens=20, + output_tokens=0, source="actual", completions=5) + report = self.store.report_by_task("t1") + self.assertEqual(report["turns"], 2) + self.assertEqual(report["llm_requests"], 8) def test_invalid_source_rejected(self): with self.assertRaises(ValueError): @@ -196,6 +207,7 @@ class TestCostStore(unittest.TestCase): "estimated_tokens": 0, "cached_input_tokens": 0, "unpriced_actual_tokens": 0, + "llm_requests": 0, }) diff --git a/cloud/tests/test_playwright.py b/cloud/tests/test_playwright.py index f6ec81a..893865c 100644 --- a/cloud/tests/test_playwright.py +++ b/cloud/tests/test_playwright.py @@ -359,5 +359,63 @@ class MCPTest(unittest.TestCase): self.assertIn(name, names) +class LineReaderTest(unittest.TestCase): + """Pipelined stdin lines must all arrive (no select starvation). + + Regression test for the live failure where the MCP answered + initialize and then hung forever: the client wrote + notifications/initialized + tools/call back-to-back, one + readline() buffered both, and select() never saw the second + line again because the fd was drained. + """ + + def test_pipelined_lines_all_delivered(self): + import time + from playwright_mcp import _LineReader + rfd, wfd = os.pipe() + try: + reader = _LineReader(rfd) + os.write(wfd, b"init\n") + lines, drained = reader.poll() + self.assertEqual(lines, ["init"]) + self.assertFalse(drained) + # Second batch arrives as one write, like a real client + # sending notification + next request back-to-back. + os.write(wfd, b"notif\nnavigate\n") + # Simulate the drain-loop delay: both lines sit in the + # pipe before the next poll. + time.sleep(0.2) + got = [] + deadline = time.time() + 2 + while len(got) < 2 and time.time() < deadline: + lines, drained = reader.poll() + got.extend(lines) + if not lines: + time.sleep(0.02) + self.assertEqual(got, ["notif", "navigate"]) + finally: + os.close(wfd) + os.close(rfd) + + def test_partial_line_flushed_at_eof(self): + from playwright_mcp import _LineReader + rfd, wfd = os.pipe() + try: + reader = _LineReader(rfd) + os.write(wfd, b"partial-no-newline") + os.close(wfd) + lines, drained = reader.poll() + self.assertEqual(lines, []) + self.assertFalse(drained) + lines, drained = reader.poll() + self.assertEqual(lines, ["partial-no-newline"]) + self.assertTrue(drained) + finally: + try: + os.close(rfd) + except OSError: + pass + + if __name__ == "__main__": unittest.main() diff --git a/cloud/worker.py b/cloud/worker.py index 9d4978b..662ac2b 100644 --- a/cloud/worker.py +++ b/cloud/worker.py @@ -5332,7 +5332,8 @@ class Worker: cached_input_tokens=usage_total.get( "cached_input_tokens", 0), usd_micros=micros, - source="actual", fallback_count=fallback_count) + source="actual", fallback_count=fallback_count, + completions=usage_total.get("completions", 0)) else: micros = None total = estimate_tokens(task.get("prompt") or "", stdout)