@@ -33,11 +33,16 @@ async function requestJson(path: string, options: AnyRecord = {}): Promise<any>
const response = await fetch ( path , { credentials : "same-origin" , . . . options , body , headers } ) ;
const text = await response . text ( ) ;
let payload = null ;
let parseFailed = false ;
try {
payload = text ? JSON . parse ( text ) : null ;
} catch {
parseFailed = true ;
payload = { text } ;
}
if ( parseFailed ) {
throw new Error ( ` Codex Queue 返回了无效 JSON( ${ text . length } bytes),可能是代理响应过大或被截断 ` ) ;
}
if ( ! response . ok || payload ? . ok === false ) {
const message = payload ? . error ? . message || payload ? . error || ` HTTP ${ response . status } ` ;
const error = new Error ( message ) ;
@@ -106,10 +111,62 @@ function taskRows(data: any): any[] {
return Array . isArray ( data ? . tasks ) ? data . tasks : [ ] ;
}
function taskSortValue ( task : any ) : number {
const time = Date . parse ( String ( task ? . updatedAt || task ? . createdAt || "" ) ) ;
return Number . isFinite ( time ) ? time : 0 ;
}
function mergeTaskRows ( groups : any [ ] [ ] , activeTaskId = "" ) : any [ ] {
const byId = new Map < string , any > ( ) ;
for ( const group of groups ) {
for ( const task of group ) {
const id = String ( task ? . id || "" ) ;
if ( id . length > 0 && ! byId . has ( id ) ) byId . set ( id , task ) ;
}
}
const statusRank : Record < string , number > = { running : 0 , judging : 1 , retry_wait : 2 , queued : 3 } ;
return Array . from ( byId . values ( ) ) . sort ( ( left , right ) = > {
const leftActive = String ( left ? . id || "" ) === activeTaskId ? 0 : 1 ;
const rightActive = String ( right ? . id || "" ) === activeTaskId ? 0 : 1 ;
if ( leftActive !== rightActive ) return leftActive - rightActive ;
const rankDelta = ( statusRank [ String ( left ? . status || "" ) ] ? ? 9 ) - ( statusRank [ String ( right ? . status || "" ) ] ? ? 9 ) ;
if ( rankDelta !== 0 ) return rankDelta ;
return taskSortValue ( right ) - taskSortValue ( left ) ;
} ) ;
}
async function loadTaskQueue ( apiBaseUrl : string , healthResult : any ) : Promise < any > {
const statuses = [ "running" , "judging" , "retry_wait" , "queued" ] ;
const results = await Promise . all ( statuses . map ( async ( status ) = > {
try {
return await requestJson ( codexApi ( apiBaseUrl , ` /api/tasks?status= ${ encodeURIComponent ( status ) } &limit=80 ` ) ) ;
} catch {
return null ;
}
} ) ) ;
const historyResult = await requestJson ( codexApi ( apiBaseUrl , "/api/tasks?limit=160" ) ) . catch ( ( ) = > null ) ;
const queue = results . find ( ( item ) = > item ? . queue ) ? . queue || historyResult ? . queue || healthResult ? . queue || healthResult ? . body ? . queue || { } ;
const rows = mergeTaskRows ( [ . . . results . map ( ( item ) = > taskRows ( item ) ) , taskRows ( historyResult ) ] , String ( queue ? . activeTaskId || "" ) ) ;
if ( rows . length > 0 ) return { ok : true , queue , tasks : rows } ;
return requestJson ( codexApi ( apiBaseUrl , "/api/tasks?limit=5" ) ) ;
}
function taskOutput ( task : any ) : any [ ] {
return Array . isArray ( task ? . output ) ? task . output : [ ] ;
}
function taskTranscript ( task : any ) : any [ ] {
if ( Array . isArray ( task ? . transcript ) ) return task . transcript ;
return taskOutput ( task ) . map ( ( item : any ) = > ( {
seq : item.seq ,
at : item.at ,
kind : item.channel === "error" ? "error" : item . channel === "command" ? "ran" : "message" ,
title : item.method || item . channel || "message" ,
bodyPreview : String ( item . text || "" ) ,
rawSeqs : [ item . seq ] ,
} ) ) ;
}
function taskAttempts ( task : any ) : any [ ] {
return Array . isArray ( task ? . attempts ) ? task . attempts : [ ] ;
}
@@ -125,6 +182,11 @@ function splitPromptTasks(prompt: string): string[] {
. filter ( Boolean ) ;
}
function repeatCountValue ( value : any ) : number {
const count = Number ( value ) ;
return Number . isFinite ( count ) ? Math . max ( 1 , Math . min ( 50 , Math . floor ( count ) ) ) : 1 ;
}
function channelLabel ( channel : string ) : string {
const labels : Record < string , string > = {
system : "SYS" ,
@@ -139,15 +201,63 @@ function channelLabel(channel: string): string {
return labels [ channel ] || channel . toUpperCase ( ) ;
}
function transcriptKindLabel ( kind : string ) : string {
const labels : Record < string , string > = {
ran : "Ran" ,
explored : "Explored" ,
edited : "Edited" ,
plan : "Plan" ,
message : "Message" ,
system : "System" ,
error : "Error" ,
} ;
return labels [ kind ] || "Message" ;
}
function omittedLabel ( lines : any ) : string {
const count = Number ( lines || 0 ) ;
return Number . isFinite ( count ) && count > 0 ? ` … + ${ Math . floor ( count ) } lines ` : "" ;
}
function taskIsActive ( task : any ) : boolean {
return [ "running" , "judging" , "retry_wait" ] . includes ( String ( task ? . status || "" ) ) ;
}
function taskIsTerminal ( task : any ) : boolean {
return [ "succeeded" , "failed" , "canceled" ] . includes ( String ( task ? . status || "" ) ) ;
}
function taskHasDetail ( task : any ) : boolean {
return Boolean ( task ? . _detailLoaded )
|| ( Array . isArray ( task ? . transcript ) && task . transcript . length > 0 )
|| ( Array . isArray ( task ? . output ) && task . output . length > 0 )
|| ( Array . isArray ( task ? . events ) && task . events . length > 0 ) ;
}
function mergeTranscriptRows ( existing : any [ ] , incoming : any [ ] ) : any [ ] {
const byKey = new Map < string , any > ( ) ;
for ( const item of [ . . . ( Array . isArray ( existing ) ? existing : [ ] ) , . . . ( Array . isArray ( incoming ) ? incoming : [ ] ) ] ) {
const key = ` ${ Number ( item ? . seq ? ? 0 ) } : ${ String ( item ? . kind || "message" ) } ` ;
byKey . set ( key , item ) ;
}
return Array . from ( byKey . values ( ) ) . sort ( ( left , right ) = > Number ( left ? . seq ? ? 0 ) - Number ( right ? . seq ? ? 0 ) ) ;
}
function transcriptMaxSeq ( transcript : any [ ] ) : number {
return ( Array . isArray ( transcript ) ? transcript : [ ] ) . reduce ( ( max , item ) = > Math . max ( max , Number ( item ? . seq ? ? 0 ) ) , 0 ) ;
}
function countValue ( counts : AnyRecord , key : string ) : string {
const value = Number ( counts [ key ] ? ? 0 ) ;
return Number . isFinite ( value ) ? String ( value ) : "0" ;
}
function codexModelOptions ( queue : any , currentModel : string ) : string [ ] {
const configured = Array . isArray ( queue ? . codexModels ) ? queue . codexModels : [ ] ;
const fallback = [ "gpt-5.4-mini" , "gpt-5.4" , "gpt-5.5" ] ;
return Array . from ( new Set ( [ . . . configured , . . . fallback , currentModel ] . map ( ( item ) = > String ( item || "" ) . trim ( ) ) . filter ( Boolean ) ) ) ;
}
function TaskCard ( { task , selected , onSelect } : AnyRecord ) {
const judge = task ? . lastJudge || { } ;
return h ( "button" , {
@@ -169,22 +279,95 @@ function TaskCard({ task, selected, onSelect }: AnyRecord) {
) ;
}
function Transcript ( { task , autoScroll } : AnyRecord ) {
function TaskListSection ( { title , tasks , selectedId , onSelect , emptyText } : AnyRecord ) {
const rows = Array . isArray ( tasks ) ? tasks : [ ] ;
return h ( "section" , { className : "codex-task-section" } ,
h ( "div" , { className : "codex-task-section-head" } ,
h ( "span" , null , title ) ,
h ( "code" , null , String ( rows . length ) ) ,
) ,
rows . length === 0
? h ( "p" , { className : "codex-task-section-empty" } , emptyText )
: h ( "div" , { className : "codex-task-section-list" } , rows . map ( ( task : any ) = > h ( TaskCard , {
key : task.id ,
task ,
selected : selectedId === task . id ,
onSelect : ( ) = > onSelect ( task . id ) ,
} ) ) ) ,
) ;
}
function Transcript ( { task , autoScroll , loading } : AnyRecord ) {
const ref = useRef < HTMLDivElement | null > ( null ) ;
const output = taskOutput ( task ) ;
const transcript = taskTranscript ( task ) ;
useEffect ( ( ) = > {
if ( autoScroll && ref . current ) ref . current . scrollTop = ref . current . scrollHeight ;
} , [ autoScroll , output . length , task ? . id ] ) ;
} , [ autoScroll , transcript . length , task ? . id ] ) ;
if ( ! task ) return h ( EmptyState , { title : "未选择任务" , text : "从左侧队列选择任务,或提交新 Codex 任务。" } ) ;
if ( loading && ! taskHasDetail ( task ) ) return h ( "div" , { className : "codex-transcript" , ref , "data-testid" : "codex-output" } ,
h ( "div" , { className : "codex-output-empty" } , "正在加载完整 session 记录..." ) ,
) ;
return h ( "div" , { className : "codex-transcript" , ref , "data-testid" : "codex-output" } ,
output . length === 0 ? h ( "div" , { className : "codex-output-empty" } , "等待 Codex 输出..." ) : output . map ( ( item : any ) = > h ( "article" , { key : ` ${ item . seq } - ${ item . channel } ` , className : ` codex-output-line ${ item . channel || "system" } ` } ,
h ( "div" , { className : "codex-output-meta" } ,
h ( "sp an" , { className : "codex-output-channel" } , channelLabel ( String ( item . channel || "sys tem ") ) ) ,
h ( "span" , null , fmtDate ( item . at ) ) ,
item . method ? h ( "code" , null , item . method ) : null ,
transcript . length === 0 ? h ( "div" , { className : "codex-output-empty" } , "等待 Codex 输出..." ) : transcript . map ( ( item : any ) = > {
const kind = String ( item . kind || "message" ) ;
const isCommand = [ "r an" , "explored" , "edi ted "] . includes ( kind ) ;
const commandMore = omittedLabel ( item . commandOmittedLines ) ;
const bodyMore = omittedLabel ( item . bodyOmittedLines ) ;
const commandText = String ( item . commandPreview || ( isCommand ? item . title || "" : "" ) ) ;
return h ( "article" , { key : ` ${ item . seq } - ${ kind } ` , className : ` codex-transcript-item ${ kind } ` } ,
h ( "div" , { className : "codex-transcript-bullet" } , "•" ) ,
h ( "div" , { className : "codex-transcript-main" } ,
h ( "div" , { className : "codex-transcript-title" } ,
h ( "span" , { className : "codex-output-channel" } , transcriptKindLabel ( kind ) ) ,
isCommand ? null : h ( "strong" , null , String ( item . title || transcriptKindLabel ( kind ) ) ) ,
item . status ? h ( "code" , null , String ( item . status ) ) : null ,
h ( "time" , null , fmtDate ( item . at ) ) ,
) ,
commandText ? h ( "pre" , { className : "codex-transcript-command" } ,
commandText ,
commandMore ? ` \ n ${ commandMore } ` : "" ,
) : null ,
item . bodyPreview ? h ( "pre" , { className : "codex-transcript-body" } ,
String ( item . bodyPreview ) ,
bodyMore ? ` \ n ${ bodyMore } (查看原始JSON获取完整记录) ` : "" ,
) : null ,
) ,
) ;
} ) ,
) ;
}
function PromptDetail ( { task } : AnyRecord ) {
if ( ! task ) return h ( EmptyState , { title : "未选择任务" , text : "选择队列或历史 session 后,这里显示完整 prompt、模型和工作目录。" } ) ;
const promptText = String ( task ? . prompt || "" ) ;
const lines = promptText . length > 0 ? promptText . split ( / \ r \ n | \ r | \ n / u ) . l e n g t h : 0 ;
return h ( "div" , { className : "codex-prompt-detail" , "data-testid" : "codex-task-prompt-detail" } ,
h ( "div" , { className : "codex-prompt-meta" } ,
h ( StatusBadge , { status : task?.status } , task ? . status || "unknown" ) ,
h ( "span" , null , ` model= ${ task ? . model || "--" } ` ) ,
h ( "span" , null , ` cwd= ${ task ? . cwd || "--" } ` ) ,
h ( "span" , null , ` created= ${ fmtDate ( task ? . createdAt ) } ` ) ,
h ( "span" , null , ` ${ lines } lines / ${ promptText . length } chars ` ) ,
) ,
h ( "pre" , { className : "codex-prompt-full" , "data-testid" : "codex-task-prompt-full" } , promptText || "空 prompt" ) ,
) ;
}
function RawTranscript ( { task } : AnyRecord ) {
const output = taskOutput ( task ) ;
if ( ! task || output . length === 0 ) return h ( EmptyState , { title : "暂无原始消息" , text : "原始 Codex app-server 消息会保留在任务 JSON 中。" } ) ;
return h ( "details" , { className : "codex-raw-output" } ,
h ( "summary" , null , ` 原始 messages ( ${ output . length } ) ` ) ,
h ( "div" , null ,
output . map ( ( item : any ) = > h ( "article" , { key : ` ${ item . seq } - ${ item . channel } ` , className : ` codex-output-line ${ item . channel || "system" } ` } ,
h ( "div" , { className : "codex-output-meta" } ,
h ( "span" , { className : "codex-output-channel" } , channelLabel ( String ( item . channel || "system" ) ) ) ,
h ( "span" , null , fmtDate ( item . at ) ) ,
item . method ? h ( "code" , null , item . method ) : null ,
) ,
h ( "pre" , null , String ( item . text || "" ) ) ,
) ) ,
) ,
h ( "pre" , null , String ( item . text || "" ) ) ,
) ) ,
) ;
}
@@ -215,21 +398,31 @@ function AttemptTable({ task }: AnyRecord) {
export function CodexQueuePage ( { microservices , onRaw , apiBaseUrl = "/api" } : AnyRecord ) {
const service = microservices . find ( ( item : any ) = > item . id === "codex-queue" ) || null ;
const selectedIdRef = useRef ( "" ) ;
const queueLoadTokenRef = useRef ( 0 ) ;
const detailLoadTokenRef = useRef ( 0 ) ;
const detailInFlightRef = useRef < { taskId : string ; token : number ; promise : Promise < void > } | null > ( null ) ;
const sessionCacheRef = useRef < Map < string , AnyRecord > > ( new Map ( ) ) ;
const [ health , setHealth ] = useState ( null ) ;
const [ tasksData , setTasksData ] = useState ( null ) ;
const [ selectedId , setSelectedId ] = useState ( "" ) ;
const [ selectedTask , setSelectedTask ] = useState ( null ) ;
const [ selectedDetailLoading , setSelectedDetailLoading ] = useState ( false ) ;
const [ prompt , setPrompt ] = useState ( "请在 UniDesk 工作区中完成一个很小的验证任务:读取 package.json 并总结项目名称,不要修改文件。" ) ;
const [ model , setModel ] = useState ( "gpt-5.4-mini" ) ;
const [ cwd , setCwd ] = useState ( "/workspace " ) ;
const [ cwd , setCwd ] = useState ( "/root/unidesk " ) ;
const [ maxAttempts , setMaxAttempts ] = useState ( 3 ) ;
const [ repeatCount , setRepeatCount ] = useState ( 1 ) ;
const [ steerPrompt , setSteerPrompt ] = useState ( "" ) ;
const [ autoScroll , setAutoScroll ] = useState ( true ) ;
const [ queueSidebarOpen , setQueueSidebarOpen ] = useState ( true ) ;
const [ busy , setBusy ] = useState ( false ) ;
const [ error , setError ] = useState ( "" ) ;
const [ refreshedAt , setRefreshedAt ] = useState ( null ) ;
const tasks = taskRows ( tasksData ) ;
const queuedTasks = tasks . filter ( ( task : any ) = > ! taskIsTerminal ( task ) ) ;
const historyTasks = tasks . filter ( ( task : any ) = > taskIsTerminal ( task ) ) ;
const queue = tasksData ? . queue || health ? . body ? . queue || health ? . queue || { } ;
const counts = queueCounts ( queue ) ;
const activeTaskId = queue ? . activeTaskId || tasks . find ( ( task : any ) = > taskIsActive ( task ) ) ? . id || "" ;
@@ -237,28 +430,122 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An
const repository = service ? microserviceRepository ( service ) : { } ;
const backend = service ? microserviceBackend ( service ) : { } ;
const promptParts = useMemo ( ( ) = > splitPromptTasks ( prompt ) , [ prompt ] ) ;
const enqueueItems = useMemo ( ( ) = > {
const count = repeatCountValue ( repeatCount ) ;
return promptParts . flatMap ( ( text ) = > Array . from ( { length : count } , ( ) = > text ) ) ;
} , [ promptParts , repeatCount ] ) ;
const codexModels = codexModelOptions ( queue , model ) ;
const selectedCanSteer = selectedTask ? . id && selectedTask ? . activeTurnId && String ( selectedTask ? . status ) === "running" ;
const selectedCanInterrupt = selectedTask ? . id && ! [ "succeeded" , "failed" , "canceled" ] . includes ( String ( selectedTask ? . status || "" ) ) ;
const selectedCanRetry = selectedTask ? . id && [ "succeeded" , "failed" , "canceled" ] . includes ( String ( selectedTask ? . status || "" ) ) ;
async function load ( preferId = selectedId ) : Promise < void > {
function publishCachedTask ( taskId : string , patch : AnyRecord , token : number ) : AnyRecord {
const cached = sessionCacheRef . current . get ( taskId ) || { } ;
const existingTask = cached . task || { } ;
const nextTranscript = Object . prototype . hasOwnProperty . call ( patch , "transcript" )
? patch . transcript
: ( Array . isArray ( existingTask . transcript ) ? existingTask . transcript : [ ] ) ;
const task = {
. . . existingTask ,
. . . patch ,
transcript : nextTranscript ,
output : Array.isArray ( patch . output ) ? patch . output : ( Array . isArray ( existingTask . output ) ? existingTask . output : [ ] ) ,
events : Array.isArray ( patch . events ) ? patch . events : ( Array . isArray ( existingTask . events ) ? existingTask . events : [ ] ) ,
} ;
const entry = {
. . . cached ,
task ,
maxSeq : transcriptMaxSeq ( nextTranscript ) ,
complete : Boolean ( patch . _transcriptComplete ? ? cached . complete ) ,
} ;
sessionCacheRef . current . set ( taskId , entry ) ;
if ( token === detailLoadTokenRef . current && selectedIdRef . current === taskId ) setSelectedTask ( task ) ;
return entry ;
}
async function loadTaskDetail ( taskId : string ) : Promise < void > {
if ( ! service || ! taskId ) return ;
const token = detailLoadTokenRef . current ;
const cached = sessionCacheRef . current . get ( taskId ) ;
if ( cached ? . task ) {
setSelectedTask ( cached . task ) ;
setSelectedDetailLoading ( false ) ;
if ( cached . complete && taskIsTerminal ( cached . task ) ) return ;
} else {
setSelectedDetailLoading ( true ) ;
}
const existing = detailInFlightRef . current ;
if ( existing ? . taskId === taskId && existing . token === token ) return existing . promise ;
const promise = ( async ( ) = > {
try {
const meta = await requestJson ( codexApi ( apiBaseUrl , ` /api/tasks/ ${ encodeURIComponent ( taskId ) } ?meta=1 ` ) ) ;
if ( token !== detailLoadTokenRef . current || selectedIdRef . current !== taskId ) return ;
const current = sessionCacheRef . current . get ( taskId ) ;
const currentTranscript = Array . isArray ( current ? . task ? . transcript ) ? current . task . transcript : [ ] ;
const metaTask = meta ? . task || { } ;
publishCachedTask ( taskId , { . . . metaTask , transcript : currentTranscript , _detailLoaded : currentTranscript.length > 0 , _transcriptComplete : current?.complete } , token ) ;
const startSeq = currentTranscript . length > 0 && ! taskIsTerminal ( metaTask )
? Math . max ( 0 , transcriptMaxSeq ( currentTranscript ) - 1 )
: transcriptMaxSeq ( currentTranscript ) ;
let afterSeq = current ? . complete && taskIsTerminal ( metaTask ) ? transcriptMaxSeq ( currentTranscript ) : startSeq ;
let hasMore = true ;
while ( hasMore ) {
const chunk = await requestJson ( codexApi ( apiBaseUrl , ` /api/tasks/ ${ encodeURIComponent ( taskId ) } /transcript?afterSeq= ${ encodeURIComponent ( String ( afterSeq ) ) } &limit=32 ` ) ) ;
if ( token !== detailLoadTokenRef . current || selectedIdRef . current !== taskId ) return ;
const cachedNow = sessionCacheRef . current . get ( taskId ) ;
const existingTranscript = Array . isArray ( cachedNow ? . task ? . transcript ) ? cachedNow . task . transcript : [ ] ;
const mergedTranscript = mergeTranscriptRows ( existingTranscript , Array . isArray ( chunk ? . transcript ) ? chunk . transcript : [ ] ) ;
const complete = Boolean ( ! chunk ? . hasMore ) ;
publishCachedTask ( taskId , {
status : chunk?.status || metaTask . status ,
updatedAt : chunk?.updatedAt || metaTask . updatedAt ,
transcript : mergedTranscript ,
_detailLoaded : complete || mergedTranscript . length > 0 ,
_transcriptComplete : complete ,
} , token ) ;
hasMore = Boolean ( chunk ? . hasMore ) ;
afterSeq = Number ( chunk ? . nextAfterSeq ? ? transcriptMaxSeq ( mergedTranscript ) ) ;
if ( ! hasMore ) break ;
await new Promise ( ( resolve ) = > window . setTimeout ( resolve , 0 ) ) ;
}
} finally {
if ( detailInFlightRef . current ? . taskId === taskId && detailInFlightRef . current ? . token === token ) detailInFlightRef . current = null ;
if ( token === detailLoadTokenRef . current && selectedIdRef . current === taskId ) setSelectedDetailLoading ( false ) ;
}
} ) ( ) ;
detailInFlightRef . current = { taskId , token , promise } ;
await promise ;
}
async function load ( preferId = selectedIdRef . current ) : Promise < void > {
if ( ! service ) return ;
const [ healthResult , tasksResult ] = await Promise . all ( [
requestJson ( ` ${ apiBaseUrl } /microservices/codex-queue/health ` ) ,
requestJson ( codexApi ( apiBaseUrl , "/api/tasks?limit=80 ") ) ,
] ) ;
const token = queueLoadTokenRef . current + 1 ;
queueLoadTokenRef . current = token ;
const requestedId = String ( preferId || selectedIdRef . current || " ") ;
const healthResult = await requestJson ( ` ${ apiBaseUrl } /microservices/codex-queue/health ` ) ;
const tasksResult = await loadTaskQueue ( apiBaseUrl , healthResult ) ;
if ( token !== queueLoadTokenRef . current ) return ;
setHealth ( healthResult ) ;
setTasksData ( tasksResult ) ;
const rows = taskRows ( tasksResult ) ;
const nextId = preferId && rows . some ( ( task : any ) = > task . id === preferId )
? preferId
const latestRequestedId = requestedId || selectedIdRef . current ;
const nextId = latestRequestedId && rows . some ( ( task : any ) = > task . id === latestRequestedId )
? latestRequestedId
: ( tasksResult ? . queue ? . activeTaskId || rows . find ( ( task : any ) = > taskIsActive ( task ) ) ? . id || rows [ 0 ] ? . id || "" ) ;
const previousId = selectedIdRef . current ;
if ( previousId !== nextId ) detailLoadTokenRef . current += 1 ;
selectedIdRef . current = nextId ;
setSelectedId ( nextId ) ;
if ( nextId ) {
const detail = await requestJson ( codexApi ( apiBaseUrl , ` /api/tasks/ ${ encodeURIComponent ( nextId ) } ` ) ) ;
setSelectedTask ( detail ? . task || null ) ;
} else {
const row = rows . find ( ( task : any ) = > task . id === nextId ) ;
if ( row ) {
const cached = sessionCacheRef . current . get ( nextId ) ;
if ( cached ? . task ) sessionCacheRef . current . set ( nextId , { . . . cached , task : { . . . row , . . . cached . task , status : row.status , updatedAt : row.updatedAt } } ) ;
}
if ( nextId ) void loadTaskDetail ( nextId ) . catch ( ( err ) = > setError ( errorMessage ( err , "加载 Codex session 详情失败" ) ) ) ;
else {
detailLoadTokenRef . current += 1 ;
setSelectedTask ( null ) ;
setSelectedDetailLoading ( false ) ;
}
setRefreshedAt ( new Date ( ) ) ;
}
@@ -278,12 +565,13 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An
async function enqueue ( event : any ) : Promise < void > {
event . preventDefault ( ) ;
await guarded ( async ( ) = > {
if ( promptParts . length === 0 ) throw new Error ( "prompt 不能为空" ) ;
const body = promptParts . length === 1
? { prompt : promptParts [ 0 ] , model , cwd , maxAttempts : Number ( maxAttempts ) }
: { tasks : promptParts.map ( ( text ) = > ( { prompt : text , model , cwd , maxAttempts : Number ( maxAttempts ) } ) ) } ;
const result = await requestJson ( codexApi ( apiBaseUrl , promptParts . length === 1 ? "/api/tasks" : "/api/tasks/batch" ) , { method : "POST" , body } ) ;
if ( enqueueItems . length === 0 ) throw new Error ( "prompt 不能为空" ) ;
const body = enqueueItems . length === 1
? { prompt : enqueueItems [ 0 ] , model , cwd , maxAttempts : Number ( maxAttempts ) }
: { tasks : enqueueItems.map ( ( text ) = > ( { prompt : text , model , cwd , maxAttempts : Number ( maxAttempts ) } ) ) } ;
const result = await requestJson ( codexApi ( apiBaseUrl , enqueueItems . length === 1 ? "/api/tasks" : "/api/tasks/batch" ) , { method : "POST" , body } ) ;
const firstId = result ? . tasks ? . [ 0 ] ? . id || "" ;
selectedIdRef . current = firstId ;
await load ( firstId ) ;
} , "Codex 任务入队失败" ) ;
}
@@ -314,17 +602,85 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An
} , "重新入队失败" ) ;
}
function selectTask ( taskId : string ) : void {
selectedIdRef . current = taskId ;
detailLoadTokenRef . current += 1 ;
setSelectedId ( taskId ) ;
const cached = sessionCacheRef . current . get ( taskId ) ;
if ( cached ? . task ) {
setSelectedTask ( cached . task ) ;
setSelectedDetailLoading ( false ) ;
} else {
setSelectedDetailLoading ( true ) ;
const row = tasks . find ( ( task : any ) = > task . id === taskId ) ;
if ( row ) setSelectedTask ( row ) ;
else setSelectedTask ( null ) ;
}
void load ( taskId ) . catch ( ( err ) = > setError ( errorMessage ( err , "切换 Codex session 失败" ) ) ) ;
}
useEffect ( ( ) = > {
void guarded ( ( ) = > load ( ) , "Codex Queue 加载失败" ) ;
void guarded ( ( ) = > load ( selectedIdRef . current ) , "Codex Queue 加载失败" ) ;
} , [ service ? . id , service ? . runtime ? . providerStatus ] ) ;
useEffect ( ( ) = > {
if ( ! service ) return undefined ;
const timer = window . setInterval ( ( ) = > {
void load ( selectedId ) . catch ( ( err ) = > setError ( errorMessage ( err , "Codex Queue 轮询失败" ) ) ) ;
void load ( selectedIdRef . current ) . catch ( ( err ) = > setError ( errorMessage ( err , "Codex Queue 轮询失败" ) ) ) ;
} , 1500 ) ;
return ( ) = > window . clearInterval ( timer ) ;
} , [ service ? . id , selectedId ] ) ;
} , [ service ? . id ] ) ;
const taskListContent = tasks . length === 0 ? h ( EmptyState , { title : "队列为空" , text : "提交一个任务后,Codex 会串行执行并保存输出。" } ) : [
h ( TaskListSection , {
key : "active" ,
title : "运行 / 排队" ,
tasks : queuedTasks ,
selectedId ,
emptyText : "当前没有运行或排队任务。" ,
onSelect : selectTask ,
} ) ,
h ( TaskListSection , {
key : "history" ,
title : "历史 session" ,
tasks : historyTasks ,
selectedId ,
emptyText : "最近没有完成、失败或取消的 session。" ,
onSelect : selectTask ,
} ) ,
] ;
const sessionPanel = h ( Panel , {
title : selectedTask ? ` Session ${ String ( selectedTask . id ) . slice ( 0 , 22 ) } ` : "Session 输出" ,
eyebrow : selectedTask ? ` ${ selectedTask . status } / ${ selectedTask . model } ` : "Codex CLI-like stream" ,
actions : h ( "div" , { className : "panel-actions" } ,
h ( "button" , { type : "button" , className : "ghost-btn" , onClick : ( ) = > setQueueSidebarOpen ( ( value : boolean ) = > ! value ) , "data-testid" : "codex-queue-sidebar-toggle" } , queueSidebarOpen ? "收起队列" : "显示队列" ) ,
h ( "label" , { className : "inline-check" } , h ( "input" , { type : "checkbox" , checked : autoScroll , onChange : ( event : any ) = > setAutoScroll ( Boolean ( event . target . checked ) ) } ) , "自动滚动" ) ,
h ( "button" , { type : "button" , className : "ghost-btn" , disabled : ! selectedCanInterrupt || busy , onClick : ( ) = > void interrupt ( ) , "data-testid" : "codex-interrupt-button" } , "打断" ) ,
h ( "button" , { type : "button" , className : "ghost-btn" , disabled : ! selectedCanRetry || busy , onClick : ( ) = > void retry ( ) } , "重试" ) ,
selectedTask ? h ( RawButton , { title : "Codex Task" , data : selectedTask , onOpen : onRaw , testId : "raw-codex-task" } ) : null ,
) ,
className : "codex-output-panel" ,
} ,
h ( "div" , { className : ` codex-session-shell ${ queueSidebarOpen ? "" : "queue-collapsed" } ` } ,
queueSidebarOpen ? h ( "aside" , { className : "codex-session-sidebar" , "data-testid" : "codex-session-sidebar" } ,
h ( "div" , { className : "codex-session-sidebar-head" } ,
h ( "div" , null ,
h ( "span" , null , "Queue" ) ,
h ( "strong" , null , ` ${ tasks . length } sessions ` ) ,
) ,
h ( "button" , { type : "button" , className : "ghost-btn" , onClick : ( ) = > setQueueSidebarOpen ( false ) } , "收起" ) ,
) ,
h ( "div" , { className : "codex-task-list codex-task-list-session" } , taskListContent ) ,
) : null ,
h ( "div" , { className : "codex-session-main" } ,
h ( "div" , { className : "codex-output-stack" } ,
h ( Transcript , { task : selectedTask , autoScroll , loading : selectedDetailLoading } ) ,
h ( RawTranscript , { task : selectedTask } ) ,
) ,
) ,
) ,
) ;
if ( ! service ) return h ( EmptyState , { title : "Codex Queue 未登记" , text : "请在 config.json 的 microservices 中登记 id=codex-queue" } ) ;
@@ -350,7 +706,7 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An
h ( "div" , { className : "microservice-ref-card" } ,
h ( "span" , null , "Codex" ) ,
h ( "strong" , null , queue ? . defaultModel || "gpt-5.4-mini" ) ,
h ( "code" , null , "codex app-server --listen stdio://" ) ,
h ( "code" , null , ` models: ${ codexModels . join ( " / " ) } ` ) ,
) ,
h ( "div" , { className : "microservice-ref-card" } ,
h ( "span" , null , "Backend" ) ,
@@ -367,50 +723,33 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An
h ( MetricCard , { label : "异常/取消" , value : String ( Number ( counts . failed || 0 ) + Number ( counts . canceled || 0 ) ) , hint : "terminal non-success" , tone : Number ( counts . failed || 0 ) > 0 ? "fail" : "" } ) ,
h ( MetricCard , { label : "最近刷新" , value : refreshedAt ? fmtClock ( refreshedAt ) : "--" , hint : "1.5s polling" } ) ,
) ,
h ( "div" , { className : "codex-session-stage" } , sessionPanel ) ,
h ( "div" , { className : "codex-queue-layout" } ,
h ( "div" , { className : "codex-left-rail" } ,
h ( Panel , { title : "提交任务" , eyebrow : promptParts.length > 1 ? ` ${ promptParts . length } tasks ` : "Single or Batch" , className : "codex-compose-panel" } ,
h ( Panel , { title : "提交任务" , eyebrow : enqueueItems.length > 1 ? ` ${ enqueueItems . length } tasks ` : "Single or Batch" , className : "codex-compose-panel" } ,
h ( "form" , { className : "codex-task-form" , onSubmit : enqueue , "data-testid" : "codex-queue-task-form" } ,
h ( "label" , null , "Prompt / 多任务用单独一行 --- 分隔" ,
h ( "textarea" , { value : prompt , rows : 8 , onChange : ( event : any ) = > setPrompt ( event . target . value ) , placeholder : "写入 Codex 任务;多个任务之间用 --- 分隔。" } ) ,
) ,
h ( "div" , { className : "codex-form-grid" } ,
h ( "label" , null , "模型" , h ( "input" , { value : model , onChange : ( event : any ) = > setModel ( event . target . value ) , placeholder : "gpt-5.4-mini" } ) ) ,
h ( "label" , null , "工作目录" , h ( "inpu t", { value : cwd , onChange : ( event : any ) = > setCwd ( event . target . value ) , placeholder : "/workspace" } ) ) ,
h ( "label" , null , "模型" ,
h ( "selec t" , { value : model , onChange : ( event : any ) = > setModel ( event . target . value ) , "data-testid" : "codex-model-select" } ,
codexModels . map ( ( name ) = > h ( "option" , { key : name , value : name } , name ) ) ,
) ,
) ,
h ( "label" , null , "工作目录" , h ( "input" , { value : cwd , onChange : ( event : any ) = > setCwd ( event . target . value ) , placeholder : queue?.defaultWorkdir || "/root/unidesk" } ) ) ,
h ( "label" , null , "最大尝试" , h ( "input" , { type : "number" , min : 1 , max : 10 , value : maxAttempts , onChange : ( event : any ) = > setMaxAttempts ( Number ( event . target . value ) ) } ) ) ,
h ( "label" , null , "入队份数" , h ( "input" , { type : "number" , min : 1 , max : 50 , value : repeatCount , onChange : ( event : any ) = > setRepeatCount ( Number ( event . target . value ) ) , "data-testid" : "codex-repeat-count-input" } ) ) ,
) ,
h ( "button" , { type : "submit" , className : "primary-btn" , disabled : busy || promptParts . length === 0 } , promptParts . length > 1 ? ` 批量入队 ${ promptParts . length } 个任务 ` : "入队并运行" ) ,
) ,
) ,
h ( Panel , { title : "队列" , eyebrow : ` ${ tasks . length } visible ` } ,
h ( "div" , { className : "codex-task-list" } ,
tasks . length === 0 ? h ( EmptyState , { title : "队列为空" , text : "提交一个任务后,Codex 会串行执行并保存输出。" } ) : tasks . map ( ( task : any ) = > h ( TaskCard , {
key : task.id ,
task ,
selected : selectedId === task . id ,
onSelect : ( ) = > {
setSelectedId ( task . id ) ;
void load ( task . id ) ;
} ,
} ) ) ,
h ( "button" , { type : "submit" , className : "primary-btn" , disabled : busy || enqueueItems . length === 0 , "data-testid" : "codex-enqueue-button" } , enqueueItems . length > 1 ? ` 批量入队 ${ enqueueItems . length } 个任务 ` : "入队并运行" ) ,
) ,
) ,
) ,
h ( "div" , { className : "codex-main-stage" } ,
h ( Panel , {
title : selectedTask ? ` Session ${ String ( selectedTask . id ) . slice ( 0 , 22 ) } ` : "Session 输出" ,
eyebrow : selectedTask ? ` ${ selectedTask . status } / ${ selectedTask . model } ` : "Codex CLI-like stream" ,
actions : h ( "div" , { className : "panel-actions" } ,
h ( "label" , { className : "inline-check" } , h ( "input" , { type : "checkbox" , checked : autoScroll , onChange : ( event : any ) = > setAutoScroll ( Boolean ( event . target . checked ) ) } ) , "自动滚动" ) ,
h ( "button" , { type : "button" , className : "ghost-btn" , disabled : ! selectedCanInterrupt || busy , onClick : ( ) = > void interrupt ( ) , "data-testid" : "codex-interrupt-button" } , "打断" ) ,
h ( "button" , { type : "button" , className : "ghost-btn" , disabled : ! selectedCanRetry || busy , onClick : ( ) = > void retry ( ) } , "重试" ) ,
selectedTask ? h ( RawButton , { title : "Codex Task" , data : selectedTask , onOpen : onRaw , testId : "raw-codex-task" } ) : null ,
) ,
className : "codex-output-panel" ,
} ,
h ( Transcript , { task : selectedTask , autoScroll } ) ,
) ,
h ( "div" , { className : "codex-detail-grid" } ,
h ( Panel , { title : "Prompt 全量" , eyebrow : selectedTask ? String ( selectedTask . id ) : "selected task" , className : "codex-prompt-panel" } ,
h ( PromptDetail , { task : selectedTask } ) ,
) ,
h ( Panel , { title : "运行控制" , eyebrow : selectedCanSteer ? "Active turn steer" : "Steer when running" } ,
h ( "form" , { className : "codex-steer-form" , onSubmit : steer } ,
h ( "label" , null , "追加 prompt" ,